import test from 'node:test'; import assert from 'node:assert/strict'; import { webcrypto } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { runInNewContext } from 'node:vm'; import ts from 'typescript'; import { baseDeck } from '../game/cards'; import { createRicheseAuction, submitRicheseBid, } from '../game/richese-auction'; import { applyAction, createGame, newPlayer, viewGame, type Game, } from '../game/engine'; import { quoteAuctionContinuation, auctionEmperorIncomeAmount, AuctionContinuationError, type AuctionContinuationInput, type AuctionContinuationOperation, } from '../game/auction-continuation-quote'; function fixture(): AuctionContinuationInput { const cards = baseDeck().slice(0, 4); return { status: 'playing', phase: 3, turn: 2, advanced: true, physicalCards: cards, order: ['h', 'e', 'b', 'g'], players: [ { id: 'h', faction: 'harkonnen', spice: 16, hand: [cards[0]], handLimit: 8, }, { id: 'e', faction: 'emperor', spice: 20, hand: [], handLimit: 4 }, { id: 'b', faction: 'beneGesserit', spice: 20, hand: [], handLimit: 4 }, { id: 'g', faction: 'guild', spice: 20, hand: [], handLimit: 4 }, ], auction: { cards, index: 0, bid: 4, bidder: 'h', active: 'h', passed: ['e', 'b', 'g'], opener: 0, }, sale: { winner: 'h', amount: 4, free: false, origin: 'normal', seller: null, }, }; } const cancel = ( kind: 'emperorIncome' | 'harkonnenBonus' | 'ixAllyCard', owner: string, recipient?: string, ): AuctionContinuationOperation => ({ kind: 'cancel', response: { kind, owner, passed: [], ...(recipient ? { recipient } : {}) }, }); void test('the paid normal sale stops at Emperor then Harkonnen responses without replaying buyer payment', () => { const g = fixture(), before = structuredClone(g); assert.deepEqual( quoteAuctionContinuation(g, { kind: 'sale', free: false }).next, { kind: 'response', response: { kind: 'emperorIncome', owner: 'e', passed: [] }, }, ); assert.deepEqual(quoteAuctionContinuation(g, { kind: 'bonus' }).next, { kind: 'response', response: { kind: 'harkonnenBonus', owner: 'h', passed: [] }, }); assert.deepEqual( quoteAuctionContinuation(g, cancel('emperorIncome', 'e')).steps, [], ); assert.deepEqual(g, before); }); void test('a free purchase bypasses Emperor income but still offers Harkonnen bonus, including the explicit legacy sale fallback', () => { const g = fixture(); g.sale!.free = true; assert.equal( ( quoteAuctionContinuation(g, { kind: 'sale', free: true }).next as { response: { kind: string }; } ).response.kind, 'harkonnenBonus', ); assert.throws( () => quoteAuctionContinuation(g, cancel('emperorIncome', 'e')), /not payable/, ); delete g.sale; const q = quoteAuctionContinuation(g, { kind: 'sale', free: true }); assert.equal(q.sale.legacy, true); assert.equal(q.sale.free, true); assert.equal(q.next.kind, 'response'); assert.equal( quoteAuctionContinuation(g, cancel('harkonnenBonus', 'h')).next.kind, 'normalLot', ); }); void test('the next normal lot resets exactly once and rotates the opener past full hands', () => { const g = fixture(); g.players[1].hand = baseDeck().slice(10, 14); const before = structuredClone(g), q = quoteAuctionContinuation(g, { kind: 'next' }); assert.ok(q.next.kind === 'normalLot'); assert.equal(q.next.active, 'b'); assert.equal(q.next.auction.opener, 2); assert.equal(q.next.auction.index, 1); assert.equal(q.next.auction.bid, 0); assert.equal(q.next.auction.allyPayment, 0); assert.equal(q.next.auction.bidder, null); assert.deepEqual(q.next.auction.passed, []); assert.equal(q.next.auction.peekKnown, false); q.next.auction.cards.reverse(); assert.deepEqual(g, before); }); void test('a final lot or all-full table returns only the unauctioned suffix at an explicit phase boundary', () => { const g = fixture(); for (const p of g.players) p.hand = Array.from({ length: p.handLimit }, (_, i) => ({ id: `${p.id}-${i}`, })); const q = quoteAuctionContinuation(g, { kind: 'next' }); assert.ok(q.next.kind === 'normalEnd'); assert.deepEqual(q.next.returned, g.auction!.cards.slice(1)); assert.equal(q.next.after, 'phase'); g.auction!.index = 3; g.sale!.amount = g.auction!.bid = 4; const last = quoteAuctionContinuation(g, { kind: 'next' }); assert.ok(last.next.kind === 'normalEnd'); assert.deepEqual(last.next.returned, []); }); void test('spending a last-slot card is projected before bonus and next-lot eligibility, without altering source hands', () => { const g = fixture(); for (const p of g.players) p.hand = Array.from({ length: p.handLimit }, (_, i) => ({ id: `${p.id}-${i}`, })); const before = structuredClone(g); assert.equal( quoteAuctionContinuation(g, cancel('emperorIncome', 'e')).next.kind, 'normalEnd', ); const bonus = quoteAuctionContinuation(g, cancel('emperorIncome', 'e'), { player: 'h', card: 'h-0', }); assert.deepEqual(bonus.next, { kind: 'response', response: { kind: 'harkonnenBonus', owner: 'h', passed: [] }, }); const lot = quoteAuctionContinuation(g, cancel('harkonnenBonus', 'h'), { player: 'g', card: 'g-0', }); assert.ok(lot.next.kind === 'normalLot'); assert.equal(lot.next.active, 'g'); assert.deepEqual(g, before); assert.throws( () => quoteAuctionContinuation( g, { kind: 'next' }, { player: 'g', card: 'missing' }, ), /custody/, ); }); void test('post-cost Ix hand eligibility chooses Technology or the next Atreides peek without inspecting card identities', () => { const g = fixture(); g.players = [ ...g.players, { id: 'i', faction: 'ixians', spice: 20, hand: [{ id: 'last-ix-card' }], handLimit: 4, }, { id: 'a', faction: 'atreides', spice: 20, hand: [], handLimit: 4 }, ]; g.order = [...g.order, 'i', 'a']; const before = quoteAuctionContinuation(g, { kind: 'next' }); assert.ok(before.next.kind === 'normalLot'); assert.deepEqual(before.next.offer, { kind: 'ixTechnology', player: 'i' }); const after = quoteAuctionContinuation(g, cancel('harkonnenBonus', 'h'), { player: 'i', card: 'last-ix-card', }); assert.ok(after.next.kind === 'normalLot'); assert.deepEqual(after.next.offer, { kind: 'atreidesAuction', owner: 'a', passed: [], }); g.ixTechnologyTurn = 2; const used = quoteAuctionContinuation(g, { kind: 'next' }); assert.ok(used.next.kind === 'normalLot'); assert.equal(used.next.offer?.kind, 'atreidesAuction'); }); void test('historical sold prefix aliases are valid, while duplicate unsold physical cards reject', () => { const g = fixture(); g.auction!.index = 1; g.auction!.cards[0] = g.auction!.cards[1]; assert.equal( quoteAuctionContinuation(g, { kind: 'next' }).next.kind, 'normalLot', ); g.auction!.cards[2] = g.auction!.cards[1]; assert.throws( () => quoteAuctionContinuation(g, { kind: 'next' }), /physical/, ); }); void test('bad source index, opener, bidder, receipt and declared family reject atomically without requiring funds or future draw supply', () => { const mutations: ((g: AuctionContinuationInput) => void)[] = [ (g) => { g.phase = 5; }, (g) => { g.order = []; }, (g) => { g.order = ['h', 'h']; }, (g) => { g.auction!.index = 99; }, (g) => { g.auction!.opener = -1; }, (g) => { g.auction!.active = 'unknown'; }, (g) => { g.auction!.bid = -1; }, (g) => { g.sale!.amount = 3; }, (g) => { g.sale!.winner = 'e'; }, (g) => { g.sale!.seller = 'e'; }, (g) => { g.auction!.passed = ['e', 'e']; }, ]; for (const mutate of mutations) { const g = fixture(); mutate(g); const before = structuredClone(g); assert.throws( () => quoteAuctionContinuation(g, { kind: 'next' }), AuctionContinuationError, ); assert.deepEqual(g, before); } const g = fixture(); g.players[0].spice = 0; assert.equal( quoteAuctionContinuation(g, cancel('emperorIncome', 'e')).next.kind, 'response', ); assert.throws( () => quoteAuctionContinuation(g, cancel('emperorIncome', 'g')), /not payable/, ); assert.throws( () => quoteAuctionContinuation(g, cancel('harkonnenBonus', 'e')), /wrong winner/, ); }); function richese( source: 'cache' | 'blackMarket' = 'blackMarket', winner = 'h', ) { const g = fixture(); g.players = [ ...g.players, { id: 'r', faction: 'richese', spice: 20, hand: [], handLimit: 4 }, ]; g.order = [...g.order, 'r']; let lot = createRicheseAuction({ event: 'lot-2', cardId: 'richese-distrans', source, owner: 'r', method: 'silent', eligible: g.order, order: g.order, tieOrder: g.order, }); for (const actor of g.order) lot = submitRicheseBid( lot, { event: lot.event, actor, amount: actor === winner ? 4 : 0 }, 20, ); g.auction = null; g.richeseAuction = lot; g.richeseBidding = { owner: 'r', event: 'declaration-2', turn: 2, stage: 'lot', position: source === 'cache' ? 'first' : null, normalCount: source === 'cache' ? 3 : null, blackMarketSold: false, cacheCanceled: false, }; g.richeseCacheCount = 8; g.sale = { winner, amount: 4, free: false, origin: source, seller: 'r' }; return g; } void test('sold Richese lots credit their seller exactly once before bonus, retaining separate declaration/pool/phase boundaries', () => { for (const source of ['blackMarket', 'cache'] as const) { const g = richese(source), q = quoteAuctionContinuation(g, { kind: 'sale', free: false }); assert.deepEqual(q.steps, [ { kind: 'sellerCredit', player: 'r', amount: 4, balance: 24 }, ]); assert.equal(q.next.kind, 'response'); assert.deepEqual(quoteAuctionContinuation(g, { kind: 'bonus' }).steps, []); const end = quoteAuctionContinuation(g, { kind: 'next' }); assert.ok(end.next.kind === 'richeseEnd'); assert.equal( end.next.after, source === 'cache' ? 'normalPool' : 'declaration', ); } const g = richese('cache', 'r'); assert.deepEqual( quoteAuctionContinuation(g, { kind: 'sale', free: false }).next, { kind: 'response', response: { kind: 'emperorIncome', owner: 'e', passed: [] }, }, ); g.richeseBidding!.position = 'last'; assert.equal( (quoteAuctionContinuation(g, { kind: 'next' }).next as { after: string }) .after, 'phase', ); }); void test('paid Richese donor receipts route only their bound share, including Emperor buyer and full donor funding', () => { for (const contribution of [1, 4]) { const g = richese('cache', 'e'); g.sale!.richeseContribution = contribution; // Payment is already committed: no live alliance/funds reconstruction. g.players.find(p => p.id === 'r')!.spice = 0; const q = quoteAuctionContinuation(g, { kind: 'sale', free: false }); assert.deepEqual(q.steps, [ { kind: 'sellerCredit', player: 'r', amount: 4 - contribution, balance: 4 - contribution }, ]); assert.equal(auctionEmperorIncomeAmount(q.sale), contribution); assert.ok(q.next.kind === 'response' && q.next.response.kind === 'emperorIncome'); const canceled = quoteAuctionContinuation(g, cancel('emperorIncome', 'e')); assert.deepEqual(canceled.steps, []); assert.ok(canceled.next.kind === 'richeseEnd' && canceled.next.after === 'normalPool'); g.sale!.recipient = 'bank'; assert.equal(auctionEmperorIncomeAmount(g.sale!), 0); assert.deepEqual(quoteAuctionContinuation(g, { kind: 'sale', free: false }).steps, []); assert.throws(() => quoteAuctionContinuation(g, cancel('emperorIncome', 'e')), /not payable/); } }); void test('Richese donor scalars reject impossible source, rule band and amount without changing the receipt', () => { const invalid: ((g: AuctionContinuationInput) => void)[] = [ g => { g.sale!.richeseContribution = 0; }, g => { g.sale!.richeseContribution = -1; }, g => { g.sale!.richeseContribution = 1.5; }, g => { g.sale!.richeseContribution = 5; }, g => { g.advanced = false; }, ]; for (const mutate of invalid) { const g = richese('cache'); g.sale!.richeseContribution = 2; mutate(g); const before = structuredClone(g); assert.throws(() => quoteAuctionContinuation(g, { kind: 'sale', free: false }), /contribution/); assert.deepEqual(g, before); } for (const g of [fixture(), richese('cache', 'r')]) { g.sale!.richeseContribution = 2; const before = structuredClone(g); assert.throws(() => quoteAuctionContinuation(g, { kind: 'next' }), /contribution/); assert.deepEqual(g, before); } }); void test('canceling a partial Richese-paid Emperor invoice advances to native Harkonnen bonus without replaying seller income', () => { const g = richese('blackMarket'); g.sale!.richeseContribution = 2; const paid = quoteAuctionContinuation(g, { kind: 'sale', free: false }); assert.deepEqual(paid.steps, [ { kind: 'sellerCredit', player: 'r', amount: 2, balance: 22 }, ]); assert.ok(paid.next.kind === 'response' && paid.next.response.kind === 'emperorIncome'); const before = structuredClone(g); const canceled = quoteAuctionContinuation(g, cancel('emperorIncome', 'e')); assert.deepEqual(canceled.steps, []); assert.ok(canceled.next.kind === 'response' && canceled.next.response.kind === 'harkonnenBonus'); assert.deepEqual(g, before); }); void test('Black Market cache exhaustion is checked only when reaching that declaration boundary; seller balance overflow rejects before credit', () => { const g = richese(); g.richeseCacheCount = 0; assert.equal( quoteAuctionContinuation(g, { kind: 'sale', free: false }).next.kind, 'response', ); assert.throws( () => quoteAuctionContinuation(g, { kind: 'next' }), /exhausted/, ); g.richeseCacheCount = 8; g.players.find((p) => p.id === 'r')!.spice = Number.MAX_SAFE_INTEGER; assert.throws( () => quoteAuctionContinuation(g, { kind: 'sale', free: false }), /overflow/, ); }); void test('Ix denial binds acquired-card reference and free paid receipt without demanding current card custody or repeating its purchase', () => { const g = fixture(); g.players = [ ...g.players, { id: 'i', faction: 'ixians', spice: 0, hand: [], handLimit: 4 }, ]; g.order = [...g.order, 'i']; g.pendingIxAlly = { player: 'h', card: g.auction!.cards[0].id, free: false }; g.players[0].hand = []; g.players[0].spice = 0; const q = quoteAuctionContinuation(g, cancel('ixAllyCard', 'i', 'h')); assert.deepEqual(q.steps, [{ kind: 'clearIxAlly' }]); assert.equal(q.next.kind, 'response'); g.pendingIxAlly.free = true; assert.throws( () => quoteAuctionContinuation(g, cancel('ixAllyCard', 'i', 'h')), /completed purchase/, ); g.pendingIxAlly.free = false; delete g.sale; assert.throws( () => quoteAuctionContinuation(g, cancel('ixAllyCard', 'i', 'h')), /completed purchase/, ); }); function live(harkCount = 1, keepBgCounter = true) { let g = createGame( 'SALEQUOTE', newPlayer('h', 'Harkonnen', 'harkonnen'), true, ); g.players.push( newPlayer('e', 'Emperor', 'emperor'), newPlayer('b', 'BG', 'beneGesserit'), newPlayer('g', 'Guild', 'guild'), ); Object.assign(g, { status: 'playing', turn: 2, phase: 2, ready: [], order: ['h', 'e', 'b', 'g'], deck: baseDeck(), discard: [], phaseOpening: null, response: null, decision: null, }); for (const p of g.players) { p.hand = []; p.spice = 20; p.forces = {}; p.reserves = 20; p.traitors = []; } const hold = (id: string, match: (c: Game['deck'][number]) => boolean) => { const i = g.deck.findIndex(match); assert.ok(i >= 0); const card = g.deck.splice(i, 1)[0]; g.players.find((p) => p.id === id)!.hand.push(card); return card; }; const own = hold('h', (c) => c.effect === 'karama'), printed = hold('g', (c) => c.effect === 'karama'), bg = hold('b', (c) => c.kind === 'worthless'); if (!keepBgCounter) { g.players[2].hand = []; g.deck.push(bg); } while (g.players[0].hand.length < harkCount) hold('h', () => true); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); assert.equal(g.phase, 3); assert.ok(g.auction); g = applyAction(g, 'h', { type: 'bid', amount: 4 }); for (let i = 0; !g.decision && !g.response && i < 20; i++) g = applyAction(g, g.auction!.active, { type: 'passBid' }); assert.equal(g.decision?.kind, 'auctionPayment'); return { g, own, printed, bg }; } function paid(harkCount = 1, free = false, keepBgCounter = true) { const f = live(harkCount, keepBgCounter); return { ...f, g: applyAction(f.g, 'h', { type: 'decision', karama: free, ...(free ? { card: f.own.id } : {}), }), }; } function passOne(state: Game) { let g = state; const kind = g.response!.kind, use = JSON.stringify(g.pendingKarama?.use); for ( let i = 0; g.response?.kind === kind && JSON.stringify(g.pendingKarama?.use) === use && i < 20; i++ ) { const p = g.players.find((p) => !g.response!.passed.includes(p.id)); assert.ok(p); g = applyAction(g, p.id, { type: 'passResponse' }); } return g; } function normalized(g: Game): AuctionContinuationInput { return { status: g.status, phase: g.phase, turn: g.turn, advanced: g.advanced, physicalCards: [ ...g.players.flatMap((p) => p.hand), ...g.deck, ...g.discard, ...(g.auction?.cards.slice(g.auction.index + 1) ?? []), ], order: g.order, players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, handLimit: p.faction === 'harkonnen' ? 8 : 4, })), auction: g.auction, sale: g.currentAuctionSale, pendingIxAlly: g.pendingIxAlly, richeseAuction: g.richeseAuction, richeseBidding: g.richeseBidding, richeseCacheCount: g.richeseCache?.length, ixTechnologyTurn: g.ixTechnologyTurn, }; } const observed = {} as { currentAuctionContinuationQuote: ( g: Game, o: AuctionContinuationOperation, s?: { player: string; card: string }, ) => unknown; }; const engineURL = new URL('../game/engine.ts', import.meta.url); runInNewContext( ts.transpileModule( readFileSync(engineURL, 'utf8') + '\nexport {currentAuctionContinuationQuote};', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, }, }, ).outputText, { exports: observed, require: createRequire(engineURL), crypto: webcrypto, structuredClone, TextEncoder, JSON, }, ); for (const form of ['printed', 'bg'] as const) void test(`a real ${form} income cancellation preserves the paid card and opens then denies the bonus before the next lot`, () => { const f = paid(), before = structuredClone(f.g), actor = form === 'printed' ? 'g' : 'b', card = f[form]; assert.equal(f.g.response?.kind, 'emperorIncome'); assert.equal(f.g.players[0].spice, 16); let g = applyAction(f.g, actor, { type: 'card', card: card.id, mode: 'cancel', }); if (form === 'bg') g = passOne(JSON.parse(JSON.stringify(g)) as Game); assert.deepEqual(f.g, before); assert.equal(g.response?.kind, 'harkonnenBonus'); assert.equal(g.players[1].spice, 20); const expected = quoteAuctionContinuation( normalized(g), cancel('harkonnenBonus', 'h'), form === 'printed' ? { player: 'b', card: f.bg.id } : { player: 'g', card: f.printed.id }, ); if (form === 'printed') { g = applyAction(g, 'b', { type: 'card', card: f.bg.id, mode: 'cancel' }); g = passOne(g); } else g = applyAction(g, 'g', { type: 'card', card: f.printed.id, mode: 'cancel', }); assert.equal(g.auction!.index, 1); assert.equal(g.currentAuctionSale, null); assert.equal(g.players[0].spice, 16); assert.equal(g.players[0].hand.length, 2); assert.ok(expected.next.kind === 'normalLot'); assert.equal(g.auction!.active, expected.next.active); assert.equal(g.discard.filter((c) => c.id === card.id).length, 1); assert.equal(viewGame(g, 'e').players[0].hand, undefined); }); void test('a real full Harkonnen hand may spend its own last-slot Karama to cancel Emperor income and create bonus eligibility', () => { const f = paid(7); assert.equal(f.g.players[0].hand.length, 8); assert.equal(f.g.response?.kind, 'emperorIncome'); const preview = quoteAuctionContinuation( normalized(f.g), cancel('emperorIncome', 'e'), { player: 'h', card: f.own.id }, ); assert.equal(preview.next.kind, 'response'); const g = applyAction(f.g, 'h', { type: 'card', card: f.own.id, mode: 'cancel', }); assert.equal(g.response?.kind, 'harkonnenBonus'); assert.equal(g.players[0].hand.length, 7); assert.equal(g.players[1].spice, 20); }); void test('actual allowed income and bonus credit/draw once and use the shared next-lot reset', () => { const f = paid(); let g = passOne(f.g); assert.equal(g.players[1].spice, 24); assert.equal(g.response?.kind, 'harkonnenBonus'); const before = g.players[0].hand.length; g = passOne(g); assert.equal(g.players[0].hand.length, before + 1); assert.equal(g.players[0].spice, 16); assert.equal(g.players[1].spice, 24); assert.equal(g.auction!.index, 1); assert.equal(g.auction!.bid, 0); const free = paid(1, true); assert.equal(free.g.players[0].spice, 20); assert.equal(free.g.response?.kind, 'harkonnenBonus'); assert.equal(free.g.players[1].spice, 20); }); void test('genuine paid declarations reject malformed next opener and current receipt before printed or BG cost without RNG', (t) => { const f = paid(); const random = t.mock.method(crypto, 'getRandomValues', () => { throw Error('No random draw'); }), uuid = t.mock.method(crypto, 'randomUUID', () => { throw Error('No event'); }); for (const form of ['printed', 'bg'] as const) for (const mutate of [ (g: Game) => { g.auction!.opener = -1; }, (g: Game) => { g.currentAuctionSale!.winner = 'e'; }, (g: Game) => { g.currentAuctionSale!.amount = 99; }, ]) { const bad = structuredClone(f.g); mutate(bad); const before = structuredClone(bad), actor = form === 'printed' ? 'g' : 'b', card = f[form]; assert.throws(() => observed.currentAuctionContinuationQuote( bad, cancel('emperorIncome', 'e'), { player: actor, card: card.id }, ), ); assert.throws(() => applyAction(bad, actor, { type: 'card', card: card.id, mode: 'cancel', }), ); assert.deepEqual(bad, before); } assert.equal(random.mock.callCount(), 0); assert.equal(uuid.mock.callCount(), 0); }); void test('spending the last opposing Karama permits the actual automatic Harkonnen bonus draw and advances exactly one lot', () => { const f = paid(1, false, false); assert.equal(f.g.response?.kind, 'emperorIncome'); assert.equal(f.g.players[2].hand.length, 0); const ownedBefore = f.g.players[0].hand.length, deckBefore = f.g.deck.length; const g = applyAction(f.g, 'g', { type: 'card', card: f.printed.id, mode: 'cancel', }); assert.equal(g.response, null); assert.equal(g.auction!.index, 1); assert.equal(g.players[0].hand.length, ownedBefore + 1); assert.equal(g.deck.length, deckBefore - 1); assert.equal(g.players[0].spice, 16); assert.equal(g.players[1].spice, 20); assert.equal(g.discard.filter((c) => c.id === f.printed.id).length, 1); assert.equal( g.log.filter((e) => e.text.includes('drew one bonus treachery card')) .length, 1, ); });