feedback7 G-UI: the spectator prep view updates effects and the enemy preview after a 机变 pick (PR #349 by @siruimei07)

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.
This commit is contained in:
siruimei07 authored and sganggs committed 2026-10-08 18:43:24 +08:00
1 parent e2503c7f60
commit 246622beff
2 files changed
+95 -34

No files matched your search

+5 -34
View File
@@ -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:<pid>` 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);
}
}
+90
View File
@@ -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);
});
}