跳轉到

D-20261029-01|回合終局一致性:流內離場與無狀態終局判定

2026-10-29 關聯追補:「已知限制與後續」中的 issue 001122(斷線車仍計入回合積分與總名次)已由 D-20261029-02 解決。 同節的 issue 001123 已於 2026-10-29 以實作修正(race-ready 後續回合扣除斷線者),未另立決策。 「決定」第 1 項「catch-up 跳號時取跳號後的值」已由 D-20261029-03 細化為:載入後分 tick 追趕期間不排程本機輸入,pending leave 延到追趕結束後第一個排程步。「已知限制與後續」中引用 issue 001104 的斷線車預測基準分歧不在該決策範圍,改由 issue 001126 追蹤。

背景與驅動力

回合終局(terminalFrame、endReason、ranking)寫進 RoundResult,結算時逐欄比對,回合共識錨也以 terminalFrame 決定尾窗。任何一欄在誠實 peer 間不同,恢復端或部分 peer 就會成為少數而拒簽,整場無法結算。

改版前有兩個讓誠實 peer 終局分歧的缺口:

  • 離場不帶幀號(issue 001117):主動離場只有 control 通道的 leave 通知,網路斷線由各 peer 自己的 ICE 狀態判定。主執行緒收到後以 quit-peers 訊息整組替換模擬端的離場集合,之後每筆已確定記錄都用當下集合判定終局。生效幀取決於牆鐘,所以不同 peer 會在不同的已確定幀把離場套進存活判定與名次,純函式重現在常見情境即分歧。
  • 滑行窗不在快照內(issue 001115):失能滑行窗的起點與靜止連續幀數是消費端逐幀累積的 RoundEndState,不在引擎狀態與 state hash 內。catch-up 發出 reset(F) 時消費端重建這份狀態,恢復端從 F+1 重新起算;對齊幀落在滑行窗內時,恢復端與 live peers 的 terminalFrame 不同(重現:F=1080 時 live 1120、恢復端 1201)。

兩者的共同根因:終局依賴了不受 rollback 與快照保障的資料。D-20261028-03 已讓終局只在已確定幀判定,但判定所需的輸入本身仍在已確定狀態之外。

2026-10-28 使用者裁決:001117 採丙案(主動離場走已簽輸入流、網路斷線只進結算 forfeit),001115 採甲案(每車已雜湊欄位加無狀態終局判定)。 2026-10-29 使用者審閱草稿後同意採納,計畫中的五個裁決點依文末「定案紀錄」定案。

考慮過的選項

離場(001117):

  • 甲:主動離場與網路斷線都改成已簽輸入流內的 leave 事件。網路斷線沒有發送者可以簽,無法成立(否決)。
  • 乙:所有離場都退出終局判定與名次,只留在 forfeit 名單。主動離場者的車會以空輸入繼續跑,剩下的人可能要等到時限(否決)。
  • 丙:主動離場走已簽輸入流的 leave 事件,排在 c+D 的確定幀生效並受 rollback 保障;網路斷線只進 forfeit 名單(採納)。

滑行窗(001115):

  • 甲:每車在引擎內加入已雜湊的「最後移動幀」與「離開存活幀」,終局改為無狀態純函式,快照與 quorum 背書自動涵蓋滑行窗,sync wire 不變(採納)。
  • 乙:把消費端 RoundEndState 一併放進 sync-response。要改 sync wire,而且 provider 單方提供的消費端狀態沒有 quorum 背書(否決)。
  • 丙:catch-up 對齊幀不得落在滑行窗內。只能縮小觸發範圍,無法消除分歧(否決)。

quit 車輛的物理語意:

  • 留在物理世界、輸入忽略、進度凍結(採納):與既有物理行為最接近,不新增 body 停用的交易。
  • 比照 chassis 退役移出世界:要新增一條退役路徑與對應快照驗證,且會改變其他車的碰撞環境(否決)。

決定

1. 流內離場

  • InputEvent 新增 LeaveInput({ type: 'leave' })。input wire 只新增一種 event type byte:2=leave,三個 byte 固定為 [2,0,0]。sync-request、sync-response、probe、checksum 與觀戰 wire 都不改。
  • leave 必須單獨成幀:與技能同幀或重複 leave,整筆 input 非法。encode、decode 與 rollback 受理(validEventsFor)三處都檢查。leave 包含在 input 簽章範圍內。
  • 缺包預測不延續 leave;leave 不屬於任何 chip schema,disallowChip 不會濾掉它。
  • 本機要求離場後,下一步的本機輸入改為只含 leave,之後恆為空輸入。leave 依一般輸入排程寫入該步實際模擬幀+D(catch-up 跳號時取跳號後的值),所以和其他輸入一樣在 c+D 的確定幀生效、受 rollback 保障。
  • 引擎在步進含 leave 的輸入時,寫入該車 RaceProgress.quitAtFrame=該步的 StepOutput.frame,只承認第一次。場地沒有 config(沒有 race progress)時不記錄。
  • quit 之後:該車輸入一律忽略(不影響狀態與 hash)、進度凍結、衝線不算完賽;場地 pass 只保留 deploy zone,出界處罰、KillZone 與 chassis 斷裂重生都略過。車體留在物理世界。

2. 斷線與自我驅逐只進 forfeit

  • ICE 斷線、15 秒重連窗逾時、倒數期間斷線、loadout 缺交,以及 desync/持續落後自我驅逐,都只寫入結算的 disconnects(forfeit),不通知模擬端,也不影響存活判定、滑行集合與名次分區。
  • 斷線車照常以 Hold 延續的預測輸入模擬。
  • 自我驅逐端已停止模擬,無法 flush 流內 leave;對端收到的 control leave(reason 'desync')一樣只記斷線。

3. 跨回合離場(quitAtStart)

模擬端回報 round-end 時附 quitPeers(終局視圖中已流內離場者,依 PeerId 排序)。主執行緒把它累加到本場的流內離場集合,下一回合建置時填入 RoundBuildRequest.quitAtStart。模擬端在 frame 0、第一次 step 之前呼叫引擎 markQuitAtStart,把這些車的 quitAtFrame 與 leftLiveAtFrame 都設為 0,所以 frame 0 的觀戰 checkpoint 也包含它。上一回合離場的 peer 不會在下一回合以馬達自走完賽得分。

4. 三個已雜湊欄位與無狀態終局

RaceProgress 新增三個欄位,都是 StepOutput.frame 語意,都進 SavedState 與 state hash(race 陣列依序附加,null 以 −1 編入)。SavedState 載入時只檢查範圍:null,或 0 到快照幀之間的整數。SAVED_STATE_VERSION 維持 1。

欄位 寫入時機
quitAtFrame 第 1 項的 leave 生效幀,或 markQuitAtStart 寫 0;只寫一次
lastMovingFrame chassis 線速或角速達低速門檻(≥)的最後一幀;所有未退役車都更新,含已完賽與已離場者
leftLiveAtFrame 首次完賽、battery/motor 失能、chassis 退役或 quit 的那一幀;只寫一次

roundEndReason(frame, limitFrames, vehicles) 改為只看當幀已確定視圖的純函式;規則本身(名次四區、單一回合級滑行窗、時限優先)沿用 D-20260814-09,算式寫在 算式表.md 第 20.1 節。progress 缺席的車在視圖中兩個欄位都填當前幀,不讓窗起點倒退,也不會被當成從未移動。所有車都沒有 progress 時只能等時限終局。

catch-up reset(F) 時消費端不重建任何終局狀態:恢復端載入的快照已含這三個欄位,與 live peers 由同一份已確定狀態推導出相同結果。觀戰 replay 共用同一判定,從 checkpoint 起播也不另外補狀態。

5. 主動離場的 flush

  • 只有使用者主動 abort、且 match 回合已 GO 尚未終局時,才走流內 leave:主執行緒送 leave-round 給模擬端,等 leave-flushed 後才宣告 control leave('voluntary')並收攤連線。
  • 模擬端在下列任一條件成立時回覆 leave-flushed,每次 leave-round 只回一次:已確定記錄的幀超過 leave 的排程幀;或 stepper 幀 ≥ 排程幀+STATE_BUFFER_MAX_FRAMES。local-test、未開始步進或已結束的回合立即回覆。
  • 主執行緒等待上限=2×ceil((INPUT_DELAY_FRAMES+STATE_BUFFER_MAX_FRAMES)×1000/FIXED_FRAMERATE_HZ) ms(60 Hz 時約 600 ms),由既有常數推得,不新增 system-constant。模擬端 fatal 時提前放行。等待期間收到的 round-end 不通知終局 listener、不定稿回合。
  • 回合之間、GO 前、local-test、failSession、分割作廢、賽前取消與自我驅逐,都不等 flush,直接走傳輸層離場,對端記為斷線。

6. 刪除的契約

  • 主執行緒對模擬端的 quit-peers 訊息與消費端的 RoundEndState 刪除,不留相容層。
  • HUD 的 quit 停用原因改讀本車 progress.quitAtFrame。

7. pre_launch 重基線

依 專案生命週期.md 的 pre_launch,state hash 域直接擴張,不保留舊 SavedState 欄位形與舊訊息:

  • determinism golden 的 KNOWN_CHECKSUMS 不變:golden 場景沒有 track config,race 為 null,hash 域擴張碰不到。
  • conformance 不變:ledger/signing-digest 不含 input digest,其餘 family 不受影響。
  • SAVED_STATE_VERSION 維持 1。

後果與影響

  • 離場在所有誠實 peer 上落在同一個已確定幀;catch-up 對齊幀不論早於或晚於離場幀、是否落在滑行窗內,恢復端都得到與 live peers 逐欄相同的 RoundResult。
  • 主動離場最多多等約 600 ms 才收攤連線。等待逾時(例如極端延遲)時 leave 可能尚未送達所有對端;對端此後只會收到 control leave,該 peer 改以斷線處理。
  • 斷線車不再結束回合也不改名次分區,剩下的玩家可能要靠完賽、失能滑行窗或時限結束回合。
  • 與既有決策的關係:
  • amends D-20260814-09:「未完賽退出」區限定為流內 leave 生效者;滑行窗由已雜湊欄位無狀態推導。四區、滑行窗與時限規則本身不變。
  • amends D-20261028-03:已簽輸入流新增 leave 事件;catch-up reset 時消費端不再重建回合終局狀態。D、R 與其餘協定語意不變。
  • amends D-20260814-11:HUD quit 停用原因的來源由編排層退出集合改為本車 progress.quitAtFrame。reason set 的順序、每幀投影與 fail-closed 閘不變。
  • 現況規格:賽內機制.md 第 3.1、4 節,算式表.md 第 20.1 節,流程/比賽進行.md 第 5、6、12.4、13 節,network-sync 第 1、2、4、4.1、5、6、7.3、7.4 節,physics-engine 第 2.10 節,interfaces 第 3 節,spectator。

已知限制與後續

  • issue 001122:B-3 定案為斷線車不得分,但回合積分、deriveTotalRanking 與 TrueSkill 仍把斷線車算進名次;斷線車夾在兩名選手之間完賽時會改變兩人的相對名次。deriveTotalRanking 屬 consensus-critical,pinning 也有 vendored 副本,自名次排除需改協定與 canon,另案處理。
  • issue 001123:下一回合的 race-ready 仍等待已離場的 peer,導致賽前取消。因此 quitAtStart 在正式流程中走不到;機制已實作並有測試,待 001123 修正後生效。
  • issue 001104:斷線車以 Hold 延續預測模擬,但各 peer 最後收到的真值可能不同,預測基準因此可能分歧,屬時鐘對齊與預測基準另案。

定案紀錄

2026-10-29 使用者審閱草稿後同意採納以下五點:

  1. quit 車輛:留在物理世界、輸入忽略、進度凍結(離場後衝線不算完賽)。
  2. desync/持續落後自我驅逐:不走流內 leave,對端只記 forfeit,不影響終局。
  3. 回合間離場與 ICE 斷線:斷線車不應得分;但自名次排除要改協定,另立 issue 001122。
  4. flush 上限:2×(D+STATE_BUFFER_MAX_FRAMES) 幀換算毫秒(約 600 ms)。
  5. 任務順序:ADR 升格排在 pinning vendor 同步之前;vendor 同步須另行取得同意。