import test from 'node:test'; import assert from 'node:assert/strict'; import { createGame, newPlayer, joinGame, applyAction, viewGame, normalizeAutomaticGame, type Game, } from '../game/engine'; import { baseDeck, leaders } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; function fixture(advanced = false) { let g = createGame('RICHTEST', newPlayer('r', 'Richese', 'guild')); joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen')); joinGame(g, newPlayer('e', 'Emperor', 'emperor')); g.players.forEach((p) => (p.ready = true)); g = applyAction(g, 'r', { type: 'start' }); for (const p of g.players) if (p.traitorChoices.length) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0], }); g.players[0].faction = 'richese'; g.players[0].leaders = leaders('richese'); g.players.forEach((p) => { p.hand = []; p.spice = 20; }); g.deck = baseDeck(); g.discard = []; g.richeseCache = richeseCards(); g.richeseRemoved = []; g.phase = 2; g.turn = 2; g.ready = []; g.order = ['h', 'e', 'r']; g.response = null; g.decision = null; g.phaseOpening = null; g.advanced = advanced; return g; } function begin(g: Game) { for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); return g; } function decision(g: Game, extra: Record) { return applyAction(g, g.decision!.player, { type: 'decision', event: g.richeseBidding!.event, ...extra, }); } function bid(g: Game, id: string, amount: number | null, allyPayment = 0) { return applyAction(g, id, { type: 'richeseBid', event: g.richeseAuction!.event, amount, allyPayment, }); } function cache( g = fixture(), position: 'first' | 'last' = 'first', method: 'onceAround' | 'silent' = 'silent', ) { g = begin(g); g = decision(g, { position }); if (position === 'last') return g; return decision(g, { card: g.richeseCache![0].id, method, direction: 'counterclockwise', }); } function physical(g: Game) { return [ ...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), ...g.players.flatMap((p) => p.hand), ...(g.auction?.cards.slice(g.auction.index) ?? []), ...(g.ixAuction?.cards ?? []), ].map((c) => c.id); } void test('cache-first declaration precedes pool preparation and seals use storm ties with exact single custody', () => { let g = cache(); assert.equal(g.auction, null); assert.equal(g.richeseBidding?.normalCount, 2); const card = g.richeseAuction!.cardId; g = bid(g, 'e', 4); const observer = viewGame(g, 'h').richeseAuction!; assert.equal(observer.ownBid, null); assert.equal(observer.revealedBids, null); assert.equal(observer.bid, 0); g = bid(g, 'h', 4); g = bid(g, 'r', 0); assert.equal(g.players[1].spice, 16); assert.equal(g.players[0].spice, 24); assert.equal(g.players[2].spice, 20); assert.ok(g.players[1].hand.some((c) => c.id === card)); assert.equal(g.players[1].hand.length, 2, 'Harkonnen bonus automatic'); assert.equal( g.auction?.cards.length, 2, 'declared count frozen before bonus', ); assert.equal(physical(g).filter((id) => id === card).length, 1); assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(g))), g); }); void test('last-position cache remains available after ordinary all-pass and is not selected early', () => { let g = cache(fixture(), 'last'); assert.ok(g.auction); assert.equal(g.richeseAuction, null); assert.equal(viewGame(g, 'e').richeseBidding!.cache, null); const deck = physical(g).sort(); while (g.auction) g = applyAction(g, g.auction.active, { type: 'passBid' }); assert.equal(g.decision?.kind, 'richeseCache'); assert.equal(g.phase, 3); g = decision(g, { card: g.richeseCache![1].id, method: 'onceAround', direction: 'clockwise', }); while (!g.richeseAuction?.outcome) g = bid(g, g.richeseAuction!.active!, null); assert.equal(g.decision?.kind, 'richeseUnbid'); g = decision(g, { keep: false }); assert.equal(g.phase, 4); assert.equal(g.richeseRemoved?.length, 1); assert.deepEqual(physical(g).sort(), deck); }); void test('Richese buying its cache pays the Emperor; unbid keeping is free and not a sale', () => { let g = cache(); g = bid(g, 'h', 1); g = bid(g, 'e', 0); g = bid(g, 'r', 2); assert.equal(g.players[0].spice, 18); assert.equal(g.players[2].spice, 22); assert.equal(g.players[0].hand.length, 1); g = cache(); for (const id of ['h', 'e', 'r']) g = bid(g, id, 0); g = decision(g, { keep: true }); assert.equal(g.players[0].spice, 20); assert.equal(g.players[0].hand.length, 1); assert.equal(g.richeseRemoved!.length, 0); }); void test('all-zero cache is automatically removed when Richese hand is full', () => { const before = fixture(); before.players[0].hand = before.deck.splice(0, 4); let g = cache(before); assert.ok(!g.richeseAuction!.eligible.includes('r')); g = bid(g, 'h', 0); g = bid(g, 'e', 0); assert.equal(g.richeseRemoved!.length, 1); assert.equal(g.players[0].hand.length, 4); assert.notEqual(g.decision?.kind, 'richeseUnbid'); }); void test('Once Around physical direction differs from storm order and ignores unoccupied circles', () => { const before = fixture(); before.playerPositions = { r: 1, h: 3, e: 6 }; const cc = cache(structuredClone(before), 'first', 'onceAround'); assert.deepEqual(cc.richeseAuction!.order, ['h', 'e', 'r']); let cw = begin(before); cw = decision(cw, { position: 'first' }); cw = decision(cw, { card: cw.richeseCache![0].id, method: 'onceAround', direction: 'clockwise', }); assert.deepEqual(cw.richeseAuction!.order, ['e', 'h', 'r']); assert.deepEqual(cw.richeseAuction!.tieOrder, ['h', 'e', 'r']); }); void test('sealed own and allied funds cannot be spent twice or withdrawn, and release for losing bidders', () => { let g = cache(); g.players[1].ally = 'e'; g.players[2].ally = 'h'; g.players[1].spice = 2; g = applyAction(g, 'e', { type: 'pledgeAid', amount: 6 }); g = bid(g, 'h', 7, 5); assert.throws( () => applyAction(g, 'e', { type: 'pledgeAid', amount: 4 }), /Ally funding/, ); assert.throws( () => applyAction(g, 'h', { type: 'bribe', target: 'r', amount: 1 }), /Spice/, ); const frozen = structuredClone(g); assert.throws(() => bid(g, 'h', 1), /already submitted/); assert.deepEqual(g, frozen); g = bid(g, 'e', 8); g = bid(g, 'r', 0); assert.equal(g.players[1].spice, 2); assert.equal(g.aid.e.amount, 6); assert.equal(g.players[2].spice, 6); }); void test('Black Market stays concealed through all methods, pays seller and reduces normal count only on sale', () => { for (const method of ['normal', 'onceAround', 'silent'] as const) { const before = fixture(true); before.players[0].hand = [before.deck.shift()!]; let g = begin(before); const card = g.players[0].hand[0]; assert.equal(g.decision?.kind, 'richeseBlackMarket'); g = decision(g, { card: card.id, method, direction: 'clockwise', claim: 'A weapon, perhaps', }); assert.equal(viewGame(g, 'h').richeseAuction?.cardId, null); assert.equal(viewGame(g, 'h').richeseAuction?.card, null); assert.equal(physical(g).filter((id) => id === card.id).length, 1); if (method === 'silent') { g = bid(g, 'e', 3); g = bid(g, 'h', 0); g = bid(g, 'r', 0); } else { let guard = 0; while (g.richeseAuction && !g.richeseAuction.outcome && guard++ < 10) { const id = g.richeseAuction.active!; g = bid(g, id, id === 'e' && g.richeseAuction.bid === 0 ? 3 : null); } } assert.equal(g.decision?.kind, 'richeseDeclaration'); assert.equal(g.players[0].spice, 23); assert.equal(g.players[2].spice, 17); assert.equal(g.richeseBidding!.blackMarketSold, true); assert.equal(g.richeseBidding!.normalCount, 1); assert.equal(physical(g).filter((id) => id === card.id).length, 1); } }); void test('unsold and canceled Black Market preserve the exact card and do not reduce count', () => { for (const canceled of [false, true]) { const before = fixture(true); before.players[0].hand = [before.deck.shift()!]; if (canceled) { const k = before.deck.find((c) => c.effect === 'karama')!; before.players[2].hand = [k]; before.deck = before.deck.filter((c) => c.id !== k.id); } let g = begin(before); const card = g.players[0].hand[0]; g = decision(g, { card: card.id, method: 'silent' }); if (canceled) g = applyAction(g, 'e', { type: 'card', card: g.players[2].hand[0].id, mode: 'cancel', }); else for (const id of ['h', 'e', 'r']) g = bid(g, id, 0); assert.deepEqual(g.players[0].hand, [card]); assert.equal(g.richeseBidding!.blackMarketSold, false); assert.equal(g.richeseBidding!.normalCount, 2); } }); void test('Atreides inspection grants only its entitled view and survives JSON before a Black Market bid', () => { const before = fixture(true); before.players[1].faction = 'atreides'; before.players[1].leaders = leaders('atreides'); before.players[0].hand = [before.deck.shift()!]; let g = begin(before); const card = g.players[0].hand[0]; g = decision(g, { card: card.id, method: 'silent' }); g = JSON.parse(JSON.stringify(g)); assert.equal(viewGame(g, 'h').richeseAuction!.card!.id, card.id); assert.equal(viewGame(g, 'e').richeseAuction!.card, null); assert.equal(viewGame(g, 'e').richeseAuction!.cardId, null); assert.equal(g.players[0].hand[0].id, card.id); }); void test('connected cache cancellation restores the ordinary pool', () => { const before = fixture(); const k = before.deck.find((c) => c.effect === 'karama')!; before.players[2].hand = [k]; before.deck = before.deck.filter((c) => c.id !== k.id); let g = begin(before); g = decision(g, { position: 'first' }); const snapshot = structuredClone(g); // The 7 October exhausted-cache ruling makes this cancellation legal and // restores one ordinary lot; it is no longer an unresolved branch. const canceled = applyAction(structuredClone(g), 'e', { type: 'card', card: k.id, mode: 'cancel' }); assert.equal(canceled.richeseAuction, null); assert.ok(canceled.auction && canceled.auction.cards.length > 0); assert.equal(canceled.players[2].hand.some((c) => c.id === k.id), false); assert.equal(canceled.discard.filter((c) => c.id === k.id).length, 1); assert.deepEqual(g, snapshot, 'the source declaration is unchanged by the trial cancellation'); }); void test('an outbid ally-funded player whose hand fills is automatically skipped without reserving losing credit', () => { const before = fixture(true); before.players[0].hand = [before.deck.shift()!]; let g = begin(before); g = decision(g, { card: g.players[0].hand[0].id, method: 'normal' }); g.players[1].ally = 'e'; g.players[2].ally = 'h'; g.players[1].spice = 0; g = applyAction(g, 'e', { type: 'pledgeAid', amount: 5 }); g = bid(g, 'h', 3, 3); g = bid(g, 'e', 4); g.players[1].hand = g.deck.splice(0, 8); g = bid(g, 'r', null); assert.equal(g.decision?.kind, 'richeseDeclaration'); assert.equal(g.players[0].spice, 24); assert.equal(g.players[2].spice, 11); assert.equal(g.aid.e.amount, 5); assert.ok( g.log.some((entry) => entry.text.includes( 'automatically passed because their hand became full', ), ), ); }); void test('restored full hands automatically submit zero or remove an unbid cache card once', () => { let g = cache(); g = bid(g, 'h', 2); g = bid(g, 'e', 0); g.players[0].hand = g.deck.splice(0, 4); g = normalizeAutomaticGame(JSON.parse(JSON.stringify(g))); assert.equal(g.players[1].hand.length, 2); assert.equal(g.players[0].spice, 22); assert.equal(g.players[0].hand.length, 4); assert.ok( g.log.some((entry) => entry.text.includes('submitted a sealed bid; its amount remains concealed')), ); let unbid = cache(); for (const id of ['h', 'e', 'r']) unbid = bid(unbid, id, 0); assert.equal(unbid.decision?.kind, 'richeseUnbid'); unbid.players[0].hand = unbid.deck.splice(0, 4); unbid = normalizeAutomaticGame(JSON.parse(JSON.stringify(unbid))); assert.equal(unbid.richeseRemoved?.length, 1); assert.equal(unbid.decision, null); assert.deepEqual(normalizeAutomaticGame(structuredClone(unbid)), unbid); }); void test('a frozen normal pool is not drawn and lost after every hand becomes full', () => { const before = fixture(); before.players[0].hand = before.deck.splice(0, 3); before.players[1].hand = before.deck.splice(0, 7); before.players[2].hand = before.deck.splice(0, 3); let g = cache(before); assert.equal(g.richeseBidding?.normalCount, 2); g = bid(g, 'r', 2); g.players[1].hand.push(g.deck.shift()!); g.players[2].hand.push(g.deck.shift()!); const cards = physical(g).sort(), deck = g.deck.map((c) => c.id); g = normalizeAutomaticGame(g); assert.equal(g.phase, 4); assert.equal(g.auction, null); assert.deepEqual( g.deck.map((c) => c.id), deck, ); assert.deepEqual(physical(g).sort(), cards); });