Files
ark-stronghold-protocol/docs/design/network.md
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

50 KiB
Raw Blame History

DESIGN §8, §14 — The network protocol and client-side combat

Part of DESIGN.md (the index; section numbers are global).

8. Network protocol (shared/protocol.js is normative)

Transport: one WebSocket per tab at /ws, JSON text frames { t: '<type>', ...payload }. Client requests may carry rid (request id); the server's direct reply echoes rid (ok or error). Server pushes have no rid. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with error RATE).

8.1 Session & lobby

C→S: hello {name, token?, version} · room.create {mode:'solo'|'coop', difficulty} · room.join {code} · room.leave · room.ready {ready} · room.setDifficulty {difficulty} (host) · room.setAiPicksLast {on} (host, co-op, before the match — ROOM_STARTED during it, BAD_TARGET in a solo room; un-readies the other humans like a difficulty change; the match takes it as opts.aiPicksLast: humans draft before AI seats, off by default, no extra random draw) · room.addBot / room.removeBot {seat} (host) · room.kick {seat, playerId} (host, before the match: another human leaves the room and gets room.closed {reason:'kicked'}; may join again; playerId = the player the host confirmed, a seat that changed hands meanwhile → BAD_TARGET) · room.start (host) · room.loadout {entries, ops?} (§16: any time in the lobby / room and during INFO_CHECK, then WRONG_PHASE; heavy rate-limit bucket; the match exposes it as m.private.loadout; ops — 0.2.2 — the per-operator 潜能 / 练度 { [charId]: { potential?: 1–6, cultivate?: 0–3 } } of the 干员调配 roster and the 自选 owned pool, ≤ 256, checked strictly with the entries, m.private.ops; absent = none set: every operator 潜能 6, 精英2 Lv.60) · room.ownership {notOwned} (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match ROOM_STARTED 'stored for the next match'; heavy bucket; the match exposes it as m.private.standIns) · room.diy {picks} (0.2.0 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null } for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js checkDiyPicks drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are BAD_MSG; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match ROOM_STARTED 'stored for the next match'; heavy bucket; the match exposes them as m.private.diy) · ping {c} S→C: welcome {playerId, token, name, serverNow} · room.state {code, hostId, mode, difficulty, aiPicksLast, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]} · room.closed {reason} · pong {c, s} · error {rid?, code, msg} · ok {rid}

Platform additions (implemented): welcome.{resumed, version}, welcome.diyKitted (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js KITTED_CHARS, the picker's list), room.state.inMatch, error.detail; room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown' (kicked: room.kick / room.removeSpectator; empty: a spectator whose room lost its last player); WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's room.start counts as the host being ready. After any (re)hello the server pushes room.state and, if a match runs, the match resends m.public/m.private (+m.field, or the field's b.start under client-side combat). Reconnect window: a dropped session stays resumable for 10 min (net.js NET_DEFAULTS.reconnectWindowMs; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its solo room's match runs stays resumable for the official config.constants.singleReconnectTime (86 400 s = 24 h; lobby option soloReconnectWindowMs; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become match.onLeave (co-op: 中途退出 = elimination; solo: the run ends 'abandoned'). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, start/handle/onDisconnect/onReconnect/onLeave/dispose) is documented verbatim at the top of server/match/Match.js and is normative. server/data.js provides getData() + index getters (getChess, getBond, getGarrison, getItem, getBand, getEffect, getEnemy, getWave, getStage, getBoss, getToken, getConfig, getMode). Note: the skill trigger rule lives at chess.skill.trigger.rule in data.

Spectator seats (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): room.spectate {code} takes one of a co-op room's MAX_SPECTATORS (2) spectator seats, in its lobby or during its match; room.removeSpectator {playerId} (host, at any time — the room screen's strip and, once the match runs, the game screen's 观战席 capsule in the top bar, GitHub #120) frees one (room.closed {reason:'kicked'} to it, no match frame after it); room.state.spectators: [{playerId, name, connected}] (still pushed while the match runs). A spectator is never a player (not in seats, not counted for the start, never host, keeps no room alive — room.closed {empty} when the last human leaves), receives the room's broadcasts and watches like an eliminated player (Match.addSpectator: b.start watch / m.field, never m.private); it may only g.watch, leave, and store a room.loadout — anything else answers error SPECTATOR. Its seat is kept and given back on reconnect like a player's.

Local recovery arbitration (0.2.3, §28.27): hello also accepts optional noReplace:boolean and claimAt:number (finite, nonnegative ms). A shared-token candidate sends noReplace:true; a tab with a saved welcome stamp sends claimAt. When that session is already connected to a different socket, noReplace or a later stamp than the attached holder causes SESSION_IN_USE before the old socket is touched. A same-stamp own reconnect can still replace its previous socket. Omitted hints retain legacy behavior; they are not authentication. Client selection/stamp rules live in design/client.md. In-flight protection requires the updated server as well as client.

8.2 Match

C→S (all carry rid): g.infoReady {setupRevision?} · g.band {bandId} · g.bandSkip · g.bandFocus {bandId?} (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; WRONG_PHASE outside BAND_DRAFT, ALREADY after the pick, BAD_TARGET for a band not allowed; §18.2) · g.buy {slot} · g.refresh · g.freeze · g.levelUp · g.sell {uid} · g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?} · g.equip {itemUid, targetUid, replaceUid?} (replaceUid: which equipped item a third one replaces, §6.2) · g.art {itemUid, row, col, dir?} (dir ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ to.dir, then RIGHT; g.move onto the piece's own tile re-orients it; board pieces in m.private / m.field {prep:true} carry dir) · g.destroy {uid} (hand / temp items only; equipped items are locked, §6.2) · g.reward {idx} · g.choice {idx, choiceId?} (with an ID: the recipient's PREP personal choice; without one: the public SP_DRAFT; stale or wrong IDs never fall through to the public draft) · g.ready {ready} · g.emote {id} · g.watch {fieldId, playerId?} (playerId: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · g.autoplay {on} · g.pause {on} (solo battles only, §14 Solo pause) · g.unitStats {seq?} (ROUND_START / SP_DRAFT / PREP, else WRONG_PHASE; eliminated: ELIMINATED) → push m.unitStats (§18.5) · g.leave S→C:

  • m.public — full public state on every change (throttled ≤ 10/s): { phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...], progress?:{killed, resolved, total, done} /* §14 顶栏胶囊: resolved = the field's own scheduled enemies knocked down or leaked, null while unknown */}] } · m.public.uniteResult — SETTLE after a 联防 only, its outcome as data: { through /* the leakers' enemies that still got through, uncapped */, helpers:[playerId...], leakers:[playerId...], losses:{playerId: the LP settlement charged this round — every alive player} }; absent when no 联防 resolved (the client's result box then reads the round's own battle; GitHub #235, PR #112)
  • m.private — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; in a boss round's prep also bossMate { playerId, side, units } — the pair partner's board on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51).
  • m.field — field meta when watching starts: { fieldId, kind, rect, stageId, units: [UnitInfo], effects? } (the units on the field, knocked-out operators waiting to redeploy included — Battle.fieldMeta, §18.3). The prep-scouting field (Match.prepFieldMeta) puts the scouted player's hand (整备区) and 临时整备区 into units as well: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a kind: 'item' unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-m.private rule); the scout signature _prepScoutSig carries the hand / temp slots, so a piece moved to another slot is a change — and carries effects: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made
  • NOTE: game time in b.snap/b.ev is carried as gt (the frame's t is the message type).
  • Active finite zero-SP timed skills display sp=timeLeft, spMax=duration and UF.SKILL; after end they display 0/0. Snapshot display capacity does not change runtime SP/cost.
  • b.snap — { fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, resolved, down?, elem?, ammo?, coins?, wolves?, neg?, stand?, standCut? } 20 Hz (a countdown summon's hp is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): down: [[id, respawnAt (game s), respawnTime (s), state, row, col]] — knocked-out operators waiting to redeploy, state 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js DOWN_STATE); row, col = the tile it lies on and comes back on (§21.24); elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]] — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; render/interp.js keeps them per snapshot and sample() hands them out from the older snapshot like flags, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): ammo: [[id, rounds left, rounds in the magazine]] — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table attack@trigger_time) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo ammoSkill: true (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; wolves: [[id, 狼影 left, the talent's maximum]] — 伺夜's 狼群 (mem.shadows / mem.wolfCapacity, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; neg: [[id, fill]] — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, unit.negFill), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one Optional, only when non-empty (PR #381; display metadata, SIM §9): stand: [[id, until]] — the game time an enemy's attack recovery ends (atkStandUntil; enemies alive, deployed, visible, neither feared nor stunned): when it falls between two snapshots the renderer holds the position until then and interpolates the rest (HP / SP unchanged), and extrapolates nothing while the newest snapshot still holds a stand; standCut: [[id, at]] — the latest time each listed enemy's recovery was cut or ignored (失衡, fear, stun, hiding, leaving): an interval with a cut inside it stays linear. A newest snapshot showing a death, a stun or a block is not extrapolated. Old snapshots without them interpolate linearly; nothing is inferred from the attack animation.
  • b.ev — { fieldId, ev: [ ... ] } event tuples: ['spawn', UnitInfo], ['atk', srcId, tgtId, projKind], ['dmg', tgtId, amount, type], ['heal', tgtId, amount], ['skill', id, on:0|1], ['die', id, reason] (reason 'killed', 'retreat', 'expired' …; only 'killed' plays an operator's knock-down sound), ['status', id, key, on], ['fx', kind, x, y, extra] ('hitCap' {id, n} = a cancelled 限伤 hit, drawn as nothing, §20.12; extra.form = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — shared/protocol.js fxForm, state like a spawn: never dropped by the client, §21.4), ['layer', playerId, bondId, n], ['bounty', playerId, coins], ['deploy', id]
  • m.toast {kind, text} · m.ticker {text, id, type, priority, playerId} (the strip plays the highest priority first, §21.10) · m.emote {playerId, id} · m.result {...}
  • m.unitStats {seq, round, units: [unitStatsEntry]} — the answer to g.unitStats (seq echoed): every own board operator / summon by uid with {id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}} and dir? (shared/protocol.js unitStatsEntry; range = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16; dir = the unit's facing now, UP|RIGHT|DOWN|LEFT — the snapshot tuples carry none and UnitInfo dir is the facing at send time — which the detail card's range overlay rotates by, GitHub PR #281) as its next battle starts (§18.5)

UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? } (dir = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; skillIndex = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (anims.skills[i]) and the audio its own ON_SKILL_START sound; moduleId = an elite ally's equipped module (uniEquipId | 'none'); items = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting m.field units (Match.prepFieldMeta, the held pieces included), so a teammate's unit shows its owner's loadout in the detail card and its equipped items as item pips on the unit (prep surfaces; user playtest #2 item 3); form = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)).

8.3 Exact view shapes (normative for match ⇄ UI)

m.private:

{ playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while a personal choice or temp overflow is pending */,
  personalChoice: null | { id, round, sourceItemId, cards: [ { kind: 'bounty', id, name, desc, descRaw, ... } ] }, // recipient only, including reconnect; absent from public / scout views
  shop: { level, maxLevel: 6, upgradePrice, refreshPrice /* effective (0 when a free refresh is available) */, freeRefreshes,
          frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold, frozen } | null ],  // slot.frozen: the 冻结 toggle copies onto every unsold slot, and 梓兰's 猎头顾问 freezes ONE card of an active refresh with the toggle off — every card draws its own flag (GitHub #354)
          rewardOffer: null | { tier, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } },  // §21.2
  hand:  [ Piece | null ],   // length 10, index = hand slot
  temp:  [ Piece | null ],   // length 5
  board: [ Piece & { row, col, dir } ],   // dir ∈ UP|RIGHT|DOWN|LEFT (§3; bench pieces carry none)
  deployCap, deployCount,
  bonds: [ { bondId, count, active, tier, layers, harmony? /* 调和's +1 is in count, §21.26 */, thresholds, countsHand } ],   // sorted: active first, then layers desc; an `off` entry (a bond the mode never activates, with members: count only, never active — the strip's grey 本局禁用 disc, bondsMeta.offBondCounts) comes last
  effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter?, counterText? /* a bounty: 还剩 N 场作战, §20.6 */ } ],
  nextEnemies: [ { enemyKey, count, tag, start? } ],   // preview of the upcoming round's wave (after waves.js generation); `start` = a leader's spawn tile [row, col] on the boss field (the boss-field prep shows it standing there, community report #12)
  loadout: { [baseChessId]: { skill, module } },   // §16: the effective loadout (chess not listed: defaults)
  standIns: [ baseChessId ],   // 0.2.0 补位: the chess this player fields as their stand-ins in this match (sorted; [] = everything owned)
  diy: { [slotBaseId]: { charId, skillIndex, uniEquipId } },   // 0.2.0 自选编队: this player's picks in this match ({} = none)
  diyBanned: [ slotBaseId ],   // 0.2.0: slotted slots out of the shop this match (every bond of the operator 本局禁用)
  stats: { dmgDealt, kills, leaks, gold, refreshes, merges } }
Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { uid, id } ] /* chess only */, count /* token stacks */, ownerUid /* token */ }

m.public.players[].status ∈ 'acting' | 'ready' | 'deciding' | 'combat' | 'done' | 'helping' | 'left' | 'dead'. m.public.fields[]: { fieldId, kind: 'normal'|'unite'|'boss'|'hidden', players: [playerId…], live: bool } — fieldId is 'n:<playerId>' for normal fields, 'u' for the unite field, 'b1'/'b2' for boss fields. Clients g.watch a fieldId; default = own field (or the unite/boss field they are in). flags bitmask: 1 blocked, 2 stunned, 4 frozen, 8 stealth (an ally's 隐匿 or 迷彩 — §20.2; an enemy's only while its 隐匿 is on: not blocked, not revealed — drawn solid otherwise, §21.4), 16 skillActive, 32 shielded, 64 invulnerable, 128 cold, 256 sleep, 512 flying. anim small int: 0 idle, 1 move, 2 attack, 3 skill, 4 die, 5 stun, 6 deploy.


14. Client-side combat (v2 architecture — supersedes the combat-streaming parts of §4, §8.2, §9)

Why: like the official client, each player's browser simulates its own battle locally and uploads the result. The server no longer paces battles in real time nor streams b.snap/b.ev for combat, so a low-power host (e.g. a Windows mini PC) is enough, and combat rendering has no network jitter. Measured (4 human seats, HARD, real sim, in-process): server CPU per combat round ≈ 170–360 ms → ≈ 1 ms; server→client traffic ≈ 2–3.3 MB → ≈ 0.25 MB per round. Browser cost ≈ 0.05–0.1 ms per tick (≈ 3–6 ms per real second at 2×) on the main thread (no worker needed).

Modes. Match option clientCombat (default on; env SP_COMBAT=server → the legacy server-run + streaming mode, kept as a fallback). m.public.combatMode = 'client' | 'server' tells the UI which one runs.

Shared deterministic sim. server/sim/** is pure ESM runnable in both Node and browsers. Node-only code (reading data/*.json from disk, research fallbacks) lives in server/sim/nodeData.js, imported dynamically by simdata.js under Node only; the browser injects its own frozen copies of /data/{chess,enemies,tokens,stages,waves,bonds,items,garrisons,bands,effects}.json via setSimData(data) (getSimData() serves them). The HTTP server serves server/sim/** read-only at /sim/ (.js only, no listings, nodeData.js excluded under any spelling — the on-disk name decides, for case-insensitive hosts) and a generated /data.js stand-in of server/data.js (the content support module imports ../../../data.js; in the browser it returns getSimData()). A battle is fully described by a JSON BattleSpec (server/sim/spec.js buildBattleSpec: { v, battleId, fieldId, kind, seed, modeId, round, stageId, rect, timeLimit|null, players:[PlayerBattleInput], spawns, routes, flags, enemyOverrides, waveId, bossId, content, boss?: { poolHp, poolMax } } — the JSON round trip of the Battle options; ±Infinity → ±1e308, spawns at time Infinity dropped) and constructed with createBattleFromSpec(spec, dataSource, { sharedBoss? }) identically on both sides; the server builds its own battles from the spec too, so both run exactly the same input. No wall-clock or Math.random inside the sim. Verified: spec-built battles give identical result digests (resultDigest) in any construction order; whole client-combat matches are outcome-identical to server-run matches (same seed).

Authority per field.

  • Normal field: the owning human's client is authoritative (if connected).
  • Unite field (联防) and boss/hidden pair fields: the lowest-seat connected human in that field is authoritative; every other human in (or watching) that field receives the same spec and simulates it locally for display only.
  • Bots, departed/disconnected players, fields with no connected human: the server simulates. Normal and 联防 fields headlessly (fields.js HeadlessJob, with a [gt, killed, total, resolved(, left)] progress timeline — resolved: the capsule's numerator on the field clock (the maintainer's review of PR #157: a bot field has no authority reporting b.progress), left: the 联防 leakers' enemies still standing, §20.6) in wall-clock slices of headlessSliceMs (8 ms) per callback on a real host — never one blocking run per field (3 bot fields ≈ 0.2–0.5 s of CPU at COMBAT start here, seconds on a mini PC); at once in virtual time; the result is released at the battle's natural end on the field clock (so round pacing and the teammates' progress look live). Boss fields in real time without snapshots (HeadlessPacer); when no field of a boss round has a connected human the whole round runs server-side as before (FieldRunner with streaming off).
  • Takeover: an authority that disconnects or leaves, or misses its deadline (timeLimit / speed real s + 15 s grace, normal/联防) loses the field: normal/联防 → the server re-simulates the spec from t = 0 (deterministic: the same result); boss → the partner's replica is promoted (b.start authoritative: true), else the server runs it, fast-forwarded to the field clock through a CreditPool that only credits damage beyond what the client already reported (the fast-forward is spread over the pacing intervals, ≤ 240 extra ticks each). A boss authority silent for 12 s is handed over the same way. The former authority gets b.end { reason: 'takeover' } and keeps displaying.

Protocol (additions; shared/protocol.js is normative).

  • battleId = <prefix>.<round>.<seq>.<fieldId> (Match._ccField; <prefix> = the match seed in base 36 plus -<matchNo>, the room's match number the lobby passes as opts.matchNo; <seq> counts every battle of the match; the informational .<fieldId> is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (n:<pid> every round, u, b1/b2 in both boss rounds), and across the matches of one room. The client runner keys its local battles by it (the same id again = the same battle, no rebuild) and the server matches reports by it, so a late b.progress / b.result of a finished battle — or one that crossed into the room's next match on the same socket — is ignored instead of being credited to another battle.
  • S→C b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done } — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans and spectator seats get the field of the player they follow as a display replica — the one they last watched with g.watch, else the first player still in; research 09 "keep-watching auto-observes the first available field", 0.2.0 item 56), on g.watch, and on reconnect/resync. elapsed = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. speed = game seconds per real second (2; tests/tools may speed up).
  • C→S b.progress { battleId, gt, killed, total, resolved?, leaks?, left?, bossDmg?, by?, leaksBy?, done? } — authoritative clients, ~1 Hz (boss fields 4 Hz). resolved = the HUD capsule's numerator of this field (below; omitted by a client that cannot count it — the teammate UI then falls back to killed); killed / total are the field's own counters: killed counts every counted knock-out (a runtime split / summon included) and may exceed total, which counts only the enemies the round scheduled. Normal/联防: leaks = counted leaks so far; 联防: left = per leaker its enemies still standing (spec.js uniteLeft, clamped by the server to fields.js uniteBillBounds; §20.6). Boss: bossDmg = cumulative damage of this field to the shared pool, by = the same per player, leaks = the field's cumulative LP cost (leaks × lpr + leader "扣除目标生命" effects; spec.js attachLpMeter), leaksBy = its split per player (the LP the enemies that reached that player's goal cost: every leak entry the result will show × its lpr; leaks minus its sum is the leader's effects; spec.js battleProgress) — the server keeps the highest value per player and, before a perfect-payout bounty pays in a client-reported field, requires the result's leaked list of that player to show at least that much and the LP asked for to have been split at all (Match._bossLeaksAgree, the boss path does not re-simulate). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less b.progress with an error frame (it would surface as a toast).
  • C→S b.result { battleId, result } — authoritative client at battle end; result = compactResult(BattleResult) (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; fitResult keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in isBattleResult; server/net.js routes b.* frames to the match (lobby routeGame).
  • S→C b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } } (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their LocalBossPool: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (BOSS_POOL_MIN_HP: SharedBossPool and LocalBossPool; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is 'cleared' while < 1 HP is left on the server empties the pool) only catches noise at that boundary.
  • S→C b.end { battleId, fieldId, reason } — 'cleared' (pool 0) / 'forced' (team LP 0): the client calls battle.forceEnd and reports; 'takeover': stop reporting, keep displaying. Both also reach a battle the client is still preparing (the sim loading, the silent catch-up): Client runner below.
  • m.public.fields[] gains progress: { killed, resolved, total, done }; m.public.combatMode; m.public.paused: boolean (always present; solo pause below).
  • b.snap / b.ev are not sent for combat in client mode. g.watch rules: see Spectating.

顶栏胶囊 (the top bar's 击倒 / 漏掉 counter; PR #157, official reading). The capsule shows resolved / total:

  • total = the enemies this field itself scheduled (Battle total: the spawns the match built, inTotal) that count — countInTotal: false, tag: 'boss' / 'part' and data notCountInTotal entries stay out, like the 领袖 of a boss field.
  • resolved = min(total, killedInTotal + leakedInTotal) (Battle.resolved): that same set's enemies that are 已解决 — knocked down (killedInTotal) or leaked (leakedInTotal). Official: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3.
  • Everything spawned while the battle runs is in neither part: a split child (磨砻's 木制瑞印), a summon (枯朽萃聚使徒's 枯朽之种, a boss's 余音 / 妖怪 / 不祥幻影 / 王权号令 equipment), a 变身 copy, a part. They still count for LP, 完美作战 and killed exactly as master did (counted): a leaked split child costs its lpr and breaks 完美作战, it just moves no capsule number.
  • killed (Battle.killed, per player too) keeps the official counted reading — a runtime split / summon knocked down counts — so it may exceed total (4 磨砻 killed plus one of their 8 children reads killed 5 with total 4 while the capsule reads 4/4). Nothing clamps it: the match's stats and the bot's scoring read those knock-outs.
  • Carried by b.snap, BattleResult (battle level and per player, the per-player resolved clamped to the player's total), b.progress and m.public.fields[].progress. A finished field publishes the field's own number, never the sum of the players': in a two-half field (the 联防 lane, the boss pair's crossing routes) an enemy that spawns on one half and leaks on the other is billed to one player's total and to the other's leakedInTotal, so each player's own min(total, …) reads 0 for an enemy the field resolved (capsule 1/1, players 0 and 0). validateClientResult therefore keeps the result's field-level resolved (the client's Battle.resolved, clamped to the field's total) and _fieldProgress publishes it — for a server-run field too, whose Battle.result() carries the same number; the per-player numbers are only the fallback of a result that has none, and with none at all progress.resolved is null. A bot / server-run field has no authority reporting b.progress, so its capsule reads the battle's own counters through the HeadlessJob timeline sample ([gt, killed, total, resolved(, left)]), never the report-driven progress.leaks (which stays 0 there); progress.resolved starts at null and is only adopted from a real reported integer — a reported 0 is a value, "not reported" is not.
  • The HUD reads it as resolved ?? killed (ui/hud.js two capsules, ui/combatHud.js teammates' rows, ui/gameLogic/format.js snapHud, battle/observe.js teammateProgress), so a server that does not send resolved displays master's numbers unchanged.

Result validation (server, fields.js validateClientResult). A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; killed ≤ maxTotal and total ≤ maxTotal (4·spawns + 100, the content-spawn allowance — killed ≤ total no longer holds, see the capsule above) and the field-level resolved ≤ total; every leaked enemyKey is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through b.progress); counted: false only for enemies that never count (data notCountInTotal, schedule countInTotal: false, boss/part entries, content spawns); 联防: leaks + unspawned per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond layerGains ≤ min(60 + 4·round, 999 − the bond's starting layers) (§20.12) — the flat part raised by what the player's layer 特质 can add to that bond (their per-battle caps; an uncapped one leaves only the 999 room, §21.26) —, none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; coins ≤ Σ bounty coins of the spawns; perfect consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is rebuilt from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is handed over instead (below). SP_VERIFY=all|sample|off (default off): all re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); sample checks ~1 battle in 8 in a later callback and logs mismatches. Match.verifyStats = { checked, mismatches, rejected, takeovers }. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.

Boss rounds. The shared pool (SharedBossPool, size = bloodPoint[difficulty] × the players alive when the fight starts, solo × 1 — the owner's decision of 2026-10-06 adopting PR #209, which replaces the fixed pool of 「保持固定血量」, §25.13.4) lives on the server and is fed by the authorities' b.progress damage deltas (attributed per player through by; BOSS_HIT tickers from each player's damage to that round's pool, §21.10) and by server-run fields; a server clock (250 ms) applies the overtime drain (−bossOvertimeDrainPerSec team LP per whole real second past bossOvertimeAfter real s, measured on the field clock — gamedata.js bossOvertimeDue, §4), the silence watchdog and the end checks: pool 0 → b.end cleared (victory), team LP 0 → b.end forced (defeat) — the first one the server registers decides the verdict (Match._finalEnding) and after a forced end no report (an in-flight b.progress, the final b.result) credits the pool (§20.5); results are awaited ≤ 6 s (× timerScale) after a forced end. b.pool is the live channel for the pool and the team LP: a boss b.progress (4 Hz per field) and the team-LP losses never re-mark the whole public state — m.public (bossHp, teamLp, fields[].progress) and the players' merged-LP shares (_syncTeamLp) refresh at ~1 Hz (Match._bossPublic, BOSS_PUBLIC_MS), and at once when the round ends. A boss field's b.result ends it only when the shared pool is empty or after that b.end — except a 'cleared' result whose cumulative report covers what the pool still holds (within its 1 HP floor) while the plausibility budget (one field credits ≤ the pool per BOSS_MIN_CLEAR_GS 5 game s) has not caught up: it is held (f.heldResult; 999-layer boards kill a leader in 2–4 game s, §20.14) — the boss clock keeps crediting the report at the budget's rate, the silence watchdog and handovers skip the field, a re-sent copy is ignored, and the pool's end (_endFinal) completes the field with it; a forged report gains nothing a b.progress could not already get. Any other result — 'forced' / 'timeout' at t = 0, 'cleared' while the pool still holds more than the report covers — and any result failing validation would stop the pair's fight (and, with every field done, end the Final Assault as a defeat), so the field is handed over (Match._bossHandover) to the partner's display replica (promoted with b.start authoritative: true) or, without one, to the server (fast-forwarded to the field clock); the sender gets b.end takeover and is never that field's authority again (f.demoted). There is no synthetic zero result for a running boss field: a stats-only stand-in (fields.js syntheticResult, carrying the damage already credited, never charging LP) only fills a result that cannot be had otherwise — the Final Assault already ending (no result, or an invalid one, within the grace) or a server-run battle whose result() threw.

Solo pause (official PauseUp / ResumeUp; Match.setPause). C→S g.pause { on }: solo matches only (co-op → WRONG_PHASE 'co-op battles never pause'), and on: true only while a battle runs (COMBAT / FINAL_ASSAULT / HIDDEN_CORE with a live field, not once the Final Assault is ending — else WRONG_PHASE 'no battle running'); on: false is always accepted. While m.public.paused is true everything of the battle is frozen: the field clock (b.start elapsed, the boss plausibility budgets and the overtime drain), the authority deadline and the server-run release timers, the boss clock, the HUD deadline / overtimeAt (shifted by the paused time on resume) and the server-run pacers (FieldRunner / HeadlessPacer skip their intervals); the browser's runner stops its local clock. In solo battles the top bar shows a 暂停 / 继续作战 button (ui/hud.js PauseButton, ui/matchStatus.js pauseAvailable; Space toggles it), a paused overlay offers 继续作战 / 放弃模拟, and the HUD clocks freeze at the pause moment. A disconnect or leave resumes (the server takes the field over — nobody is left to resume it); the battle phase ending drops the pause.

Spectating (research 09 §3.1 / §6.3). No live spectating while your own normal battle runs (g.watch → WRONG_PHASE 'own battle running'; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (m.public.fields[].progress); tapping a teammate's avatar expands 前往查看 → g.watch → b.start (display replica fast-forwarded to the field clock); the own row shows 返回战场. Prep: the same flow shows the teammate's board (m.field with prep: true) and sends it again when that board changes (GitHub #87). In a boss round's prep that board is built on the boss field — the player's half (rows − 7, the right half mirrored with RIGHT ↔ LEFT, server/match/finalAssault.js bossFieldPlacement), kind: 'boss', side, the leader in nextEnemies at its spawn tile — and framed by the boss-field prep camera of that side, so a watcher sees the leader as the players do (0.2.0, community report item 55). The scout is framed by the prep camera in its shop-folded form and renders the teammate’s hand (整备区) and 临时整备区 as units on their rows — the own prep bench’s exact look (user playtest #2 item 1, the GitHub #44 suggestion; the spectator seat's scout view is the same, §23.19's never-a-private-view staying the no-m.private rule). While scouting, the 本局信息 drawer shows the watched player's 策略 in place of one's own (game.js scoutPid → enemyDrawer.js InfoTab bandId / bandOwner, labelled "X 的策略"; user playtest #2 item 2), and the right effects column follows the watched field too: the scout meta carries the player's effects view, a display-replica battle derives it from the spec's playerEffects (battle/observe.js spectateEffects) — undefined (empty column, never one's own) for 联防 / boss pair fields and server-run watched fields. 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → BAD_TARGET 'other group hidden'. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is no view switcher during normal combat. Eliminated players and spectator seats follow a player through every phase reset (0.2.0, community report item 56; the official DeadAutoObDn { obIndex, state, preparation }; the idea of PR #189): the player they last watched with a manual g.watch (Match.watchPref; a shared field records the playerId tapped, else nobody), else the first player still in (seat order, AI seats included) — the round start pushes that player's prep board (Match._followScout, adopted by the screen like a 前往查看 tap: battle/observe.js followedScout; kept through 机变 and 休整期), each battle starts on that player's field, a resync / a spectator joining in prep gets the board again; they may watch any field.

Client runner. public/js/battle/runner.js owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most max(8, 4·speed) ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same snapshot()/drainEvents() wire formats every frame (view.setLocalFeed shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + local, battleId, players, sides, speed) into store.match.field and its state into store.match.battle (with leaks: { [fieldId]: counted leaks so far } of every normal field it simulates and, while the 联防 field is on screen, uniteLeft per leaker (§20.6), authoritative or display replica — the settle rule's count, /sim/spec.js battleProgress; published in the frame a count changes: the live LP of §17.5), sends b.progress/b.result when authoritative (results retried once on timeout; a result lost with the socket — the request failed DISCONNECTED / OFFLINE or timed out twice — is kept and sent again when the session is back online and whenever a b.start still names that finished battle authoritative: true; the server answers a duplicate ok and changes nothing; a server refusal is final), follows m.public.paused (solo pause: every local clock stands still, resume shifts it by the paused time), handles b.pool/b.end, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; the server deadline + takeover cover extreme cases). A b.start is registered as pending the moment it arrives — while the sim module still loads and while the new battle is built and caught up — so what comes for that battle meanwhile is applied to it in order once it exists (0.2.1, PR #266 by @siruimei07; before, such messages were dropped): b.end takeover stops its reports; a forced / timeout b.end (the first end counts) ends it as soon as the Battle is built, without catching up, and an authority reports it; a second b.start of the same battle updates its flags (one Battle per battleId); b.pool and the solo pause reach it. A b.start of another field supersedes it: an authority, or a battle the server already ended, is still built and kept (a superseded authority catches up in the background), a display replica is dropped; the next prep's clear drops it (nothing of it is built, stepped or reported afterwards). Catch-up frames keep the state events and every enemy form fx (keepsState); while hidden, the battle on screen holds them for the first frame back (hold; past HELD_MAX the superseded status / skill toggles are dropped, compactHeld, and only a backlog still past it re-enters the view from the field meta), and a battle that ended while hidden delivers them when the tab is shown; a catch-up frame or a hidden step drains its events every EV_SLICE (15) ticks, each batch stamped with its own game time (gt), so a late form fx reaches the render engine as late as it is; the game screen's pre-entry buffer (keepEarly, also while a re-sent field meta re-enters the field on screen) and the render engine's event queue (render/interp.js isCosmeticEvent) never drop a form fx either (§21.4). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next b.start retries and the server's deadline covers the field meanwhile. public/js/battle/observe.js holds the observing rules and labels.

Corrections from research 08/09 (normative):

  • Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN action becomes clamp(roundHalfEven(n·P(t)/P(new)), 1, 5) units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (data/tuning.json keeps only titles); co-op boss pool = bloodPoint[difficulty] unscaled.
  • Ground pathing: official SPFA-style 4-direction search from the goal with crates as cost-1000 tiles, then straight-line smoothing (research 08 §3.4). act1 m02 crates #001–#004 inactive in 下半. Remake refinement (user playtest #2, narrowed by §21.15): route lengths and routes stay the official ones unless 0.1.0's road-over-floor preference route crosses strictly fewer non-blockable (floor / gate / 深水区) tiles and no more 深水区 (GitHub #375) — a corner touch is not a crossing; ties keep the official route. E.g. 战场#01's lower-gate enemies take the col-8 road, while 战场#04's keep the official diagonal from row 9 into row 10 (server/sim/grid.js header; test/sim/pathing-official.test.js, pathing-blockable.test.js).
  • Observing: no watching while your own normal battle runs; after it ends (and in prep) tap a teammate → 前往查看 (local replica, fast-forwarded). ‹ › only inside 联防/FA fields; FA: own pair only; eliminated: free.
  • Prep interactions: tap a unit → underframe with 出售 +1 (and 撤退 on board); items/Arts → 销毁; shop and upgrade are two-tap (select → confirm; the remake's 机变 cards too, §18.2); strategy draft forbids a strategy already taken by a teammate (队友已选).
  • Emotes: 6 themes × 6 battle emotes (research 09 §4.2), image-only bubbles beside the sender avatar, 1 s cooldown, 3 s bubble; g.emote {id} with the official emoji id.

Opening reroll messages and local seat recovery

C→S adds host-only room.rerollSetup {setupRevision} / room.cancelReroll {voteId} and participant g.rerollVote {voteId, agree}. m.public carries setupRevision and nullable rerollVote {id, proposerId, voters, agreed}. Ready must reference the current revision; omission means revision 0 for old clients. The server blocks Ready while voting, refuses stale ids and cancels on disconnect/leave; DESIGN §28.21 / META.md describe the complete contract.

Explicit local recovery (§28.20) reuses saved hello tokens after a fresh live-tab claim check. Display names and room codes are UI metadata only, never authentication. No new network message or nickname/IP matching is introduced.

Optional coins: [[id, balance, cap]] is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name.

Preparation state carries read-only prepMapChars for 外勤医疗. Boss-mate and scouting metadata include them in displayed field coordinates. Draft cards expose optional soldOut when equipment stock is unavailable; the client disables picking and the server checks again before consumption.