客户端预测的确认边界:只重放未确认输入
动作游戏客户端在等待服务端快照时,必须立即模拟本地输入;确认到达后又不能把已经纳入权威状态的输入执行第二次。本探针把确认序号、确认位置、预测位置和待确认队列交给一个预测器所有,渲染层只能读取快照。输入使用递增序号,位移单位为世界单位,模拟步长视为固定。
// .tmp/daily-labs/2026-07-26-2000/prediction.js
export class PredictionState {
#confirmedSequence = 0;
#confirmedPosition = 0;
#predictedPosition = 0;
#pending = [];
get snapshot() {
return Object.freeze({
confirmedSequence: this.#confirmedSequence,
confirmedPosition: this.#confirmedPosition,
predictedPosition: this.#predictedPosition,
pending: this.#pending.map((input) => ({ ...input })),
});
}
}

所有权
PredictionState 是唯一模拟状态所有者。网络接收器提交确认,输入采集器追加命令,表现层读取不可变快照;三者都不能直接改位置。核心不变量是输入序号严格递增、确认不得倒退、待确认队列只保存权威状态尚未包含的命令。这样断线、重连或快照乱序不会把表现层修正误写回模拟。快照中的队列还执行逐项复制,调用方即使修改返回对象,也不能越过接口改变内部命令;这比只冻结最外层对象更接近真实只读边界。
// .tmp/daily-labs/2026-07-26-2000/prediction.js
pushInput(sequence, delta) {
if (!Number.isInteger(sequence) || sequence <= this.#confirmedSequence) {
throw new RangeError("input sequence must follow confirmation");
}
if (this.#pending.some((input) => input.sequence >= sequence)) {
throw new RangeError("input sequence must be strictly increasing");
}
const input = Object.freeze({ sequence, delta });
this.#pending.push(input);
this.#predictedPosition += delta;
return this.snapshot;
}
重放边界
正常时序先预测序号 1、2、3,再收到“序号 2、位置 4.5”的权威确认。预测器丢弃 1 和 2,只从 4.5 重放序号 3 的 -1,最终位置为 3.5。这里不计算“本地误差再减一次”,因为权威位置已经包含确认序号及其之前的全部模拟结果。确认点因此既是队列裁剪边界,也是重放起点;两者若使用不同的序号比较规则,就会出现漏执行或重复执行。
// .tmp/daily-labs/2026-07-26-2000/prediction.js
reconcile(confirmedSequence, authoritativePosition) {
if (
!Number.isInteger(confirmedSequence) ||
confirmedSequence < this.#confirmedSequence ||
confirmedSequence > this.#pending.at(-1)?.sequence
) {
throw new RangeError("confirmation is outside pending window");
}
const remaining = this.#pending.filter(
(input) => input.sequence > confirmedSequence,
);
const predictedPosition = remaining.reduce(
(position, input) => position + input.delta,
authoritativePosition,
);
this.#confirmedSequence = confirmedSequence;
this.#confirmedPosition = authoritativePosition;
this.#pending = remaining;
this.#predictedPosition = predictedPosition;
return this.snapshot;
}

越界失败
失败时序在队列只有序号 1、2 时收到序号 3 的确认。若先覆盖权威位置再发现序号越界,客户端会留下无法解释的半提交状态。实现先验证窗口、构造剩余队列并计算候选位置,最后一次性替换字段;异常因此保持确认序号、位置和队列不变。
// .tmp/daily-labs/2026-07-26-2000/prediction.test.js
test("invalid confirmation preserves prediction atomically", () => {
const state = new PredictionState();
state.pushInput(1, 3);
state.pushInput(2, 2);
const before = state.snapshot;
assert.throws(() => state.reconcile(3, 99), RangeError);
assert.deepEqual(state.snapshot, before);
});
行为证据
固定输入 (1,+3)、(2,+2)、(3,-1) 先得到位置 4;服务端确认序号 2 的位置为 4.5,重放后只剩序号 3,位置成为 3.5。测试覆盖本地预测、确认重放和失败保持。耗时只说明该探针本次执行完成,不构成网络延迟或性能结论。
$ cd $WORK_DIR/.tmp/daily-labs/2026-07-26-2000
$ npm test
{
"behavior": {
"predictedBeforeConfirmation": 4,
"predictedAfterConfirmation": 3.5,
"replayedSequences": [3],
"invalidConfirmationPreserved": true
},
"tests": {
"passed": 3,
"failed": 0,
"skipped": 0
},
"elapsedMs": 3.3498
}
exit_code=0
real=0.59s
架构取舍
保存输入而非逐帧状态,可把内存量约束为未确认窗口长度,但要求同一输入在相同前置状态上确定性执行。当前线性过滤与重放适合短窗口;当窗口增长到挤占帧预算,应改用环形缓冲并限制单帧重放 Tick。多实体共享交互时,确认单位应从单实体位置演进为世界快照,但“从权威状态开始、只重放确认点之后的输入、候选成功后再提交”三项不变量不能改变。
适用边界
探针没有模拟丢包、抖动、碰撞或视觉平滑,只证明确认窗口的状态转换。画面纠偏应在表现层对旧显示位置与新预测位置插值,不能反向污染模拟。若相同输入在重放后仍产生不同结果,问题属于模拟确定性、资源版本或 Tick 顺序;扩大插值时间只能隐藏症状,不能修复同步合同。