Complete forced Harkonnen returns with private saved inspection

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.
This commit is contained in:
Quintine committed 2026-09-21 16:45:20 +10:00
1 parent a3bc0ac837
commit 0f79f28ccb
21 files changed
+847 -52

No files matched your search

+3 -1
View File
@@ -311,13 +311,15 @@ void test('promise-bound Worthless candidate is conservatively retained but an i
void test('hand-exchange suspension waits; restored response uses newly returned card and original passes', () => {
let g = fixture(['harkonnen', 'emperor', 'guild']);
g.phase = 3;
g.players[0].specialKaramaUsed = true;
g.pendingExchange = {
response: { kind: 'guildIncome', owner: 'c', amount: 3, passed: ['a'] },
decision: null,
};
g.response = null;
g.decision = { kind: 'handExchange', player: 'a', target: 'b', count: 1 };
g.players[0].hand = [karamas[0]];
placeFixtureHand(g, 0, [karamas[0], shield]);
g = normalizeAutomaticGame(g);
assert.equal(g.players[2].spice, 10);
assert.equal(g.decision?.kind, 'handExchange');
+8
View File
@@ -154,6 +154,14 @@ void test('owner can retake control through Truthtrance, phase opening, response
};
},
(g) => {
// A valid legacy Advanced exchange with a genuine return choice.
g.advanced = true; g.phase = 3;
g.deck = baseDeck();
const take = (kind: string) => g.deck.splice(g.deck.findIndex(card => card.kind === kind || card.effect === kind), 1)[0];
g.discard = [take('karama')];
g.players[0] = { ...newPlayer('a', 'Owner', 'harkonnen'),
autopilot: 'Medium', specialKaramaUsed: true, hand: [take('shield'), take('projectile')] };
g.pendingExchange = { response: null, decision: null };
g.decision = { kind: 'handExchange', player: 'a', target: 'e', count: 1 };
},
];
+5
View File
@@ -194,6 +194,8 @@ void test('real Harkonnen extraction reserves future recipient slots and enough
hold(g, 'r', 'Distrans');
const give = hold(g, 'r', 'Shield');
const k = hold(g, 'e', 'Karama');
// Keep a real return choice after the blind draw; a Karama-only hand now returns automatically.
const retained = hold(g, 'e', 'Truthtrance');
for (const name of ['Maula Pistol', 'Lasgun', 'Baliset', 'Snooper'].slice(
0,
initialSize,
@@ -208,6 +210,8 @@ void test('real Harkonnen extraction reserves future recipient slots and enough
target: 'a',
amount: 2,
});
assert.equal(g.decision?.kind, 'handExchange');
assert.equal(g.players[2].hand.length, 3);
if (initialSize === 4) {
const snapshot = structuredClone(g);
assert.throws(() => transfer(g, 'r', 'a', give), /room|exchange/);
@@ -220,6 +224,7 @@ void test('real Harkonnen extraction reserves future recipient slots and enough
.map((c) => c.id),
});
assert.equal(g.players[1].hand.length, 4);
assert.deepEqual(g.players[2].hand.map(card => card.id), [retained]);
assert.deepEqual(inventory(g), before);
}
let g = fixture();
+322
View File
@@ -0,0 +1,322 @@
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);
});
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { HarkonnenExchangeInspection } from '../components/harkonnen-exchange-inspection';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { baseDeck } from '../game/cards';
function view() {
const game = createGame('INSPECTION', newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
const result = viewGame(game, 'h');
result.harkonnenExchangeInspection = {
owner: 'h', target: 'e', turn: 3, phase: 3, kind: 'draw',
cards: baseDeck().filter(card => card.name === 'Shield').slice(0, 1),
};
return result;
}
const render = (game: GameView) => renderToStaticMarkup(createElement(HarkonnenExchangeInspection, { game }));
void test('private exchange record offers readable cards and inspection without a return confirmation', () => {
const html = render(view());
assert.match(html, /Your Harkonnen inspection · Turn 3/);
assert.match(html, /Cards you took and inspected from Emperor/);
assert.match(html, /may have changed hands since/);
assert.match(html, /Shield/);
assert.match(html, /Inspect card: Shield/);
assert.doesNotMatch(html, /Return cards|Confirm|Acknowledge/);
});
void test('legacy history describes held cards without inventing their source', () => {
const game = view(); game.harkonnenExchangeInspection!.kind = 'return';
const html = render(game);
assert.match(html, /Cards in your hand before the automatic return to Emperor/);
assert.doesNotMatch(html, /Cards you took/);
});
void test('absent and mismatched-owner records render no private card information', () => {
const game = view(); game.me = 'e'; assert.equal(render(game), '');
game.me = 'h'; game.harkonnenExchangeInspection = null; assert.equal(render(game), '');
});
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, initializeBaseGameForAudit, type Action, type Game } from '../game/engine';
import { unitStore } from './fixture-nexus-room-store';
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
const clock = { now: () => 30000, sleep: async () => {} };
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
void test('authenticated forced exchange races once and restores the same owner-only inspection without redrawing cards', async t => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Harkonnen inspection', 'harkonnen', true, []);
const code = made.view.code;
const joined = [await f.rooms.joinRoom(code, 'Exchange target', 'guild'), await f.rooms.joinRoom(code, 'Observer', 'emperor')];
const auths = await Promise.all([made.token!, ...joined.map(p => p.token!)].map(token => f.restart().authenticate(code, token)));
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
for (let i = 0; i < auths.length; i++) await act(i, { type: 'ready' });
let game = await f.restart().readRoom(code);
const seedVersion = game.version;
// The audit seam runs genuine Advanced setup; no public start gate is changed.
game = initializeBaseGameForAudit(game);
game.version = seedVersion + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(game), game.version, code, seedVersion).changes, 1);
for (let i = 0; i < auths.length; i++) {
const choices = player(game, auths[i].playerId).traitorChoices;
if (choices.length) game = await act(i, { type: 'traitor', leader: choices[0] });
}
assert.equal(game.status, 'playing');
const inventory = sampleInventory(game), seats = f.sqlite.prepare('SELECT * FROM seats').all();
const owner = auths[0].playerId, target = auths[1].playerId;
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
const give = (id: string, effect: string) => {
const at = game.deck.findIndex(card => card.effect === effect || card.kind === effect); assert.ok(at >= 0);
const card = game.deck.splice(at, 1)[0]; player(game, id).hand.push(card); return card.id;
};
const karama = give(owner, 'karama');
give(target, 'shield'); give(target, 'snooper'); give(target, 'projectile'); give(target, 'poison');
Object.assign(game, {
phase: 3, phaseOpening: null, active: target, ready: [], response: null, decision: null,
auction: { cards: game.deck.splice(0, 1), index: 0, bid: 0, bidder: null, active: target, passed: [], opener: 0 },
});
verifySampleCustody(game, inventory);
const stagedVersion = game.version; game.version++;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(game), game.version, code, stagedVersion).changes, 1);
const targetCards = structuredClone(player(game, target).hand), parentAuction = structuredClone(game.auction);
const version = game.version;
const action: Action = { type: 'card', card: karama, mode: 'special', target, amount: 4 };
const before = f.sqlite.prepare('SELECT state,version FROM rooms').all();
await assert.rejects(f.restart().act(code, auths[2], version, action, clock));
assert.deepEqual(f.sqlite.prepare('SELECT state,version FROM rooms').all(), before);
let arrivals = 0; let release!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
const results = await Promise.allSettled([
f.rooms.act(code, auths[0], version, action, clock),
f.restart().act(code, auths[0], version, action, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
game = await f.restart().readRoom(code);
assert.equal(game.version, version + 1);
assert.equal(game.decision, null); assert.equal(game.pendingExchange, null);
assert.equal(player(game, owner).hand.length, 0);
assert.equal(player(game, owner).specialKaramaUsed, true);
assert.equal(game.discard.filter(card => card.id === karama).length, 1);
assert.deepEqual(player(game, target).hand.map(card => card.id).sort(), targetCards.map(card => card.id).sort());
assert.deepEqual(game.auction, parentAuction);
const inspection = structuredClone(game.harkonnenExchangeInspection);
assert.ok(inspection); assert.equal(inspection.kind, 'draw'); assert.equal(inspection.owner, owner);
const { signature, ...visibleInspection } = inspection;
assert.equal(typeof signature, 'string'); assert.ok(signature.length);
assert.deepEqual(inspection.cards.map(card => card.id).sort(), targetCards.map(card => card.id).sort());
const committed = f.sqlite.prepare('SELECT state,version FROM rooms').all();
for (let i = 0; i < auths.length; i++) {
const view = await f.restart().readSeatView(code, auths[i]);
assert.deepEqual(view.harkonnenExchangeInspection, i === 0 ? visibleInspection : null);
if (view.harkonnenExchangeInspection) assert.equal('signature' in view.harkonnenExchangeInspection, false);
assert.equal('pendingExchange' in view, false);
for (const other of view.players.filter(p => p.id !== auths[i].playerId)) assert.equal(other.hand, undefined);
}
await assert.rejects(f.restart().act(code, auths[0], version, action, clock));
await assert.rejects(f.restart().act(code, auths[0], game.version, { type: 'decision', returnCards: inspection.cards.map(card => card.id) }, clock));
assert.deepEqual(f.sqlite.prepare('SELECT state,version FROM rooms').all(), committed);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
verifySampleCustody(game, inventory);
// Independent legacy scenario in the same disposable store: a real exchange
// loses its extra choice before a worker restart, predating inspection records.
let legacy = JSON.parse(before[0].state as string) as Game;
const extra = legacy.deck.shift()!;
player(legacy, owner).hand.push(extra);
legacy = applyAction(legacy, owner, action);
assert.equal(legacy.decision?.kind, 'handExchange');
assert.equal(f.rooms.needsAutomaticRoomRecovery(legacy), true);
legacy.version = game.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(legacy), legacy.version, code, game.version).changes, 1);
const optional = f.sqlite.prepare('SELECT state,version FROM rooms').all();
await f.restart().continueRoomAutomatic(code, clock);
assert.deepEqual(f.sqlite.prepare('SELECT state,version FROM rooms').all(), optional,
'A genuine return choice remains waiting without a write.');
player(legacy, owner).hand = player(legacy, owner).hand.filter(card => card.id !== extra.id);
legacy.deck.push(extra);
delete legacy.harkonnenExchangeInspection;
const legacyCards = structuredClone(player(legacy, owner).hand);
const optionalVersion = legacy.version; legacy.version++;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(legacy), legacy.version, code, optionalVersion).changes, 1);
const noDraw = t.mock.method(globalThis.crypto, 'getRandomValues', () => { throw new Error('Legacy recovery must not draw again.'); });
arrivals = 0;
const recoveryBarrier = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await recoveryBarrier; };
await Promise.all([f.rooms.continueRoomAutomatic(code, clock), f.restart().continueRoomAutomatic(code, clock)]);
delete f.hooks.beforeWrite; noDraw.mock.restore();
assert.equal(arrivals, 2);
const restored = await f.restart().readRoom(code);
assert.equal(restored.version, legacy.version + 1);
assert.equal(restored.decision, null); assert.equal(restored.pendingExchange, null);
assert.equal(restored.harkonnenExchangeInspection?.kind, 'return');
assert.deepEqual(restored.harkonnenExchangeInspection?.cards, legacyCards);
assert.equal(restored.discard.filter(card => card.id === karama).length, 1);
for (let i = 0; i < auths.length; i++) {
const view = await f.restart().readSeatView(code, auths[i]);
if (i === 0) { assert.equal(view.harkonnenExchangeInspection?.kind, 'return'); assert.deepEqual(view.harkonnenExchangeInspection?.cards, legacyCards); }
else assert.equal(view.harkonnenExchangeInspection, null);
}
const recovered = f.sqlite.prepare('SELECT state,version FROM rooms').all();
await f.restart().continueRoomAutomatic(code, clock);
assert.deepEqual(f.sqlite.prepare('SELECT state,version FROM rooms').all(), recovered);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
verifySampleCustody(restored, inventory);
} finally { f.sqlite.close(); }
});
+31 -8
View File
@@ -6,7 +6,6 @@ import {
normalizeAutomaticGame,
viewGame,
type Action,
type Decision,
type Game,
} from '../game/engine';
import { richeseCards } from '../game/richese-cards';
@@ -362,15 +361,9 @@ void test('forged or missing saved Ixian lot decisions fail before projection, a
(game) => {
game.ixRicheseTechnologyEvent += '-forged';
},
(game) => {
const duplicate = structuredClone(game.decision) as Decision;
game.pendingExchange = { response: null, decision: duplicate };
},
];
for (const corrupt of corruptions) {
const game = structuredClone(genuine);
corrupt(game);
async function rejectSaved(game: Game) {
f.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, f.code);
@@ -387,6 +380,36 @@ void test('forged or missing saved Ixian lot decisions fail before projection, a
);
assert.deepEqual(snapshot(f, f.code), before);
}
for (const corrupt of corruptions) {
const game = structuredClone(genuine);
corrupt(game);
await rejectSaved(game);
}
// A real paid Box search holds this exact Ix decision. Duplicating the live
// decision now exercises Ix uniqueness without fabricating a Harkonnen parent.
const staged = structuredClone(genuine);
const boxIndex = staged.richeseCache!.findIndex(card => card.effect === 'nullentropyBox');
assert.ok(boxIndex >= 0);
const box = staged.richeseCache!.splice(boxIndex, 1)[0];
player(staged, f.observerAuth.playerId).hand.push(box);
// Two eligible physical cards keep the paid search as a genuine private choice.
staged.discard.push(take(staged, card => card.kind === 'worthless'),
take(staged, card => card.kind === 'worthless'));
assert.deepEqual(physicalCards(staged), physicalCards(genuine));
f.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(staged), staged.version, f.code);
await f.act(f.observerAuth, { type: 'card', card: box.id });
const suspended = await f.restart().readRoom(f.code);
assert.equal(suspended.decision?.kind, 'nullentropy');
assert.equal(player(suspended, f.observerAuth.playerId).spice,
player(staged, f.observerAuth.playerId).spice - 2);
assert.deepEqual(suspended.pendingNullentropy!.resume.decision, genuine.decision);
assert.deepEqual(physicalCards(suspended), physicalCards(genuine));
assert.doesNotThrow(() => viewGame(suspended, f.ixAuth.playerId));
const duplicated = structuredClone(suspended);
duplicated.decision = structuredClone(genuine.decision);
await rejectSaved(duplicated);
});
void test('concurrent authenticated declines persist one Richese lot continuation behind the room CAS', async (t) => {
+2
View File
@@ -240,6 +240,8 @@ for (const form of ['printed', 'bg'] as const)
});
void test('a gift restores a real Harkonnen hand exchange with its inherited BG declaration and saved response', () => {
const f = parent(fixture(true, 3));
// Keep a real choice after spending the Karama; a sole held card returns automatically.
player(f.g, 'h').hand.push(...f.g.deck.splice(0, 1));
const hark = player(f.g, 'h').hand.find((c) => c.effect === 'karama')!;
let g = applyAction(f.g, 'h', {
type: 'card',
@@ -675,6 +675,8 @@ void test('a real Harkonnen hand-exchange return decision survives Truthtrance w
initial.advanced = true;
initial.phase = 3;
const karama = hold(initial, 'p', 'Karama');
// Retain a real return choice even after the intervening Truthtrance is spent.
hold(initial, 'p', 'Shield');
player(initial, 'q').hand.push(...initial.deck.splice(0, 2));
initial = applyAction(initial, 'p', {
type: 'card',
+9 -1
View File
@@ -128,7 +128,15 @@ void test('physical force facts include Bene Gesserit advisors without exposing
void test('a force fact combined with a private hand fact projects only its aggregate answer to the respondent', () => {
const initial = typedGame();
const target = player(initial, 'f');
const privateName = target.hand[0].name;
// Setup may move the target's only opening Truthtrance to the asker.
// Stage a definite held card without changing the physical inventory.
const inventory = knowledgePhysical(initial);
initial.deck.push(...target.hand);
const privateCard = initial.deck.shift();
assert.ok(privateCard);
target.hand = [privateCard];
assert.deepEqual(knowledgePhysical(initial), inventory);
const privateName = privateCard.name;
const combined: TruthFact = { kind: 'or', terms: [
fact({ kind: 'reserves' }, 'total', 11), { kind: 'hand', name: privateName },
] };
+4
View File
@@ -173,6 +173,10 @@ void test('any-time question overlays and restores pending windows without reapp
};
}
if (mode === 'decision') {
initial.advanced = true;
initial.phase = 3;
initial.players.find(p => p.id === 'h')!.specialKaramaUsed = true;
hold(initial, 'h', 'Shield'); // Preserve a real return choice during the overlay.
initial.decision = {
kind: 'handExchange',
player: 'h',