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:
sganggs committed 2026-10-08 20:14:09 +08:00
commit f9969aeeef
15 files changed
+842 -55

No files matched your search

+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);
});
}
+153
View File
@@ -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);
});
}