import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions, runBots } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; const act = (g: Game, actor: string, action: Action) => applyAction(g, actor, action); const seat = (g: Game, id: string) => { const p = g.players.find(row => row.id === id); assert.ok(p); return p; }; const copy = (g: Game): Game => JSON.parse(JSON.stringify(g)); /** Genuine Advanced H/A/E setup, second-turn worm and reciprocal alliance. * Test wallets and conservation-preserving Karama swaps are staged; bidding, * passing, settlement and all responses are native actions. */ function openAuction(buyerKarama = false, emperorSpice = 10) { const source = createTruthAllianceFixture({advanced:true, consent:'none'}); let g = source.game; g = act(g, 'harkonnen', {type:'alliance', target:'emperor'}); g = act(g, 'emperor', {type:'alliance', target:'harkonnen'}); g = advanceTruthAlliance(g, s => s.phase === 3 && !!s.auction && truthAllianceClean(s)); for (let i = 0; g.auction && g.auction.active !== 'harkonnen' && i < g.players.length; i++) g = act(g, g.auction.active, {type:'passBid'}); assert.equal(g.auction?.active, 'harkonnen'); seat(g, 'harkonnen').spice = 6; seat(g, 'emperor').spice = emperorSpice; // The winner's private physical Karama changes only whether the underlying // auction needs its own payment choice, never whether this gift opens. const buyer = seat(g, 'harkonnen'); const removed = buyer.hand.filter(card => card.effect === 'karama'); buyer.hand = buyer.hand.filter(card => card.effect !== 'karama'); g.deck.push(...removed); if (buyerKarama) { const index = g.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); const [card] = g.deck.splice(index, 1); buyer.hand.push(card); } return g; } function finishBid(g: Game, amount = 4, allyPayment = 0) { const card = g.auction!.cards[g.auction!.index].id; g = act(g, 'harkonnen', {type:'bid', amount, allyPayment}); for (let i = 0; g.auction && !g.response && !g.decision && i < 12; i++) g = act(g, g.auction.active, {type:'passBid'}); assert.equal(g.decision?.kind, 'auctionPayment'); return {g, card}; } function opportunity(g: Game) { const result = viewGame(g, 'emperor').emperorAuctionGift; assert.ok(result); return result; } function continued(g: Game) { const offer = opportunity(g); return act(g, 'emperor', {type:'emperorAuctionGiftContinue', event:offer.event}); } function passResponses(g: Game) { for (let i = 0; g.response && i < 30; i++) { const p = g.players.find(row => !g.response!.passed.includes(row.id)); assert.ok(p); g = act(g, p.id, {type:'passResponse'}); } return g; } function completePayment(g: Game) { if (g.decision?.kind === 'auctionPayment') g = act(g, g.decision.player, {type:'decision', karama:false}); return passResponses(g); } function frozenAuction(g: Game) { const a = g.auction!; return {decision:structuredClone(g.decision), turn:g.turn, phase:g.phase, active:g.active, card:a.cards[a.index].id, index:a.index, bid:a.bid, bidder:a.bidder, passed:structuredClone(a.passed), allyPayment:a.allyPayment, aid:structuredClone(g.aid), clock:structuredClone(g.auctionClock ?? null)}; } for (const buyerKarama of [false, true]) for (const emperorSpice of [0, 10]) { void test(`uniform prepayment gift opportunity: buyer Karama ${buyerKarama}, Emperor spice ${emperorSpice}`, () => { const {g: initial, card} = finishBid(openAuction(buyerKarama, emperorSpice)); const g = copy(initial), offer = opportunity(g), parent = frozenAuction(g); assert.equal(offer.recipient, 'harkonnen'); assert.equal(offer.maximum, emperorSpice); assert.deepEqual(viewGame(g, 'emperor').emperorAuctionGiftPending, {owner:'emperor'}); for (const other of ['harkonnen', 'atreides']) { assert.equal(viewGame(g, other).emperorAuctionGift, null); assert.deepEqual(viewGame(g, other).emperorAuctionGiftPending, {owner:'emperor'}); } const before = copy(g); assert.throws(() => act(g, 'harkonnen', {type:'decision', karama:false})); assert.throws(() => act(g, 'harkonnen', {type:'emperorAuctionGiftContinue', event:offer.event})); assert.throws(() => act(g, 'emperor', {type:'emperorAuctionGiftContinue', event:'stale'})); assert.deepEqual(g, before); let next = continued(g); assert.equal(viewGame(next, 'emperor').emperorAuctionGift, null); assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null); if (buyerKarama) { assert.deepEqual(frozenAuction(next), parent); assert.equal(next.decision?.kind, 'auctionPayment'); next = completePayment(next); } else next = passResponses(next); assert.ok(seat(next, 'harkonnen').hand.some(row => row.id === card), 'original lot delivered once'); assert.throws(() => act(next, 'emperor', {type:'emperorAuctionGiftContinue', event:offer.event})); }); } void test('a prepayment gift preserves the declared mixed payer split and Advanced payment routing', () => { let g = openAuction(true); seat(g, 'harkonnen').spice = 4; g = act(g, 'emperor', {type:'pledgeAid', amount:2}); const bid = finishBid(g, 6, 2); g = bid.g; const parent = frozenAuction(g), event = opportunity(g).event; assert.equal(opportunity(g).maximum, 8); g = act(g, 'emperor', {type:'emperorGift', amount:1}); assert.equal(g.response?.kind, 'emperorGift'); assert.equal(seat(g, 'emperor').spice, 8); g = passResponses(copy(g)); assert.deepEqual(frozenAuction(g), parent); assert.equal(seat(g, 'harkonnen').spice, 5); assert.equal(seat(g, 'emperor').spice, 7); assert.equal(opportunity(g).event, event); g = continued(g); assert.equal(g.decision?.kind, 'auctionPayment'); g = completePayment(g); assert.ok(seat(g, 'harkonnen').hand.some(card => card.id === bid.card)); assert.equal(seat(g, 'harkonnen').spice, 1); assert.equal(seat(g, 'emperor').spice, 11, 'Own-price four returns to Emperor; its separately pledged two went to Bank.'); }); void test('a real Karama cancels the gift without disturbing the auction or its payment', () => { let g = openAuction(true); const third = seat(g, 'atreides'); let card = third.hand.find(row => row.effect === 'karama'); if (!card) { const index = g.deck.findIndex(row => row.effect === 'karama'); assert.ok(index >= 0); [card] = g.deck.splice(index, 1); third.hand.push(card); } assert.ok(card); const bid = finishBid(g); g = bid.g; const parent = frozenAuction(g), emperor = seat(g, 'emperor').spice, buyer = seat(g, 'harkonnen').spice; g = act(g, 'emperor', {type:'emperorGift', amount:2}); assert.equal(g.response?.kind, 'emperorGift'); g = act(g, 'atreides', {type:'card', card:card.id, mode:'cancel'}); assert.deepEqual(frozenAuction(g), parent); assert.equal(seat(g, 'emperor').spice, emperor); assert.equal(seat(g, 'harkonnen').spice, buyer); assert.equal(g.discard.filter(row => row.id === card!.id).length, 1); g = continued(g); g = completePayment(g); assert.ok(seat(g, 'harkonnen').hand.some(row => row.id === bid.card)); }); void test('four minimal Emperor policies can continue the neutral prepayment opportunity', () => { const {g} = finishBid(openAuction(false, 0)); for (const difficulty of DIFFICULTIES) { const view = viewGame(g, 'emperor'); view.players.find(p => p.id === 'emperor')!.bot = difficulty; const actions = botActions(view); assert.deepEqual(actions, [{type:'emperorAuctionGiftContinue', event:opportunity(g).event}]); const next = act(copy(g), 'emperor', actions[0]); assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null); } }); void test('Advanced Bene Gesserit may use a physical Worthless card to cancel the gift', () => { let g = createGame('EMPAUCTIONBG', newPlayer('emperor', 'Emperor', 'emperor'), true); joinGame(g, newPlayer('harkonnen', 'Harkonnen', 'harkonnen')); joinGame(g, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit')); for (const p of g.players) g = act(g, p.id, {type:'ready'}); g = act(g, 'emperor', {type:'start', advancedPreview:true}); for (let i = 0; i < 80 && g.status === 'setup'; i++) { const step = nextStrongholdFactionsNativeStep(g); assert.ok(step); g = act(g, step.actor, step.action); } assert.equal(g.status, 'playing'); // Stage only the current alliance, test wallets and unopened ordinary lot; // the Worthless card is moved from the actual deck, never manufactured. seat(g, 'emperor').ally = 'harkonnen'; seat(g, 'harkonnen').ally = 'emperor'; seat(g, 'emperor').spice = 10; seat(g, 'harkonnen').spice = 6; const bg = seat(g, 'beneGesserit'); let worthless = bg.hand.find(card => card.kind === 'worthless'); if (!worthless) { const index = g.deck.findIndex(card => card.kind === 'worthless'); assert.ok(index >= 0); [worthless] = g.deck.splice(index, 1); if (bg.hand.length >= 4) g.deck.push(bg.hand.pop()!); bg.hand.push(worthless); } assert.ok(worthless); // A genuine opposing Karama keeps the conversion response visible long // enough to exercise its continuation instead of automatically passing. const emperorSeat = seat(g, 'emperor'); if (!emperorSeat.hand.some(card => card.effect === 'karama')) { const index = g.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); const [counter] = g.deck.splice(index, 1); if (emperorSeat.hand.length >= 4) g.deck.push(emperorSeat.hand.pop()!); emperorSeat.hand.push(counter); } const lot = g.deck.shift(); assert.ok(lot); Object.assign(g, {phase:3, active:'harkonnen', order:['harkonnen','emperor','beneGesserit'], response:null, decision:null, phaseOpening:null, ready:[], auction:{cards:[lot], index:0, bid:0, bidder:null, active:'harkonnen', passed:[], opener:0, peekKnown:false}}); const bid = finishBid(g); g = bid.g; const original = frozenAuction(g), event = opportunity(g).event; const emperor = seat(g, 'emperor').spice, buyer = seat(g, 'harkonnen').spice; g = act(g, 'emperor', {type:'emperorGift', amount:2}); assert.equal(g.response?.kind, 'emperorGift'); g = act(g, 'beneGesserit', {type:'card', card:worthless.id, mode:'cancel'}); assert.equal(g.response?.kind, 'worthlessKarama'); g = passResponses(g); assert.deepEqual(frozenAuction(g), original); assert.equal(opportunity(g).event, event); assert.equal(seat(g, 'emperor').spice, emperor); assert.equal(seat(g, 'harkonnen').spice, buyer); assert.equal(g.discard.filter(card => card.id === worthless!.id).length, 1); g = continued(g); g = completePayment(g); assert.ok(seat(g, 'harkonnen').hand.some(card => card.id === bid.card)); }); void test('the normal bot scheduler advances a bot Emperor before the buyer payment', () => { const {g, card} = finishBid(openAuction(false, 0)); seat(g, 'emperor').bot = 'Medium'; const next = runBots(g, 8); assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null); assert.ok(seat(next, 'harkonnen').hand.some(row => row.id === card)); });