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:
1 parent
a3bc0ac837
commit
0f79f28ccb
21 files changed
+847
-52
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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), '');
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
] };
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user