import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame, type Game, type Action, } from '../game/engine'; import { baseDeck, spiceDeck } from '../game/cards'; import { location, territory } from '../game/board'; import { richeseCards } from '../game/richese-cards'; const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); function fixture() { const g = createGame( 'BGCANCELCTX', newPlayer('b', 'BG', 'beneGesserit'), true, ['choam'], ); g.players.push( newPlayer('r', 'Richese', 'richese'), newPlayer('e', 'Emperor', 'emperor'), newPlayer('h', 'Harkonnen', 'harkonnen'), ); Object.assign(g, { status: 'playing', phase: 4, turn: 2, storm: 18, active: 'b', order: ['b', 'r', 'e', 'h'], deck: baseDeck(), richeseCache: richeseCards(), phaseOpening: null, }); for (const p of g.players) { p.hand = []; p.spice = 20; p.forces = {}; p.reserves = 20; p.traitors = []; } player(g, 'b').ally = 'r'; player(g, 'r').ally = 'b'; player(g, 'e').ally = 'h'; player(g, 'h').ally = 'e'; function hold( id: string, predicate: (c: Game['deck'][number]) => boolean, cache = false, ) { const pile = cache ? g.richeseCache! : g.deck, at = pile.findIndex(predicate); assert.ok(at >= 0); const card = pile.splice(at, 1)[0]; player(g, id).hand.push(card); return card.id; } const worthless = hold('b', (c) => c.kind === 'worthless'), truth = hold('b', (c) => c.effect === 'truthtrance'); hold('b', (c) => c.name === 'Shield'); const printed = hold('e', (c) => c.effect === 'karama'), special = hold('h', (c) => c.effect === 'karama'); hold('h', (c) => c.name === 'Maula Pistol'); const box = hold('r', (c) => c.effect === 'nullentropyBox', true), distrans = hold('r', (c) => c.effect === 'distrans', true), gift = hold('r', (c) => c.effect === 'ornithopter', true), given = hold('r', (c) => c.name === 'Snooper'); const at = g.deck.findIndex((c) => c.name === 'Lasgun'); assert.ok(at >= 0); g.discard.push(...g.deck.splice(at, 1)); return { g, worthless, truth, printed, special, box, distrans, gift, given }; } type Fixture = ReturnType; function action(g: Game, id: string, a: Action) { const before = structuredClone(g), next = applyAction(g, id, a); assert.deepEqual(g, before); return next; } function passKind(initial: Game, kind: NonNullable['kind']) { let g = reload(initial); for (let tries = 0; g.response?.kind === kind; tries++) { assert.ok(tries < 12); const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id; g = action(g, id, { type: 'passResponse' }); } return g; } function beginCancel(g: Game, f: Fixture) { const next = action(g, 'b', { type: 'card', card: f.worthless, mode: 'cancel', }); assert.equal(next.response?.kind, 'worthlessKarama'); assert.equal(next.pendingKarama?.opportunity?.kind, 'cancel'); return next; } function privateViews(g: Game) { const before = structuredClone(g); for (const p of g.players) { const view = viewGame(reload(g), p.id); assert.equal('pendingKarama' in view, false); assert.equal(JSON.stringify(view).includes('"signature"'), false); for (const other of view.players) if (other.id !== p.id) for (const key of ['hand', 'spice', 'traitors']) assert.equal(key in other, false, key); } assert.deepEqual(g, before); } function inventory(g: Game) { const ids = [ ...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand), ...(g.richeseCache ?? []), ...(g.auction?.cards.slice( g.auction.index + (g.currentAuctionSale ? 1 : 0), ) ?? []), ] .map((c) => c.id) .sort(); assert.equal(new Set(ids).size, ids.length); return ids; } function paidBox(g: Game, f: Fixture) { const paid = action(g, 'r', { type: 'card', card: f.box }); assert.ok(paid.pendingNullentropy); assert.equal(player(paid, 'r').spice, 18); privateViews(paid); return paid; } function finishBox(g: Game) { const pending = g.pendingNullentropy!, card = g.discard.find((c) => c.name === 'Lasgun')!; assert.ok(card); return action(reload(g), 'r', { type: 'decision', event: pending.event, card: card.id, }); } function sourceGift(f: Fixture) { return action(f.g, 'e', { type: 'emperorGift', amount: 4 }); } function freeBoxSlot(f: Fixture) { const hand = player(f.g, 'r').hand; const index = hand.findIndex((card) => card.id === f.given); assert.ok(index >= 0); f.g.deck.push(...hand.splice(index, 1)); } function sourceAuction(f: Fixture) { freeBoxSlot(f); let g = f.g; g.phase = 3; g.auction = { cards: g.deck.splice(0, 1), index: 0, bid: 0, bidder: null, active: 'b', passed: [], opener: 0, }; g = action(g, 'b', { type: 'bid', amount: 2 }); for (const id of ['r', 'e', 'h']) g = action(g, id, { type: 'passBid' }); assert.equal(g.decision?.kind, 'auctionPayment'); g = action(g, 'b', { type: 'decision', karama: false }); assert.equal(g.response?.kind, 'emperorIncome'); return g; } void test('an actual gift cancellation preserves hand-changing Distrans, Truthtrance and paid Box interruptions without freezing resources', () => { const f = fixture(), source = sourceGift(f), original = structuredClone(source.response), ids = inventory(source); let g = beginCancel(source, f); const stamped = structuredClone(g.pendingKarama); g = action(g, 'h', { type: 'passResponse' }); g = action(g, 'r', { type: 'card', card: f.distrans, target: 'b', give: f.given, }); assert.deepEqual(g.pendingKarama, stamped); assert.equal(player(g, 'b').hand.length, 3); g = action(g, 'b', { type: 'card', card: f.truth }); for (const id of ['r', 'e', 'h']) g = action(g, id, { type: 'truthPass' }); g = action(g, 'b', { type: 'truthAsk', question: { kind: 'fact', target: 'e', fact: { kind: 'spice', compare: 'eq', value: 20 }, }, }); assert.equal(viewGame(g, 'e').truthAnswer, 'yes'); assert.equal(viewGame(g, 'b').truthAnswer, null); g = action(g, 'e', { type: 'truthAnswer', answer: 'yes' }); assert.deepEqual(g.pendingKarama, stamped); g = finishBox(paidBox(g, f)); assert.deepEqual(g.pendingKarama, stamped); assert.deepEqual(g.pendingKarama!.use, { kind: 'cancel', response: original, }); assert.equal(player(g, 'r').spice, 18); privateViews(g); g = passKind(g, 'worthlessKarama'); assert.equal(g.pendingKarama ?? null, null); assert.equal(g.response, null); assert.equal(player(g, 'e').spice, 20); assert.equal(player(g, 'h').spice, 20); assert.equal(g.truthHistory!.length, 1); assert.deepEqual(inventory(g), ids); assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g)); }); void test('canceling a real Richese gift survives Box suspension and retains the source transfer intent without giving its card', () => { const f = fixture(); freeBoxSlot(f); let g = action(f.g, 'r', { type: 'richeseGift', card: f.gift }); const original = structuredClone(g.pendingRicheseGift), ids = inventory(g); g = beginCancel(g, f); const stamped = structuredClone(g.pendingKarama); const paid = paidBox(g, f); assert.equal(paid.pendingKarama, null); assert.deepEqual(paid.pendingNullentropy!.resume.pendingKarama, stamped); assert.deepEqual(paid.pendingRicheseGift, original); g = finishBox(paid); assert.deepEqual(g.pendingKarama, stamped); privateViews(g); g = passKind(g, 'worthlessKarama'); assert.equal(g.pendingRicheseGift, null); assert.equal(g.pendingKarama ?? null, null); assert.equal( player(g, 'r').hand.some((c) => c.id === f.gift), true, ); assert.equal( player(g, 'b').hand.some((c) => c.id === f.gift), false, ); assert.deepEqual(inventory(g), ids); }); void test('paid auction income cancellation survives a real Harkonnen exchange and Box inside that exchange without replaying purchase or sampling', () => { const f = fixture(); let g = sourceAuction(f); const sale = structuredClone(g.currentAuctionSale), auction = structuredClone(g.auction), ids = inventory(g); g = beginCancel(g, f); // The paid auction income response now stamps optional Bureaucrat fields // whose absent values are JSON-invisible; the Box suspension serializes, so // compare the pending acknowledgement through the same serialization. const stamped = JSON.parse(JSON.stringify(g.pendingKarama)); g = action(g, 'h', { type: 'card', mode: 'special', card: f.special, target: 'b', amount: 1, }); assert.equal(g.decision?.kind, 'handExchange'); assert.equal(g.pendingExchange?.response?.kind, 'worthlessKarama'); assert.deepEqual(JSON.parse(JSON.stringify(g.pendingKarama)), stamped); const sampled = structuredClone(g.players.map((p) => p.hand)); g = finishBox(paidBox(g, f)); assert.deepEqual(g.pendingKarama, stamped); assert.equal(g.decision?.kind, 'handExchange'); assert.deepEqual(player(g, 'b').hand, sampled[0]); assert.deepEqual(player(g, 'h').hand, sampled[3]); assert.deepEqual(g.currentAuctionSale, sale); assert.deepEqual(g.auction, auction); privateViews(g); const returned = player(g, 'h').hand.find((c) => c.name === 'Maula Pistol')!; g = action(g, 'h', { type: 'decision', returnCards: [returned.id] }); assert.deepEqual(g.pendingKarama, stamped); g = passKind(g, 'worthlessKarama'); assert.equal(g.pendingKarama ?? null, null); assert.equal(g.pendingExchange, null); assert.equal(player(g, 'b').spice, 18); assert.equal(player(g, 'e').spice, 20); assert.equal(player(g, 'r').spice, 18); assert.equal( g.log.filter((l) => l.text.includes('won a treachery card')).length, 1, ); assert.equal(g.log.filter((l) => l.text.includes('unseen cards')).length, 1); assert.deepEqual(inventory(g), ids); }); void test('source-bound saved cancellations reject replaced response, source auction, transfer and battle contexts while valid legacy metadata-free use still resumes', () => { for (const source of ['gift', 'auction', 'transfer', 'battle'] as const) { const f = fixture(); let g = f.g; if (source === 'gift') g = sourceGift(f); else if (source === 'auction') g = sourceAuction(f); else if (source === 'transfer') g = action(g, 'r', { type: 'richeseGift', card: f.gift }); else { g.phase = 6; player(g, 'e').ally = null; player(g, 'h').ally = null; g.active = 'h'; g.order = ['h', 'e', 'b', 'r']; for (const id of ['h', 'e']) { player(g, id).forces = { 'red_chasm:7': 3 }; player(g, id).reserves = 17; } player(g, 'e').elites = { forces: { 'red_chasm:7': 1 }, reserves: 4, tanks: 0, revived: 0, }; g = action(g, 'h', { type: 'chooseBattle', territory: 'red_chasm', target: 'e', }); assert.equal(g.response?.kind, 'eliteStrength'); assert.equal(g.response.owner, 'e'); } g = beginCancel(g, f); const pending = g.pendingKarama!; const mutations: ((g: Game) => void)[] = [ (s) => { s.turn++; }, (s) => { s.phase = 7; }, (s) => { s.pendingKarama!.owner = 'r'; }, (s) => { s.response!.owner = 'e'; }, (s) => { s.pendingKarama!.opportunity!.signature = '{}'; }, (s) => { const use = s.pendingKarama!.use; if (use.kind === 'cancel') use.response.owner = 'h'; }, ]; if (source === 'gift') mutations.push( (s) => { const use = s.pendingKarama!.use; if (use.kind === 'cancel') use.response.amount = 5; }, (s) => { const use = s.pendingKarama!.use; if (use.kind === 'cancel') use.response.recipient = 'r'; }, ); if (source === 'auction') mutations.push( (s) => { s.auction!.index++; }, (s) => { s.currentAuctionSale!.winner = 'r'; }, ); if (source === 'transfer') mutations.push( (s) => { s.pendingRicheseGift!.intent.cardId = f.box; }, (s) => { s.pendingRicheseGift!.event = 'another-gift'; }, ); if (source === 'battle') mutations.push( (s) => { s.battle!.event = 'another-battle'; }, (s) => { s.battle!.defender = 'r'; }, ); for (const [index, change] of mutations.entries()) { const bad = reload(g); change(bad); const before = structuredClone(bad); for (const p of bad.players) assert.throws(() => viewGame(bad, p.id), `${source} ${index} view`); assert.throws( () => normalizeAutomaticGame(bad), `${source} ${index} normalize`, ); assert.throws( () => action(bad, 'e', { type: 'passResponse' }), `${source} ${index} action`, ); assert.deepEqual(bad, before); } const legacy = reload(g); delete legacy.pendingKarama!.opportunity; assert.equal(legacy.pendingKarama!.use.kind, 'cancel'); const done = passKind(legacy, 'worthlessKarama'); assert.equal(done.pendingKarama ?? null, null); assert.equal(done.discard.filter((c) => c.id === f.worthless).length, 1); assert.equal(pending.opportunity!.kind, 'cancel'); } }); void test('a real spice worm cancellation survives a separately summoned worm and its added ride before restoring the original source', () => { const f = fixture(); const fremen = newPlayer('f', 'Fremen', 'fremen'); f.g.players.push(fremen); f.g.order.push('f'); f.g.phase = 1; f.g.nexus = false; f.g.ready = []; const lands = spiceDeck().filter((c) => 'territory' in c); const original = lands.find((c) => c.territory === 'the_great_flat')!; const summoned = 'red_chasm'; const originalKey = location(original.territory, original.sector); const summonedKey = location(summoned, territory(summoned).sectors[0]); fremen.forces = { [originalKey]: 2, [summonedKey]: 3 }; fremen.reserves = 15; const hand = player(f.g, 'h').hand; const index = hand.findIndex((card) => card.id === f.special); fremen.hand.push(...hand.splice(index, 1)); const worm = spiceDeck().find((card) => 'worm' in card)!; f.g.spiceDeck = [worm, ...lands.filter((card) => card !== original)]; f.g.spiceDiscard = [[original], []]; let g = f.g; for (const id of g.order) g = action(g, id, { type: 'ready' }); assert.equal(g.response?.kind, 'wormSurvival'); assert.equal(g.response?.location, original.territory); g = beginCancel(g, f); const conversion = reload(g).pendingKarama; const sequence = structuredClone(g.spiceSequence); g = action(g, 'f', { type: 'card', mode: 'special', card: f.special, territory: summoned, }); assert.equal(g.response?.kind, 'wormSurvival'); assert.equal(g.response?.location, summoned); assert.equal(g.pendingKarama ?? null, null); assert.deepEqual(reload(g).summonedWorm!.resume.pendingKarama, conversion); assert.notDeepEqual(g.spiceSequence, sequence); privateViews(g); g = passKind(g, 'wormSurvival'); assert.equal(g.summonedWorm, null); assert.deepEqual(reload(g).pendingKarama, conversion); assert.deepEqual(g.spiceSequence, sequence); assert.ok(g.wormRides.includes(summoned)); privateViews(g); g = passKind(g, 'worthlessKarama'); assert.equal(g.pendingKarama ?? null, null); assert.equal(player(g, 'f').forces[originalKey], undefined); assert.equal(player(g, 'f').forces[summonedKey], 3); assert.equal(player(g, 'f').tanks, 2); assert.equal(g.discard.filter((card) => card.id === f.special).length, 1); assert.equal(g.discard.filter((card) => card.id === f.worthless).length, 1); }); void test('a second BG cancellation may resolve separate Richese purchase income and then restore its suspended original cancellation', () => { const f = fixture(); freeBoxSlot(f); const richeseHand = player(f.g, 'r').hand; const unusedDistrans = richeseHand.findIndex( (card) => card.id === f.distrans, ); f.g.deck.push(...richeseHand.splice(unusedDistrans, 1)); const harkHand = player(f.g, 'h').hand; const special = harkHand.findIndex((card) => card.id === f.special); richeseHand.push(...harkHand.splice(special, 1)); const secondIndex = f.g.deck.findIndex((card) => card.kind === 'worthless'); const second = f.g.deck.splice(secondIndex, 1)[0]; player(f.g, 'b').hand.push(second); let g = beginCancel(sourceGift(f), f); const original = reload(g).pendingKarama; const acquire = g.richeseCache![0].id; const ids = inventory(g); g = action(g, 'r', { type: 'card', mode: 'special', card: f.special, acquire, }); assert.equal(g.response?.kind, 'richesePurchaseIncome'); assert.deepEqual( reload(g).pendingRichesePurchaseIncome!.resume.pendingKarama, original, ); assert.equal(g.pendingKarama ?? null, null); g = action(g, 'b', { type: 'card', mode: 'cancel', card: second.id }); assert.equal(g.pendingKarama?.opportunity?.kind, 'cancel'); assert.notEqual( g.pendingKarama?.opportunity?.signature, original?.opportunity?.signature, ); assert.equal(g.pendingKarama?.use.kind, 'cancel'); privateViews(g); for (let i = 0; g.pendingRichesePurchaseIncome; i++) { assert.ok(i < 12); g = action( reload(g), g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); } assert.deepEqual(reload(g).pendingKarama, original); assert.equal(g.response?.kind, 'worthlessKarama'); assert.equal(player(g, 'e').spice, 20); assert.equal(player(g, 'r').spice, 17); assert.equal( player(g, 'r').hand.filter((card) => card.id === acquire).length, 1, ); privateViews(g); g = passKind(g, 'worthlessKarama'); assert.equal(g.pendingKarama ?? null, null); assert.equal(player(g, 'e').spice, 20); assert.equal(player(g, 'h').spice, 20); assert.deepEqual(inventory(g), ids); for (const id of [f.worthless, second.id, f.special]) assert.equal(g.discard.filter((card) => card.id === id).length, 1); });