From 6e59b5e4dde476fb924a04773a9d4c531862bebd Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 20:34:32 +0800 Subject: [PATCH] feedback3 WJ: prep watch of another board updates when it changes A scout stays subscribed to n: and gets a new prep field when that board moves, including a spectator. A shop change does not push one. --- docs/DESIGN.md | 2 +- docs/META.md | 6 ++-- public/js/screens/game.js | 5 ++-- server/match/Match.js | 44 +++++++++++++++++++++++----- test/match/prep-watch.test.js | 55 +++++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 test/match/prep-watch.test.js diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fec5035c..2d38aac6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -559,7 +559,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer **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`, unchanged). 联防: 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 start each combat phase on the first field (auto-observe) and may watch any field. +**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). 联防: 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 start each combat phase on the first field (auto-observe) and 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). 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. diff --git a/docs/META.md b/docs/META.md index 9713f86e..225efe53 100644 --- a/docs/META.md +++ b/docs/META.md @@ -213,7 +213,7 @@ no battle effect), it gets the settlement's `m.result`, and `handle()` answers o The rest of this section is the legacy server-run mode (`SP_COMBAT=server`): `g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE) -`'n:'` returns a one-shot board view — during a battle phase an `'n:'` id must name a live field +`'n:'` keeps the viewer scouting that board and pushes `prepFieldMeta` again when it changes (GitHub #87) — during a battle phase an `'n:'` id must name a live field (`BAD_TARGET 'no such field'` otherwise, and the viewer keeps its stream). In the 最终攻势 / 隐秘核心 a player fighting in a boss field may only watch its own field ("两名参与者会处于同一个战场,但无法查看 另一组队友的战场情况" → `BAD_TARGET 'other group hidden'`); eliminated / departed players spectate any field. @@ -707,8 +707,8 @@ the strip keeps the layers the battle reached through the COMBAT_END pause and t `ps.layers` (rules, the 联防 spec, `activatedLayers`) are untouched; SETTLE clears the pending gains as it adds them to `ps.layers` (once); the next round start clears them too. -`m.field` = `{ fieldId, kind, rect, stageId, units, live }`; during prep `g.watch 'n:'` returns a one-shot board -view with `prep: true` (scouting a teammate). +`m.field` = `{ fieldId, kind, rect, stageId, units, live }`; during prep `g.watch 'n:'` returns that board +with `prep: true` and sends it again when the board changes (GitHub #87). **`b.snap` / `b.ev` game time**: every frame is `{ t: '', … }`, so the snapshot's game time (DESIGN `b.snap.t`) is sent as **`gt`** (game seconds); `b.ev` carries the same `gt`. The client reads `gt` (`render/interp.js frameTime`, diff --git a/public/js/screens/game.js b/public/js/screens/game.js index b4522d84..27078e57 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -422,8 +422,9 @@ function MatchScreen() { view.pushEvents(early); audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn')); } - if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units) && field.units.length) { - // prep scouting (read-only teammate board): no snapshots follow, so place the units once + if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) { + // prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters + // above and places the units again, including an empty board (GitHub #87). view.pushSnapshot({ fieldId: field.fieldId, gt: 0, units: field.units.filter((u) => u && u.id != null).map((u) => [u.id, Number(u.x) || 0, Number(u.y) || 0, u.maxHp || 1, u.maxHp || 1, 0, 0, 0, 0]), diff --git a/server/match/Match.js b/server/match/Match.js index 683542a4..c7aca182 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -781,8 +781,8 @@ export class Match { const list = [...this._privDirty]; this._privDirty.clear(); for (const ps of list) { - if (ps.isBot || ps.left || !ps.connected) continue; - this._sendPrivate(ps, false); + if (!(ps.isBot || ps.left || !ps.connected)) this._sendPrivate(ps, false); + this._notifyPrepScouts(ps); } } if (this._pubDirty || forcePublic) this._maybeSendPublic(forcePublic); @@ -969,6 +969,35 @@ export class Match { return { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, prep: true, nextEnemies }; } + /** Board signature of a prep scout view (units only: a shop or funds change is not a board change). */ + _prepScoutSig(ps) { + const parts = []; + for (const { r, c, piece } of boardOrder(ps.board)) { + const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; + parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`); + } + return parts.join(';'); + } + + /** + * Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current board + * (the player who just asked to watch); everyone scouting it receives a new `m.field` only when the board changed. + * A live battle field owns the `n:` id, so this stays quiet once fields exist. + */ + _notifyPrepScouts(ps, { to = null } = {}) { + if (!ps || !ps.alive || this.fields.length) return; + const fid = `n:${ps.playerId}`; + const watchers = this.watchersOf(fid); + if (!watchers.length) return; + const sig = this._prepScoutSig(ps); + const changed = sig !== ps._prepScoutSig; + ps._prepScoutSig = sig; + const dest = changed ? watchers : (to ? [to] : []); + if (!dest.length) return; + const meta = this.prepFieldMeta(ps); + for (const pid of dest) this.sendTo(pid, meta); + } + _sendField(playerId, fieldId) { const f = this.fields.find((x) => x.fieldId === fieldId); if (f) { @@ -1052,14 +1081,15 @@ export class Match { return OK; } if (fieldId.startsWith('n:')) { - // prep scouting — a one-shot board view — only while no battle field is up: during 各自行动 / 联防 / 最终攻势 / - // 隐秘核心 an 'n:' id must name a live field (else the boss-group rule above could be bypassed, and the - // viewer would stop receiving its own field's snapshots) + // prep scouting, only while no battle field is up: during 各自行动 / 联防 / 最终攻势 / 隐秘核心 an 'n:' id + // must name a live field (else the boss-group rule above could be bypassed, and the viewer would stop receiving + // its own field's snapshots). The scout stays in `watchers` so a later board change pushes prepFieldMeta again + // (GitHub #87); combat start clears the map and reassigns live fields, spectators included. if (this.fields.length) return fail(ERR.BAD_TARGET, 'no such field'); const target = this.players.get(fieldId.slice(2)); if (!target || !target.alive) return fail(ERR.BAD_TARGET); - this.watchers.delete(ps.playerId); - this.sendTo(ps.playerId, this.prepFieldMeta(target)); + this.watchers.set(ps.playerId, fieldId); + this._notifyPrepScouts(target, { to: ps.playerId }); return OK; } return fail(ERR.BAD_TARGET); diff --git a/test/match/prep-watch.test.js b/test/match/prep-watch.test.js new file mode 100644 index 00000000..b16ce2ee --- /dev/null +++ b/test/match/prep-watch.test.js @@ -0,0 +1,55 @@ +// GitHub #87: watching another board during prep follows its moves. A spectator uses the same prep scout. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PHASE } from '../../shared/constants.js'; +import { makeMatch, give, legalTileFor, chessOfTier } from './harness.js'; + +const MELEE = (c) => c.position === 'MELEE' && c.profession === 'TANK'; + +test('#87 prep watch: seat B moves during prep and seat A\'s view of B changes before combat; a spectator does too', () => { + const S = 's_spec'; + const h = makeMatch({ mode: 'coop', humans: 2, seed: 8701, fake: true, spectators: [S] }).start(); + h.toPrep(1); + const m = h.m; + assert.equal(m.phase, PHASE.PREP); + const b = h.ps('p_1'); + assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true }); + for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) { + if (p.kind === 'chess') b.returnCopies(p); + } + b.board.clear(); + b.hand.fill(null); + b.temp.fill(null); + b.recompute(); + const id = chessOfTier(1, MELEE).find((x) => m.pool.has(x)); + const piece = give(m, b, id); + assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + assert.equal(m.watchers.get('p_0'), 'n:p_1'); + assert.equal(m.watchers.get(S), 'n:p_1'); + for (const pid of ['p_0', S]) { + const first = h.lastTo(pid, 'm.field'); + assert.equal(first.fieldId, 'n:p_1'); + assert.equal(first.prep, true); + assert.equal(first.units.some((u) => u.uid === piece.uid), false, `${pid}: the piece is still in the hand`); + } + assert.equal(h.allTo(S, 'm.private').length, 0); + const beforeA = h.allTo('p_0', 'm.field').length; + const beforeS = h.allTo(S, 'm.field').length; + const [r, c] = legalTileFor(m, b, id); + assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: r, col: c }, dir: 'LEFT' }), { ok: true }); + assert.equal(m.phase, PHASE.PREP, 'still prep — the update did not wait for combat'); + for (const [pid, before] of [['p_0', beforeA], [S, beforeS]]) { + const fields = h.allTo(pid, 'm.field'); + assert.equal(fields.length, before + 1, `${pid}: one new board`); + const last = fields.at(-1); + const u = last.units.find((x) => x.uid === piece.uid); + assert.ok(u, `${pid}: the moved piece is on the watched board`); + assert.deepEqual([u.x, u.y, u.dir], [c, r, 'LEFT']); + } + const n = h.allTo('p_0', 'm.field').length; + assert.deepEqual(m.handle('p_1', { t: 'g.freeze' }), { ok: true }); + assert.equal(h.allTo('p_0', 'm.field').length, n, 'a shop toggle is not a board change'); + assert.equal(h.allTo(S, 'm.private').length, 0); + m.dispose(); +});