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); + }); +}