From 246622beff7fa0949a55340cc2a803e1dca45424 Mon Sep 17 00:00:00 2001 From: siruimei07 Date: Thu, 8 Oct 2026 18:43:24 +0800 Subject: [PATCH] =?UTF-8?q?feedback7=20G-UI:=20the=20spectator=20prep=20vi?= =?UTF-8?q?ew=20updates=20effects=20and=20the=20enemy=20preview=20after=20?= =?UTF-8?q?a=20=E6=9C=BA=E5=8F=98=20pick=20(PR=20#349=20by=20@siruimei07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. Prep-scout refresh now signs the public prepFieldMeta payload, so a 机变 or bounty change that only updates effects or nextEnemies is pushed to teammates and spectators. Shop, funds, and private views stay off that payload. An unchanged public view is still not sent twice, and an explicit g.watch still resends the current view. Tests: node --test test/match/prep-watch.test.js test/match/prep-bench.test.js test/match/spectator.test.js test/match/feedback5-boss-prep-scout.test.js test/match/teammate-loadout.test.js test/ui/spectate-effects.test.js test/match/feedback5-boss-pair-prep.test.js — 25 pass, 0 fail. eslint on the touched files: 0 errors. --- server/match/match/watch.js | 39 ++------------- test/match/prep-watch.test.js | 90 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 34 deletions(-) diff --git a/server/match/match/watch.js b/server/match/match/watch.js index e79555f0..3fa5e35e 100644 --- a/server/match/match/watch.js +++ b/server/match/match/watch.js @@ -8,7 +8,6 @@ // Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). import { PHASE, ERR } from '../../../shared/constants.js'; -import { boardOrder, pieceDir } from '../board.js'; import { snapFrame } from '../fields.js'; import { OK, fail } from './common.js'; @@ -238,38 +237,9 @@ export class MatchWatch { } } - /** Board signature of a prep scout view (board, hand and temp rows: a shop or funds change is not a board change). - * Hand / temp entries carry their slot — `prepFieldMeta` draws x from it, so a piece moved to another slot is a - * change (review of PR #129). In a boss round's prep the pair partner's board is part of the view (item 51). */ - _prepScoutSig(ps) { - const parts = []; - const boardSig = (q) => { - for (const { r, c, piece } of boardOrder(q.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}`); - } - }; - boardSig(ps); - const m = this._bossMateOf(ps); - if (m) { parts.push(`|mate:${m.mate.playerId}:${m.side}`); boardSig(m.mate); } - for (let i = 0; i < ps.hand.length; i++) { - const piece = ps.hand[i]; - if (!piece) continue; - const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; - parts.push(`h${i}:${piece.uid}:${piece.id}:${items}`); - } - for (let i = 0; i < ps.temp.length; i++) { - const piece = ps.temp[i]; - if (!piece) continue; - const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; - parts.push(`t${i}:${piece.uid}:${piece.id}:${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. + * Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current view + * (the player who just asked to watch); everyone scouting it receives a new `m.field` only when that view changed. * A live battle field owns the `n:` id, so this stays quiet once fields exist. */ _notifyPrepScouts(ps, { to = null } = {}) { @@ -277,12 +247,13 @@ export class MatchWatch { const fid = `n:${ps.playerId}`; const watchers = this.watchersOf(fid); if (!watchers.length) return; - const sig = this._prepScoutSig(ps); + const meta = this.prepFieldMeta(ps); + // Deduplicate the public payload itself: effects and enemy previews can change without any piece moving (#346). + const sig = JSON.stringify(meta); 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); } } diff --git a/test/match/prep-watch.test.js b/test/match/prep-watch.test.js index 1932072c..2886b659 100644 --- a/test/match/prep-watch.test.js +++ b/test/match/prep-watch.test.js @@ -55,3 +55,93 @@ test('#87 prep watch: seat B moves during prep and seat A\'s view of B changes b assert.equal(h.allTo(S, 'm.private').length, 0); m.dispose(); }); + +for (const clientCombat of [false, true]) { + test(`#346 prep watch: a bounty choice refreshes existing teammate and spectator views (clientCombat=${clientCombat})`, (t) => { + const S = 's_spec'; + // Only the earlier battles are fake; the draft, choice, views and notifications are real. + const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 1, fake: true, clientCombat, spectators: [S] }).start(); + const m = h.m; + t.after(() => m.dispose()); + assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT)); + assert.equal(m.round, 3); + assert.equal(m.sp.family, 'bounty'); + assert.equal(m.spTurn(), 'p_0'); + assert.equal(m.sp.cards[0].name, '悬赏·持续III'); + for (const pid of ['p_1', S]) assert.deepEqual(m.handle(pid, { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true }); + h.flushAll(); + const before = h.lastTo(S, 'm.field'); + const counts = ['p_1', S].map((pid) => [pid, h.allTo(pid, 'm.field').length]); + + assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0 }), { ok: true }); + h.flushAll(); + for (const [pid, count] of counts) { + const fields = h.allTo(pid, 'm.field'); + assert.equal(fields.length, count + 1, `${pid}: the choice pushes one updated scout view`); + const after = fields.at(-1); + assert.deepEqual(after.units, before.units, 'no board, hand or temp piece changed'); + assert.deepEqual(after.effects.map((e) => e.name), [...before.effects.map((e) => e.name), '悬赏·持续III']); + assert.deepEqual(after.nextEnemies.filter((e) => e.tag === 'bounty').map((e) => [e.enemyKey, e.count]), [['enemy_1272_nhtank', 1]]); + assert.deepEqual(after.nextEnemies.filter((e) => e.tag !== 'bounty'), before.nextEnemies); + assert.deepEqual(after, m.prepFieldMeta(h.ps('p_0')), 'the watcher receives the current public view'); + assert.equal(Object.hasOwn(after, 'funds'), false); + assert.equal(Object.hasOwn(after, 'shop'), false); + } + + // Exercise dedup even when the player is dirty, rather than just flushing an empty queue. + m.markPrivate(h.ps('p_0')); + h.flushAll(); + h.flushAll(); + for (const [pid, count] of counts) assert.equal(h.allTo(pid, 'm.field').length, count + 1, `${pid}: unchanged public content is not pushed again`); + assert.equal(h.allTo(S, 'm.private').length, 0, 'the spectator never receives private player state'); + + assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true }); + assert.equal(h.allTo(S, 'm.field').length, counts[1][1] + 2, 'an explicit watch still resends the current view'); + assert.equal(h.allTo('p_1', 'm.field').length, counts[0][1] + 1, 'the resend does not duplicate the other watcher\'s view'); + + // Shop actions are only legal after the draft ends, in PREP. + h.toPrep(3); + h.flushAll(); + const prepCounts = ['p_1', S].map((pid) => [pid, h.allTo(pid, 'm.field').length]); + assert.deepEqual(m.handle('p_0', { t: 'g.freeze' }), { ok: true }); + h.flushAll(); + for (const [pid, count] of prepCounts) assert.equal(h.allTo(pid, 'm.field').length, count, `${pid}: a shop toggle does not change the scout view`); + assert.equal(h.allTo(S, 'm.private').length, 0); + assert.equal(m.errorCount, 0); + }); +} + +for (const field of ['effects', 'nextEnemies']) { + test(`#346 prep watch: ${field} changes alone refresh the existing view`, (t) => { + const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 1, fake: true, spectators: ['s_spec'] }).start(); + const m = h.m; + t.after(() => m.dispose()); + assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT)); + assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0 }), { ok: true }); + assert.deepEqual(m.handle('s_spec', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true }); + h.flushAll(); + const before = h.lastTo('s_spec', 'm.field'); + const count = h.allTo('s_spec', 'm.field').length; + const ps = h.ps('p_0'); + const bounty = ps.bounties[0]; + if (field === 'effects') bounty.roundsLeft--; + else bounty.card.count++; + m.markPrivate(ps); + h.flushAll(); + + assert.equal(h.allTo('s_spec', 'm.field').length, count + 1); + const after = h.lastTo('s_spec', 'm.field'); + assert.deepEqual(after.units, before.units); + if (field === 'effects') { + assert.equal(after.effects.at(-1).counter, 1, 'the remaining bounty battles update in place'); + assert.deepEqual(after.nextEnemies, before.nextEnemies); + } else { + const enemies = after.nextEnemies.filter((e) => e.tag === 'bounty'); + assert.ok(enemies.every((e) => e.enemyKey === 'enemy_1272_nhtank')); + assert.equal(enemies.reduce((n, e) => n + e.count, 0), 2, 'both bounty enemies reach the preview'); + assert.deepEqual(after.effects, before.effects); + } + assert.equal(h.allTo('s_spec', 'm.private').length, 0); + assert.equal(m.errorCount, 0); + }); +}