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.
148 lines
7.6 KiB
JavaScript
148 lines
7.6 KiB
JavaScript
// 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 { GEO, 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);
|
|
const hu = first.units.find((u) => u.uid === piece.uid);
|
|
assert.ok(hu, `${pid}: the piece is held — it scouts as a unit on the hand row`);
|
|
assert.equal(hu.y, GEO.HAND_ROW, `${pid}: on the bench (row 7), not on the field`);
|
|
}
|
|
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();
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|