feedback7: merge fb7-g-ui — PRs #306 #309 #341 #349 #356 #358 #361 applied as-is (Grok coding run; static review p3: MERGE)
- Golden unchanged (union OK); broad node --test 5257 (5256 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
commit
f9969aeeef
15 files changed
+842
-55
No files matched your search
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR } from '../../shared/constants.js';
|
||||
import { makeMatch, legalTileFor, checkInvariants } from './harness.js';
|
||||
|
||||
const SILENCE = 'chess_char_2_02_a';
|
||||
const CATHY = 'chess_char_4_11_a';
|
||||
const EQUIPMENT = 'chess_item_1_02_e_b'; // Elite equipment does not merge.
|
||||
|
||||
function setup(t) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 1, fake: true }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
h.toPrep(1);
|
||||
return { h, m: h.m, ps: h.ps('p_0') };
|
||||
}
|
||||
|
||||
function deploy(m, ps, piece, chessId = piece.id) {
|
||||
const tile = legalTileFor(m, ps, chessId);
|
||||
assert.ok(tile);
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
|
||||
}
|
||||
|
||||
function equip(m, ps, owner, count) {
|
||||
return Array.from({ length: count }, () => {
|
||||
const item = ps.acquireItem(EQUIPMENT, { silent: true });
|
||||
assert.ok(item);
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: owner.uid }), { ok: true });
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function fillStorage(ps) {
|
||||
while ([...ps.hand, ...ps.temp].some((p) => p === null)) assert.ok(ps.acquireItem(EQUIPMENT, { silent: true }));
|
||||
}
|
||||
|
||||
function swapIntoTemp(m, ps, piece) {
|
||||
const idx = ps.hand.indexOf(piece);
|
||||
const filler = ps.temp.find((p) => p?.kind === 'item');
|
||||
assert.ok(idx >= 0 && filler);
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: filler.uid, to: { area: 'hand', idx } }), { ok: true });
|
||||
assert.equal(ps.find(piece.uid).area, 'temp');
|
||||
}
|
||||
|
||||
function snapshot(ps) {
|
||||
return structuredClone({
|
||||
hand: ps.hand, temp: ps.temp, board: ps.board, funds: ps.funds, pendingFunds: ps.pendingFunds,
|
||||
stats: ps.stats, round: ps.round, tempDue: ps._tempDue,
|
||||
});
|
||||
}
|
||||
|
||||
const inventory = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean);
|
||||
const tokensOf = (ps, owner) => inventory(ps).filter((p) => p.kind === 'token' && p.ownerUid === owner.uid);
|
||||
|
||||
for (const area of ['hand', 'temp']) {
|
||||
for (const itemCount of [1, 2]) {
|
||||
test(`sale uses the freed ${area} summon slot to return ${itemCount} equipment item(s)`, (t) => {
|
||||
const { h, m, ps } = setup(t);
|
||||
const owner = ps.acquireChess(SILENCE, { silent: true });
|
||||
deploy(m, ps, owner);
|
||||
const items = equip(m, ps, owner, itemCount);
|
||||
const other = ps.acquireChess(SILENCE, { silent: true });
|
||||
deploy(m, ps, other);
|
||||
const [stack] = tokensOf(ps, owner);
|
||||
fillStorage(ps);
|
||||
if (area === 'temp') swapIntoTemp(m, ps, stack);
|
||||
assert.equal(ps.find(stack.uid).area, area);
|
||||
// Two items need one existing free slot as well as the slot released by the stack.
|
||||
if (itemCount === 2) {
|
||||
const filler = ps.temp.find((p) => p?.kind === 'item');
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true });
|
||||
}
|
||||
const untouched = structuredClone(inventory(ps).filter((p) => p !== owner && p !== stack));
|
||||
const funds = ps.funds;
|
||||
const poolBefore = m.pool.left(SILENCE);
|
||||
const copies = owner.poolCopies;
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
||||
assert.equal(ps.find(owner.uid), null);
|
||||
assert.deepEqual(tokensOf(ps, owner), []);
|
||||
assert.equal(ps.funds, funds + 1);
|
||||
assert.equal(m.pool.left(SILENCE), poolBefore + copies);
|
||||
assert.deepEqual(owner.items, []);
|
||||
for (const item of items) {
|
||||
assert.equal(ps.find(item.uid).piece, item, 'the original equipment object and UID return');
|
||||
assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area));
|
||||
}
|
||||
assert.equal(ps.find(items[0].uid).area, area, 'hand is preferred, with overflow into temp');
|
||||
for (const piece of untouched) assert.deepEqual(ps.find(piece.uid).piece, piece);
|
||||
assert.equal(inventory(ps).length, untouched.length + items.length);
|
||||
assert.deepEqual(h.logs.warn, []);
|
||||
checkInvariants(m);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { name, ownerId, area, itemCount, stackCount } of [
|
||||
{ name: 'one hand stack cannot hold two equipment items', ownerId: SILENCE, area: 'hand', itemCount: 2, stackCount: 1 },
|
||||
{ name: 'one temp stack cannot hold two equipment items', ownerId: SILENCE, area: 'temp', itemCount: 2, stackCount: 1 },
|
||||
{ name: 'a two-summon stack frees only one slot', ownerId: CATHY, area: 'hand', itemCount: 2, stackCount: 2 },
|
||||
{ name: 'board summons and foreign stacks free no usable slot', ownerId: SILENCE, area: 'board', itemCount: 1, stackCount: 1 },
|
||||
]) {
|
||||
test(`sale rejects atomically when ${name}`, (t) => {
|
||||
const { m, ps } = setup(t);
|
||||
const owner = ps.acquireChess(ownerId, { silent: true });
|
||||
deploy(m, ps, owner);
|
||||
const items = equip(m, ps, owner, itemCount);
|
||||
const other = ps.acquireChess(SILENCE, { silent: true });
|
||||
deploy(m, ps, other);
|
||||
const [stack] = tokensOf(ps, owner);
|
||||
assert.equal(stack.count, stackCount);
|
||||
if (area === 'board') deploy(m, ps, stack, owner.id);
|
||||
fillStorage(ps);
|
||||
if (area === 'temp') swapIntoTemp(m, ps, stack);
|
||||
assert.equal(ps.find(stack.uid).area, area);
|
||||
const before = snapshot(ps);
|
||||
const poolBefore = m.pool.left(ownerId);
|
||||
assert.equal(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }).error, ERR.HAND_FULL);
|
||||
assert.deepEqual(snapshot(ps), before, 'pieces, equipment, funds, and bookkeeping remain unchanged');
|
||||
assert.equal(m.pool.left(ownerId), poolBefore);
|
||||
for (const item of items) assert.equal(ps.find(item.uid).area, 'equipped');
|
||||
checkInvariants(m);
|
||||
// Free exactly one additional slot and retry the same public intent.
|
||||
const filler = ps.temp.find((p) => p?.kind === 'item');
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true });
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
||||
assert.equal(ps.find(owner.uid), null);
|
||||
assert.deepEqual(tokensOf(ps, owner), []);
|
||||
assert.equal(ps.funds, before.funds + 1);
|
||||
for (const item of items) {
|
||||
assert.equal(ps.find(item.uid).piece, item);
|
||||
assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area));
|
||||
}
|
||||
assert.equal(tokensOf(ps, other).length, 1);
|
||||
checkInvariants(m);
|
||||
});
|
||||
}
|
||||
|
||||
for (const area of ['hand', 'temp']) {
|
||||
test(`sale still counts the operator's own ${area} slot`, (t) => {
|
||||
const { m, ps } = setup(t);
|
||||
const owner = ps.acquireChess(SILENCE, { silent: true });
|
||||
const [item] = equip(m, ps, owner, 1);
|
||||
fillStorage(ps);
|
||||
if (area === 'temp') swapIntoTemp(m, ps, owner);
|
||||
assert.equal(ps.find(owner.uid).area, area);
|
||||
const funds = ps.funds;
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
||||
assert.equal(ps.find(owner.uid), null);
|
||||
assert.equal(ps.find(item.uid).piece, item);
|
||||
assert.equal(ps.find(item.uid).area, area);
|
||||
assert.equal(ps.funds, funds + 1);
|
||||
checkInvariants(m);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user