import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, type Action, type Game } from '../game/engine'; import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance'; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); const seat = (game: Game, id: string) => { const result = game.players.find(player => player.id === id); assert.ok(result); return result; }; /** Original public H/A/E setup and real second-turn Nexus; only the optional * empty group ledger is staged. Test wallets and the cancellation card swap * below are explicitly controlled, not claimed native earnings. */ function auction(advanced: boolean, group: boolean): Game { const source = createTruthAllianceFixture({ advanced, consent: 'none' }); let game = structuredClone(source.game); if (group) game.coalitionState = emptyCoalitionState('three-short'); game = act(game, 'harkonnen', { type: 'alliance', target: 'emperor' }); game = act(game, 'emperor', { type: 'alliance', target: 'harkonnen' }); if (group) { const pair = coalitionOf(game.players, game.coalitionState, 'harkonnen'); assert.ok(pair); game = act(game, 'harkonnen', { type: 'coalitionPropose', groupId: pair.id, entrant: 'atreides', expectedRevision: game.coalitionState!.revision }); for (const id of ['emperor', 'atreides']) { const offer = game.coalitionState!.proposal!; game = act(game, id, { type: 'coalitionAnswer', event: offer.event, nexus: offer.nexus, turn: offer.turn, accept: true, expectedRevision: game.coalitionState!.revision }); } } game = advanceTruthAlliance(game, state => state.phase === 3 && !!state.auction && truthAllianceClean(state)); for (let count = 0; game.auction && game.auction.active !== 'harkonnen' && count < game.players.length; count++) game = act(game, game.auction.active, { type: 'passBid' }); assert.equal(game.auction?.active, 'harkonnen'); // Set only the wallets needed to observe the original physical bid and payer // legs; no auction, card, seat, alliance, or pending receipt is installed. seat(game, 'harkonnen').spice = 2; seat(game, 'emperor').spice = 10; seat(game, 'atreides').spice = 10; return game; } function passResponses(game: Game): Game { for (let count = 0; game.response && count < 20; count++) { const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); game = act(game, actor.id, { type: 'passResponse' }); } assert.equal(game.response, null); return game; } /** Reach native paid sale, leaving any resulting Emperor-income response for * the caller. Do not invent the special purchase or repeat an invoice. */ function bid(game: Game, action: Action): Game { game = act(game, 'harkonnen', action); for (let count = 0; !game.response && !game.decision && game.auction?.bidder === 'harkonnen' && count < 12; count++) game = act(game, game.auction.active, { type: 'passBid' }); if (game.pendingEmperorAuctionGift) game = act(game, game.pendingEmperorAuctionGift.owner, { type: 'emperorAuctionGiftContinue', event: game.pendingEmperorAuctionGift.event }); if (!game.response && game.decision?.kind === 'auctionPayment') game = act(game, 'harkonnen', { type: 'decision', karama: false }); return game; } for (const amount of [2, 6]) { void test(`Advanced ordinary pair routes the Emperor's ${amount}-spice contribution to the bank`, () => { let game = auction(true, false); const hand = seat(game, 'harkonnen').hand.length; seat(game, 'harkonnen').spice = amount === 2 ? 4 : 0; game = act(game, 'emperor', { type: 'pledgeAid', amount }); game = bid(game, { type: 'bid', amount: 6, allyPayment: amount }); game = passResponses(game); assert.ok(seat(game, 'harkonnen').hand.length > hand); assert.equal(seat(game, 'harkonnen').spice, 0); assert.equal(seat(game, 'emperor').spice, 10 - amount + (6 - amount)); }); } for (const amount of [2, 6]) { void test(`Advanced three-member bid routes the Emperor's ${amount}-spice share to the bank`, () => { let game = auction(true, true); seat(game, 'harkonnen').spice = amount === 6 ? 0 : 1; game = act(game, 'emperor', { type: 'coalitionPledge', recipient: 'harkonnen', amount }); if (amount === 2) game = act(game, 'atreides', { type: 'coalitionPledge', recipient: 'harkonnen', amount: 3 }); game = bid(game, { type: 'bid', amount: 6, allyPayment: 0, coalitionContributions: amount === 6 ? [{ donor: 'emperor', amount: 6 }] : [{ donor: 'emperor', amount: 2 }, { donor: 'atreides', amount: 3 }] }); game = passResponses(game); assert.equal(seat(game, 'harkonnen').spice, 0); assert.equal(seat(game, 'emperor').spice, 10 - amount + (6 - amount)); assert.equal(seat(game, 'atreides').spice, amount === 2 ? 7 : 10); }); } void test('Advanced direct Emperor gift belongs to the buyer and is not treated as an Emperor contribution', () => { let game = auction(true, false); game = act(game, 'emperor', { type: 'emperorGift', amount: 2 }); game = passResponses(game); assert.equal(seat(game, 'emperor').spice, 8); assert.equal(seat(game, 'harkonnen').spice, 4); game = bid(game, { type: 'bid', amount: 4, allyPayment: 0 }); game = passResponses(game); assert.equal(seat(game, 'harkonnen').spice, 0); assert.equal(seat(game, 'emperor').spice, 12); }); void test('Basic keeps the earlier full-Emperor-income handling for the same ordinary pair bid', () => { let game = auction(false, false); seat(game, 'harkonnen').spice = 4; game = act(game, 'emperor', { type: 'pledgeAid', amount: 2 }); game = bid(game, { type: 'bid', amount: 6, allyPayment: 2 }); game = passResponses(game); assert.equal(seat(game, 'harkonnen').spice, 0); assert.equal(seat(game, 'emperor').spice, 14); }); void test('canceling the remaining Advanced Emperor income keeps both payer debits and the purchased card', () => { let game = auction(true, false); seat(game, 'harkonnen').spice = 4; // Controlled conservation supplement: swap one original dealt Harkonnen // card for an original unauctioned Karama, with no card duplicated. const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); const [karama] = game.deck.splice(index, 1), [swapped] = seat(game, 'harkonnen').hand.splice(0, 1); seat(game, 'harkonnen').hand.push(karama); game.deck.push(swapped); const hand = seat(game, 'harkonnen').hand.length; game = act(game, 'emperor', { type: 'pledgeAid', amount: 2 }); game = bid(game, { type: 'bid', amount: 6, allyPayment: 2 }); assert.equal(game.response?.kind, 'emperorIncome'); game = act(game, 'harkonnen', { type: 'card', mode: 'cancel', card: karama.id }); game = passResponses(game); assert.equal(seat(game, 'harkonnen').spice, 0); assert.equal(seat(game, 'emperor').spice, 8); assert.ok(seat(game, 'harkonnen').hand.length >= hand); assert.equal(game.discard.filter(card => card.id === karama.id).length, 1); });