Automatically return the full combined hand when it is the only legal special-Karama return. Preserve real choices and restore the suspended response or auction payment through the same settlement. Record the actual blind draw as owner-only historical card faces, with a bound integrity receipt; legacy forced saves describe held-card history truthfully. Add readable private history and enlarged inspectors. Both manual and forced returns publish only a faction/action completion tag. Wake idle legacy exchanges through production recovery without writes for real choices. Keep provisional auction funding rules and release gates. Validation: types, lint, 5150 offline tests, build and 49 HTTP tests passed. Independent review resolved history substitution and root review completed idle recovery wiring. New tests cover 1-4 forced draws, real choices, overlays, exact saved history, immutable rejection, all four legal AI paths, production competing actions/recovery, private views and custody. One genuine Advanced four-player sample completed 158 actions with 4 JSON restores and no rejected action; no difficulty tuning. Browser QA QUTHVPTT used genuine Advanced setup and conserved Bidding positions. The actual power returned four cards without a prompt; private history/enlargement and exact refresh worked. Phone panel fit without horizontal overflow with 44px inspector controls. Screenshot capture timed out; complete visual acceptance and transient notice capture remain open. Preserved every prior row across all ten tables, including 1586 rooms and 3354 seats. New QA/integration records bring totals to 1626 rooms and 3442 seats. No reset, restart or automation.
323 lines
17 KiB
TypeScript
323 lines
17 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
applyAction, createGame, initializeBaseGameForAudit, joinGame, newPlayer,
|
|
normalizeAutomaticGame, viewGame, type Action, type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
|
|
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
|
const player = (game: Game, id: string) => game.players.find(candidate => candidate.id === id)!;
|
|
const inventory = (game: Game) => [
|
|
...game.deck, ...game.discard, ...game.players.flatMap(candidate => candidate.hand),
|
|
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
|
|
].map(card => card.id).sort();
|
|
function act(game: Game, owner: string, action: Action) {
|
|
const before = structuredClone(game), cards = inventory(game);
|
|
const next = reload(applyAction(game, owner, action));
|
|
assert.deepEqual(game, before);
|
|
assert.deepEqual(inventory(next), cards);
|
|
assert.equal(new Set(cards).size, cards.length);
|
|
return next;
|
|
}
|
|
function hold(game: Game, owner: string, kind: string) {
|
|
const index = game.deck.findIndex(card => card.effect === kind || card.kind === kind);
|
|
assert.ok(index >= 0, kind);
|
|
const card = game.deck.splice(index, 1)[0];
|
|
player(game, owner).hand.push(card);
|
|
return card.id;
|
|
}
|
|
/** Genuine Advanced setup followed by a conserved later Bidding position. */
|
|
function fixture(extra: string[] = [], count = 4) {
|
|
let game = createGame('HARKAUTO', newPlayer('h', 'Harkonnen', 'harkonnen'), true);
|
|
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
|
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
|
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
|
game = initializeBaseGameForAudit(game);
|
|
for (const p of game.players)
|
|
if (player(game, p.id).traitorChoices.length)
|
|
game = applyAction(game, p.id, { type: 'traitor', leader: player(game, p.id).traitorChoices[0] });
|
|
assert.equal(game.status, 'playing');
|
|
Object.assign(game, { phase: 3, active: 'e', storm: 18, phaseOpening: null,
|
|
response: null, decision: null, ready: [] });
|
|
for (const p of game.players) {
|
|
game.deck.push(...p.hand); p.hand = []; p.spice = 20;
|
|
}
|
|
const karama = hold(game, 'h', 'karama');
|
|
const blocker = hold(game, 'g', 'karama');
|
|
const extras = extra.map(kind => hold(game, 'h', kind));
|
|
for (let index = 0; index < count; index++) hold(game, 'e', index % 2 ? 'poison' : 'projectile');
|
|
game.auction = { cards: [game.deck.shift()!], index: 0, bid: 0, bidder: null,
|
|
active: 'e', passed: [], opener: game.order.indexOf('e') };
|
|
assert.equal(inventory(game).length, 33);
|
|
return { game, karama, blocker, extras };
|
|
}
|
|
const exchange = (game: Game, karama: string, amount: number) =>
|
|
act(game, 'h', { type: 'card', mode: 'special', card: karama, target: 'e', amount });
|
|
const exchangeNotices = (game: Game) => game.log.filter(entry => entry.automatic?.name === 'Harkonnen exchange');
|
|
function stable(game: Game) {
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(game))), game);
|
|
assert.equal(new Set(inventory(game)).size, inventory(game).length);
|
|
}
|
|
function privateInspection(game: Game) {
|
|
assert.ok(game.harkonnenExchangeInspection);
|
|
const { signature, ...inspection } = game.harkonnenExchangeInspection;
|
|
assert.equal(typeof signature, 'string');
|
|
assert.ok(signature.length);
|
|
assert.deepEqual(viewGame(game, 'h').harkonnenExchangeInspection, inspection);
|
|
for (const id of ['e', 'g']) {
|
|
const view = viewGame(game, id);
|
|
assert.equal(view.harkonnenExchangeInspection, null);
|
|
assert.equal(view.players.find(p => p.id === 'h')!.hand, undefined);
|
|
assert.equal('pendingExchange' in view, false);
|
|
}
|
|
for (const card of game.harkonnenExchangeInspection.cards)
|
|
for (const entry of game.log.filter(line => /unseen cards|returned .*cards/.test(line.text))) {
|
|
assert.equal(entry.text.includes(card.id), false);
|
|
assert.equal(entry.text.includes(card.name), false);
|
|
}
|
|
}
|
|
function reject(game: Game, owner: string, action: Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, owner, action));
|
|
assert.deepEqual(game, before);
|
|
}
|
|
function rejectSaved(game: Game) {
|
|
const before = structuredClone(game);
|
|
for (const check of [
|
|
() => applyAction(game, 'h', { type: 'advanceBots' }),
|
|
() => normalizeAutomaticGame(game),
|
|
() => viewGame(game, 'h'),
|
|
() => viewGame(game, 'g'),
|
|
]) { assert.throws(check); assert.deepEqual(game, before); }
|
|
}
|
|
|
|
void test('one to four forced cards return automatically and their actual blind draw remains private after JSON restore', t => {
|
|
for (const count of [1, 2, 3, 4]) {
|
|
const { game, karama } = fixture([], count);
|
|
const target = player(game, 'e').hand.map(card => card.id).sort();
|
|
const done = exchange(game, karama, count);
|
|
assert.equal(done.pendingExchange, null);
|
|
assert.equal(done.decision, null);
|
|
assert.equal(player(done, 'h').hand.length, 0);
|
|
assert.deepEqual(player(done, 'e').hand.map(card => card.id).sort(), target);
|
|
assert.equal(done.discard.filter(card => card.id === karama).length, 1);
|
|
assert.deepEqual(done.harkonnenExchangeInspection, {
|
|
owner: 'h', target: 'e', turn: game.turn, phase: 3, kind: 'draw',
|
|
cards: done.harkonnenExchangeInspection!.cards,
|
|
signature: done.harkonnenExchangeInspection!.signature,
|
|
});
|
|
assert.deepEqual(done.harkonnenExchangeInspection!.cards.map(card => card.id).sort(), target);
|
|
assert.deepEqual(exchangeNotices(done).map(entry => entry.automatic), [{ faction: 'harkonnen', name: 'Harkonnen exchange' }]);
|
|
privateInspection(done);
|
|
const ownView = viewGame(done, 'h');
|
|
ownView.harkonnenExchangeInspection!.cards[0].name = 'Changed view copy';
|
|
assert.notEqual(done.harkonnenExchangeInspection!.cards[0].name, 'Changed view copy');
|
|
const noDraw = t.mock.method(globalThis.crypto, 'getRandomValues', () => { throw new Error('A saved exchange must not draw again.'); });
|
|
stable(done);
|
|
privateInspection(reload(done));
|
|
noDraw.mock.restore();
|
|
const later = reload(done);
|
|
later.turn++; later.phase = 4; later.auction = null;
|
|
later.deck.push(...done.auction!.cards);
|
|
later.discard.push(player(later, 'e').hand.shift()!);
|
|
assert.deepEqual(later.harkonnenExchangeInspection, done.harkonnenExchangeInspection,
|
|
'Historical knowledge does not freeze a card in the hand it returned to.');
|
|
privateInspection(later);
|
|
stable(later);
|
|
}
|
|
});
|
|
|
|
void test('a real partial draw keeps the return choice and its inspection records drawn cards rather than selected returns', () => {
|
|
const { game, karama, extras } = fixture(['shield', 'snooper']);
|
|
const pending = exchange(game, karama, 2);
|
|
assert.equal(pending.decision?.kind, 'handExchange');
|
|
assert.equal(player(pending, 'h').hand.length, 4);
|
|
assert.equal(player(pending, 'e').hand.length, 2);
|
|
assert.equal(exchangeNotices(pending).length, 0);
|
|
const inspection = structuredClone(pending.harkonnenExchangeInspection!);
|
|
assert.ok(inspection.cards.every(card => player(game, 'e').hand.some(original => original.id === card.id)));
|
|
privateInspection(pending);
|
|
stable(pending);
|
|
reject(pending, 'e', { type: 'decision', returnCards: extras });
|
|
reject(pending, 'h', { type: 'decision', returnCards: [extras[0], extras[0]] });
|
|
const done = act(pending, 'h', { type: 'decision', returnCards: [extras[0], inspection.cards[0].id] });
|
|
assert.equal(done.pendingExchange, null);
|
|
assert.deepEqual(done.harkonnenExchangeInspection, inspection);
|
|
assert.equal(exchangeNotices(done).length, 1);
|
|
assert.equal(exchangeNotices(done)[0].text.includes('automatically'), false);
|
|
privateInspection(done);
|
|
stable(done);
|
|
});
|
|
|
|
void test('automatic return restores a real private gift response with its prior passes and spends its income only once', () => {
|
|
const fixtureState = fixture([], 2);
|
|
let game = fixtureState.game;
|
|
player(game, 'e').ally = 'h'; player(game, 'h').ally = 'e';
|
|
game = act(game, 'e', { type: 'emperorGift', amount: 7 });
|
|
game = act(game, 'h', { type: 'passResponse' });
|
|
const saved = structuredClone(game.response);
|
|
assert.equal(saved?.kind, 'emperorGift');
|
|
const done = exchange(game, fixtureState.karama, 2);
|
|
assert.deepEqual(done.response, saved);
|
|
assert.equal(player(done, 'h').spice, 20);
|
|
assert.equal(viewGame(done, 'g').response!.amount, undefined);
|
|
assert.equal(exchangeNotices(done).length, 1);
|
|
privateInspection(done);
|
|
stable(done);
|
|
const paid = act(done, 'g', { type: 'passResponse' });
|
|
assert.equal(player(paid, 'h').spice, 27);
|
|
assert.equal(player(paid, 'e').spice, 13);
|
|
assert.equal(exchangeNotices(paid).length, 1);
|
|
stable(paid);
|
|
const canceled = act(done, 'g', { type: 'card', mode: 'cancel', card: fixtureState.blocker });
|
|
assert.equal(player(canceled, 'h').spice, 20);
|
|
assert.equal(player(canceled, 'e').spice, 20);
|
|
assert.equal(exchangeNotices(canceled).length, 1);
|
|
stable(canceled);
|
|
});
|
|
|
|
void test('automatic return restores an actually won unfunded auction payment after giving its Karama back', () => {
|
|
const fixtureState = fixture([], 1);
|
|
let game = fixtureState.game;
|
|
const [targetCard] = player(game, 'e').hand.splice(0);
|
|
game.deck.push(targetCard);
|
|
player(game, 'e').hand.push(player(game, 'g').hand.splice(0)[0]);
|
|
player(game, 'e').spice = 0;
|
|
game = act(game, 'e', { type: 'bid', amount: 30 });
|
|
for (let step = 0; game.auction?.active && !game.decision && step < 8; step++)
|
|
game = act(game, game.auction.active, { type: 'passBid' });
|
|
assert.deepEqual(game.decision, { kind: 'auctionPayment', player: 'e' });
|
|
const auction = structuredClone(game.auction);
|
|
const done = exchange(game, fixtureState.karama, 1);
|
|
assert.deepEqual(done.decision, game.decision);
|
|
assert.deepEqual(done.auction, auction);
|
|
assert.equal(player(done, 'e').hand[0].effect, 'karama');
|
|
assert.equal(exchangeNotices(done).length, 1);
|
|
stable(done);
|
|
});
|
|
|
|
void test('Truthtrance keeps priority when its spend makes a real pending exchange forced, then the return finishes once', () => {
|
|
const { game, karama, extras } = fixture(['truthtrance'], 2);
|
|
let pending = exchange(game, karama, 2);
|
|
const inspection = structuredClone(pending.harkonnenExchangeInspection);
|
|
pending = act(pending, 'h', { type: 'card', card: extras[0] });
|
|
assert.equal(player(pending, 'h').hand.length, 3, 'The committed Truthtrance stays physically held until its answer completes.');
|
|
assert.ok(pending.truthtrance);
|
|
assert.equal(pending.decision?.kind, 'handExchange');
|
|
assert.equal(exchangeNotices(pending).length, 0);
|
|
stable(pending);
|
|
privateInspection(pending);
|
|
reject(pending, 'h', { type: 'decision', returnCards: player(pending, 'h').hand.map(card => card.id) });
|
|
for (let step = 0; pending.truthtrance?.stage === 'priority' && step < 8; step++) {
|
|
const voter = pending.players.find(p => !pending.truthtrance!.passed.includes(p.id))!;
|
|
pending = act(pending, voter.id, { type: 'truthPass' });
|
|
}
|
|
pending = act(pending, 'h', { type: 'truthAsk', question: { kind: 'fact', target: 'e',
|
|
fact: { kind: 'spice', compare: 'eq', value: 20 } } });
|
|
assert.equal(exchangeNotices(pending).length, 0);
|
|
const done = act(pending, 'e', { type: 'truthAnswer', answer: 'yes' });
|
|
assert.equal(done.truthtrance, null);
|
|
assert.equal(done.pendingExchange, null);
|
|
assert.deepEqual(done.harkonnenExchangeInspection, inspection);
|
|
assert.equal(exchangeNotices(done).length, 1);
|
|
assert.equal(done.discard.filter(card => card.id === extras[0]).length, 1);
|
|
stable(done);
|
|
});
|
|
|
|
void test('a legacy forced save records the known returned hand without claiming to reconstruct its original blind draw', t => {
|
|
const { game, karama, extras } = fixture(['shield'], 2);
|
|
const legacy = exchange(game, karama, 2);
|
|
// Conserved old saved position: only the mandatory return remains, with no historical inspection field.
|
|
const other = player(legacy, 'h').hand.find(card => card.id === extras[0])!;
|
|
player(legacy, 'h').hand = player(legacy, 'h').hand.filter(card => card.id !== other.id);
|
|
legacy.deck.push(other);
|
|
delete legacy.harkonnenExchangeInspection;
|
|
const known = structuredClone(player(legacy, 'h').hand);
|
|
const before = structuredClone(legacy);
|
|
t.mock.method(globalThis.crypto, 'getRandomValues', () => { throw new Error('Legacy return must not reroll the draw.'); });
|
|
const done = reload(normalizeAutomaticGame(legacy));
|
|
assert.deepEqual(legacy, before);
|
|
assert.deepEqual(done.harkonnenExchangeInspection, {
|
|
owner: 'h', target: 'e', turn: game.turn, phase: 3, kind: 'return', cards: known,
|
|
signature: done.harkonnenExchangeInspection!.signature,
|
|
});
|
|
assert.deepEqual(inventory(done), inventory(legacy));
|
|
assert.equal(done.pendingExchange, null);
|
|
assert.equal(exchangeNotices(done).length, 1);
|
|
privateInspection(done);
|
|
stable(done);
|
|
});
|
|
|
|
void test('all four legal AI paths retain real choices and can continue after an automatic forced return', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const f = fixture(['shield'], 2);
|
|
player(f.game, 'h').bot = difficulty;
|
|
const pending = exchange(f.game, f.karama, 2);
|
|
const actions = botActions(viewGame(pending, 'h'));
|
|
assert.equal(actions.length, 1);
|
|
assert.equal(actions[0].type, 'decision');
|
|
assert.equal(act(pending, 'h', actions[0]).pendingExchange, null);
|
|
const forced = fixture([], 2);
|
|
player(forced.game, 'e').bot = difficulty;
|
|
const done = exchange(forced.game, forced.karama, 2);
|
|
const next = botActions(viewGame(done, 'e'));
|
|
assert.ok(next.length);
|
|
assert.ok(next.every(action => action.type !== 'decision'));
|
|
act(done, 'e', next[0]);
|
|
}
|
|
});
|
|
|
|
void test('malformed saved counts, custody, inspection and continuation reject actions, views and normalization immutably', () => {
|
|
const f = fixture(['shield'], 2);
|
|
const pending = exchange(f.game, f.karama, 2);
|
|
const changes: ((game: Game) => void)[] = [
|
|
game => { if (game.decision?.kind === 'handExchange') game.decision.count = 0; },
|
|
game => { if (game.decision?.kind === 'handExchange') game.decision.count = 1.5; },
|
|
game => { if (game.decision?.kind === 'handExchange') game.decision.count = 1; },
|
|
game => { if (game.decision?.kind === 'handExchange') game.decision.target = 'h'; },
|
|
game => { if (game.decision?.kind === 'handExchange') game.decision.player = 'e'; },
|
|
game => { game.pendingExchange = null; },
|
|
game => { game.pendingExchange!.decision = structuredClone(game.decision); },
|
|
game => { delete (game.pendingExchange as Partial<NonNullable<Game['pendingExchange']>>).response; },
|
|
game => { game.decision = null; },
|
|
game => { game.phase = 4; },
|
|
game => { game.turn++; },
|
|
game => { player(game, 'h').specialKaramaUsed = false; },
|
|
game => { player(game, 'h').hand.push(structuredClone(player(game, 'h').hand[0])); },
|
|
game => { game.deck.push(structuredClone(player(game, 'e').hand[0])); },
|
|
game => { game.harkonnenExchangeInspection!.owner = 'e'; },
|
|
game => { game.harkonnenExchangeInspection!.target = 'g'; },
|
|
game => { game.harkonnenExchangeInspection!.cards[0].name = 'Invented'; },
|
|
game => { game.harkonnenExchangeInspection!.cards[1] = structuredClone(game.harkonnenExchangeInspection!.cards[0]); },
|
|
];
|
|
for (const change of changes) { const malformed = reload(pending); change(malformed); rejectSaved(malformed); }
|
|
const duplicateDraw = fixture([], 2);
|
|
player(duplicateDraw.game, 'e').hand.push(structuredClone(player(duplicateDraw.game, 'e').hand[0]));
|
|
reject(duplicateDraw.game, 'h', { type: 'card', mode: 'special', card: duplicateDraw.karama, target: 'e', amount: 1 });
|
|
});
|
|
|
|
void test('completed inspection binds its original canonical faces, order, target, turn and draw provenance', () => {
|
|
const f = fixture([], 2);
|
|
const completed = exchange(f.game, f.karama, 2);
|
|
completed.turn++;
|
|
stable(completed);
|
|
const changes: ((game: Game) => void)[] = [
|
|
game => { game.harkonnenExchangeInspection!.cards[0] = structuredClone(game.deck[0]); },
|
|
game => { game.harkonnenExchangeInspection!.cards.reverse(); },
|
|
game => { game.harkonnenExchangeInspection!.target = 'g'; },
|
|
game => { game.harkonnenExchangeInspection!.turn = game.turn; },
|
|
game => { game.harkonnenExchangeInspection!.kind = 'return'; },
|
|
game => { game.harkonnenExchangeInspection!.signature += 'changed'; },
|
|
game => { delete (game.harkonnenExchangeInspection as Partial<NonNullable<Game['harkonnenExchangeInspection']>>).signature; },
|
|
];
|
|
for (const change of changes) {
|
|
const malformed = reload(completed);
|
|
change(malformed);
|
|
assert.deepEqual(inventory(malformed), inventory(completed), 'Only the historical knowledge payload changed.');
|
|
rejectSaved(malformed);
|
|
}
|
|
privateInspection(completed);
|
|
});
|