多数票不是权威状态:战斗仲裁器真正维护的是分歧责任

三名玩家在第 450 帧上报状态哈希,两人得到 0x7A,一人得到 0x31。服务端没有这一帧的完整权威状态,只有三份摘要。最容易写出的逻辑,是把 0x7A 宣布为正确答案,再把第三名玩家踢出房间。
这个实现很短,也很危险。多数只能说明两份计算结果相同,不能证明它们符合规则、没有共同缺陷,更不能恢复出第 450 帧的真实状态。若两台客户端运行了同一个错误版本,它们完全可能稳定地产生相同的错误哈希。仲裁器若把“人数较多”偷换成“状态正确”,系统就把一个检测机制抬成了并不具备的权威来源。
真正可落地的设计要先缩小承诺:状态哈希仲裁器不拥有战斗真相,它只拥有分歧记录、责任累积和处置建议。这个边界看似保守,却决定了重连、回放、反作弊和线上诊断能否继续建立在可解释的基础上。
谁拥有真相

在权威服务器战斗中,服务器模拟状态可以是真相;在纯帧同步房间里,输入序列与确定性规则共同定义真相;在只有客户端状态哈希的轻量架构里,仲裁器手中甚至没有足够材料重建真相。三种系统不能共用同一句“多数即正确”。
TankPlay 的 Arbiter 选择了第三种语义。它接收座位号、帧号、状态哈希和当前在线座位集合,等待当前在线成员完成报告,然后对哈希计数。它返回的结果只有两个维度:需要踢出的座位,以及房间是否进入可疑状态。它没有返回“正确状态”,也没有尝试把少数客户端覆盖成多数状态。
因此,状态所有权至少分成三层:战斗模拟拥有游戏状态;连接或房间层拥有在线席位;仲裁器拥有检查点投票、连续异常次数和处罚计数。仲裁器可以说“这个座位连续偏离当前多数”,却不能说“多数哈希对应的坦克位置一定正确”。调用方若需要修复状态,必须转向快照、权威重演或终止本局,而不是从一个 64 位摘要反推世界。
这也解释了为什么仲裁接口必须接收 online_seats,而不能只在内部维护一个固定玩家列表。谁有资格参与本轮判断,属于房间当前成员关系,不属于哈希统计本身。把成员关系复制进仲裁器,会产生第二份席位真相;断线重连发生时,两份真相迟早分叉。
两条硬约束
第一条不变量是:只有当前在线且属于本轮集合的座位,才能写入检查点。代码在入口同时检查在线集合是否为空、报告者是否在集合内。不在集合中的迟到包不能借旧身份改变当前裁决。这里防的不是普通脏数据,而是会话边界被历史消息穿透。
第二条不变量是:一次裁决只在当前在线成员全部提交后发生。检查点中的旧投票会先按最新在线集合清理,投票数不足当前在线人数时直接返回。这样,3 人房间变成 2 人房间后,已离线座位的旧哈希不会继续充当第三张票;新在线集合中的两个人都完成报告后,检查点才结束。
还有一条必须由调用方共同维持的不变量:收到 kicked 后,要立即从房间成员关系和后续仲裁输入中移除对应座位。Arbiter 负责发出处置事件,但不是连接管理器。若调用方继续把已踢座位放进 online_seats,后续一致报告可能重置它的处罚计数,等于让已经完成的处置被下一轮投票反向修改。模块边界不是“代码放在哪个文件”,而是每个状态转换由谁最终提交。
这三条约束把职责切得很清楚:成员层决定谁能投票,仲裁器决定分歧如何累计,房间层执行踢出,战斗或回放系统决定如何处理已经不可信的战局。
正常裁决

以三个在线座位为例。第 15 帧到达检查点后,座位 0 报告哈希 9,仲裁器只记录,不裁决;座位 1 也报告 9,仍然等待;座位 2 报告 8,此时三份报告齐全,哈希 9 获得两票,满足严格多数。
严格多数的条件是 count > floor(N/2)。三人房间需要至少两票,四人房间需要至少三票。使用“大于一半”而不是“数量最多”很重要:四人投成 2:2 时虽然存在并列第一,但不存在足以承担责任归因的多数。
多数形成后,与多数哈希一致的座位会清空处罚计数,不一致座位增加一次处罚。检查点随即删除,避免同一帧被重复完成。这个时序的实际含义是,处罚针对连续、可重复的偏离,而不是一次孤立差异。网络抖动不会改变确定性计算结果,但版本切换、未初始化字段、非法浮点路径或真实作弊都可能制造稳定偏离;连续性是区分瞬时异常和结构性问题的最低成本信息。
本地测试把这条正常路径锁定为可运行证据:三人中两份哈希相同、一份不同,少数座位第一次不会被踢;连续三个检查点都偏离多数时,第三次才产生踢出事件。当前 battle 包的五个仲裁测试全部通过,说明这些数字是现有实现约束,不是文章为了说明问题临时编出的参数。
多数的边界
多数裁决的时间复杂度是 O(N):先统计 N 份哈希,再遍历当前在线座位更新处罚。每个待完成检查点最多持有 N 份投票,当前实现最多保留两个检查点,因此仲裁状态的主要空间上界约为 2N 份投票,加上每个座位一个处罚计数。对小房间而言,这个成本几乎可以忽略。
但计算便宜不代表语义便宜。多数方案至少有三个明确边界。
其一,它无法处理共同错误。两个相同版本的客户端可能共享同一个确定性缺陷,于是错误结果自然成为多数。其二,它无法处理对称分裂。两人房间的 1:1、四人房间的 2:2 都没有多数,不能安全归责。其三,它依赖报告集合准确。若连接层错误地把已经失联的座位继续算作在线,检查点会一直等待一份不会到达的报告。
因此,多数哈希适合做低成本异常分类,不适合做状态恢复。真正需要恢复能力时,至少还要保存输入历史、逻辑版本、随机种子和可验证快照。哈希告诉我们“不同”,重演材料才有机会解释“从哪里开始不同”。两者用途不同,不能因为都与确定性有关就合并成一个模块。
处罚为何累积
直接踢出一次偏离者,看起来更果断,实质上把检测噪声全部转成玩家损失。当前实现采用三次处罚阈值,而且与多数一致时清零。这相当于一个非常小的状态机:0 → 1 → 2 → kicked,任何一次重新一致都会回到 0。
这个设计不是在证明“三次最科学”,而是在表达一个重要取舍:误杀成本高于延迟处置成本。三次检查点之间若间隔 15 帧,最短确认窗口就是 45 帧;若检查点频率改变,实际时间窗口也随之改变。因此阈值不能脱离检查点间隔讨论。以每秒 30 个逻辑帧为例,15 帧一次检查,三次连续偏离约对应 1.5 秒;若改成每 150 帧一次,相同阈值就意味着 15 秒。数字相同,处置体验完全不同。
当前代码把阈值写成行为而非配置,适合规则尚稳定、房间规模固定的阶段。等到游戏同时存在竞技赛、休闲赛和观战房间,误杀代价与作弊窗口不同,演进触发条件就出现了:不是立刻做一套通用策略引擎,而是先把“检查间隔 × 连续次数 = 最短处置窗口”作为模式级参数,确保每种模式仍可复算。
另一个容易忽略的点是,踢出事件只在处罚计数恰好到达 3 时产生。它不是每个后续检查点重复广播的状态。因此调用方必须把事件变成持久的房间成员变更;如果只把它当一条可能丢失的通知,第四次偏离不会自动补发第三次事件。
错误替代
一个常见替代方案是“选择票数最多的哈希,然后把其他客户端状态强制同步过去”。它在演示环境里很顺滑:检测到差异,立即把少数修正成多数,房间继续运行。但这个方案同时破坏了可解释性和安全性。
首先,哈希不是状态。只有摘要时根本没有可覆盖的数据。其次,即使多数客户端同时上传快照,也无法证明该快照是合法状态;恶意客户端只需控制多数席位,就能把伪造结果包装成修复来源。再次,强制覆盖会擦除首次分歧位置,使后续回放只看到“被修正后的结果”,真正的确定性缺陷反而更难定位。
更稳妥的分层是:仲裁器只输出分歧分类;房间策略决定继续、暂停或终止;回放系统保留输入和版本证据;若存在权威服务器,恢复只能使用权威快照。没有权威快照时,宁可把结果标记为可疑,也不要制造一个无法证明的“正确状态”。
另一个看似简单的方案,是只比较服务端与每个客户端的哈希。若服务器运行完整权威模拟,这当然更强,甚至不再需要客户端多数。但它会改变整个成本模型:服务器必须执行所有确定性逻辑,并承担与客户端版本兼容、CPU 容量和部署节奏的约束。是否升级到该方案,应由竞技公平要求和作弊损失驱动,而不是因为多数仲裁不够漂亮。
演进信号
当前设计适合小规模、固定席位、检查点稀疏的实时房间。它的价值在于状态少、边界清楚:两个待处理检查点、每座位一个处罚计数、一个房间级可疑标记。没有额外存储服务,也没有为了理论完备引入共识协议。
当以下信号出现时,才需要升级:可疑房间无法通过现有日志定位;跨版本玩家频繁产生稳定分歧;比赛结果需要申诉和可审计证据;同一玩家跨房间的异常需要关联;服务器已经具备完整权威模拟。对应演进分别是增加检查点诊断包、把逻辑版本纳入报告键、保留签名回放、引入跨局风险状态,以及最终改为服务端权威比对。
这些能力不应提前塞回 Arbiter。它只需要维护本局内、当前在线集合上的有限判断。把跨局信誉、连接执行、状态恢复和回放存储都装进仲裁器,会让一个本来可以单元测试的纯状态机变成线上控制中心。
架构判断可以归结为一句话:多数票能够支持责任归因,但不能创造权威真相。只要系统始终守住这个边界,仲裁器就能保持小而可靠;一旦把“相同的人多”写成“状态必然正确”,后续所有自动修复都会建立在未经证明的前提上。