import assert from 'node:assert/strict'; import test from 'node:test'; import { baseDeck } from '../game/cards'; import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); /** Staged quiet Basic Nexus, empty group ledger and pre-Charity wallets; pair * formation, unanimous group agreement, Charity completion and the auction * are real engine actions. One original Karama is physically transferred from * the deck to the bidder, so the actual payment choice remains available. * This is a controlled setup, not a completed game or a public group option. */ function fixture(): Game { let game = createGame('GROUPBID', newPlayer('a', 'Fremen bidder', 'fremen'), false); game.players.push(newPlayer('b', 'Guild donor', 'guild'), newPlayer('c', 'Harkonnen donor', 'harkonnen'), newPlayer('d', 'Emperor outsider', 'emperor')); Object.assign(game, { status: 'playing', phase: 1, turn: 2, nexus: true, order: ['a', 'b', 'c', 'd'], ready: [], storm: 18, deck: baseDeck(), discard: [], response: null, decision: null, phaseOpening: null }); for (const player of game.players) { player.spice = 10; player.hand = []; player.forces = {}; player.reserves = 20; player.tanks = 0; player.traitorChoices = []; player.traitors = []; } game.players[0].spice = 2; const cardIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(cardIndex >= 0); game.players[0].hand.push(game.deck.splice(cardIndex, 1)[0]); game.coalitionState = emptyCoalitionState(); game = act(game, 'a', {type: 'alliance', target: 'b'}); game = act(game, 'b', {type: 'alliance', target: 'a'}); const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair); game = act(game, 'a', {type: 'coalitionPropose', groupId: pair.id, entrant: 'c', expectedRevision: game.coalitionState!.revision}); for (const id of ['b', 'c']) { 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}); } assert.ok(game.coalitionEscrow); Object.assign(game, {phase: 2, nexus: false, active: null, ready: []}); for (const player of game.players) game = act(game, player.id, {type: 'ready'}); assert.equal(game.phase, 3); assert.equal(game.auction?.active, 'a'); return game; } function pledge(game: Game): Game { game = act(game, 'b', {type: 'coalitionPledge', recipient: 'a', amount: 4}); return act(game, 'c', {type: 'coalitionPledge', recipient: 'a', amount: 3}); } const groupBid: Action = {type: 'bid', amount: 6, allyPayment: 0, coalitionContributions: [{donor:'b', amount:3}, {donor:'c', amount:2}]}; const pledged = (game: Game, donor: string, recipient = 'a') => game.coalitionEscrow!.pledges.find(item => item.donor === donor && item.recipient === recipient)?.amount ?? 0; const spice = (game: Game) => game.players.reduce((sum, player) => sum + player.spice, 0) + (game.coalitionEscrow?.pledges.reduce((sum, entry) => sum + entry.amount, 0) ?? 0); function rejected(game: Game, id: string, action: Action) { const before = structuredClone(game); assert.throws(() => act(game, id, action)); assert.deepEqual(game, before); } function passResponse(game: Game): Game { if (!game.response) return game; const kind = game.response?.kind; for (let i = 0; game.response?.kind === kind && i < 16; i++) { const next = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(next); game = act(game, next.id, {type: 'passResponse'}); } return game; } function finishBid(game: Game): Game { for (let i = 0; !game.response && !game.decision && i < 12; i++) { assert.ok(game.auction?.active); game = act(game, game.auction.active, {type: 'passBid'}); } return game; } void test('each donor share of a leading group bid remains locked until outbid', () => { let game = act(pledge(fixture()), 'a', groupBid); assert.equal(game.auction!.bidder, 'a'); assert.deepEqual(game.auction!.coalitionContributions, groupBid.coalitionContributions); rejected(game, 'b', {type:'coalitionPledge', recipient:'a', amount:2}); rejected(game, 'c', {type:'coalitionPledge', recipient:'a', amount:1}); assert.equal(pledged(game, 'b'), 4); assert.equal(pledged(game, 'c'), 3); game = act(game, 'b', {type:'passBid'}); game = act(game, 'c', {type:'passBid'}); assert.equal(game.auction!.active, 'd'); game = act(game, 'd', {type:'bid', amount:7, allyPayment:0}); assert.equal(game.auction!.bidder, 'd'); assert.equal(game.auction!.coalitionContributions, undefined); game = act(game, 'b', {type:'coalitionPledge', recipient:'a', amount:1}); game = act(game, 'c', {type:'coalitionPledge', recipient:'a', amount:0}); assert.equal(pledged(game, 'b'), 1); assert.equal(pledged(game, 'c'), 0); assert.equal(game.players[1].spice, 9); assert.equal(game.players[2].spice, 10); assert.equal(spice(game), 32); }); void test('fulfilled and released bid promises leave group bid funding usable', () => { for (const history of ['settled', 'prior-turn'] as const) { const initial = fixture(); initial.phaseBidPromises = [{event:`bid-${history}`, player:'d', asker:'a', turn:history === 'prior-turn' ? initial.turn - 1 : initial.turn, minimum:1, answer:true, ...(history === 'settled' ? {fulfilled:true} : {released:true})}]; const game = act(pledge(initial), 'a', groupBid); assert.equal(game.auction?.bidder, 'a'); assert.deepEqual(game.auction?.coalitionContributions, groupBid.coalitionContributions); assert.deepEqual(game.phaseBidPromises, initial.phaseBidPromises); } }); void test('only the bidder sees the combined group payment, without raw donor allocations', () => { const game = act(pledge(fixture()), 'a', groupBid); const own = viewGame(game, 'a').auction; assert.ok(own); assert.equal(own.coalitionPayment, 5); assert.equal(Object.hasOwn(own, 'coalitionContributions'), false); for (const id of ['b', 'c', 'd']) { const other = viewGame(game, id).auction; assert.ok(other); assert.equal(Object.hasOwn(other, 'coalitionPayment'), false); assert.equal(Object.hasOwn(other, 'coalitionContributions'), false); } }); for (const difficulty of DIFFICULTIES) void test(`${difficulty} chooses a legal decision for a funded group bid`, () => { let game = act(pledge(fixture()), 'a', groupBid); game = finishBid(game); assert.equal(game.decision?.kind, 'auctionPayment'); const before = structuredClone(game), view = viewGame(game, 'a'); view.players.find(player => player.id === 'a')!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action); assert.equal(action.type, 'decision'); assert.equal(typeof action.karama, 'boolean'); assert.ok(act(game, 'a', action)); assert.deepEqual(game, before); }); void test('winning group bid settles its named donors once and pays the outside Emperor', () => { let game = pledge(fixture()); const original = spice(game), beforeHand = game.players[0].hand.length; game = act(game, 'a', groupBid); game = finishBid(game); assert.equal(game.decision?.kind, 'auctionPayment'); game = act(game, 'a', {type:'decision', karama:false}); if (game.response) assert.equal(game.response.kind, 'emperorIncome'); game = passResponse(game); if (game.auction) assert.equal(game.auction.coalitionContributions, undefined, 'A later lot cannot retain the previous bid’s donor allocation.'); assert.equal(game.players[0].spice, 1); assert.equal(game.players[1].spice, 6); assert.equal(game.players[2].spice, 7); assert.equal(pledged(game, 'b'), 1); assert.equal(pledged(game, 'c'), 1); assert.equal(game.players[3].spice, 16); assert.equal(game.players[0].hand.length, beforeHand + 1); assert.equal(spice(game), original); }); void test('a later Truthtrance bid commitment does not invalidate an already funded group bid', () => { const source = fixture(); // Controlled, custody-preserving transfer: the real Truthtrance moves from // the undealt deck to Emperor's empty hand; no card or pledge is invented. const index = source.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0); const [truth] = source.deck.splice(index, 1); source.players[3].hand.push(truth); let game = act(pledge(source), 'a', groupBid); game = act(game, 'd', {type:'card', card:truth.id}); for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) { const next = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(next); game = act(game, next.id, {type:'truthPass'}); } assert.equal(game.truthtrance?.stage, 'ask'); assert.ok(game.auction); game = act(game, 'd', {type:'truthAsk', question:{kind:'bid', target:'b', turn:game.turn, lot:game.auction.index + 1, minimum:8}}); game = act(game, 'b', {type:'truthAnswer', answer:'no'}); assert.ok(game.bidPromises?.some(promise => promise.player === 'b' && !promise.answer)); assert.equal(game.auction!.bidder, 'a'); game = finishBid(game); assert.equal(game.decision?.kind, 'auctionPayment'); game = act(game, 'a', {type:'decision', karama:false}); if (game.response) game = passResponse(game); assert.equal(game.players[0].spice, 1); assert.equal(pledged(game, 'b'), 1); assert.equal(pledged(game, 'c'), 1); assert.equal(game.players[3].spice, 16); }); void test('an Emperor donor receives the entire winning payment under the normal income rule', () => { let game = fixture(); // Staged faction swap tests the printed Emperor contribution case while // preserving wallets, cards and the original group membership. game.players[1].faction = 'emperor'; game.players[3].faction = 'guild'; game = act(pledge(game), 'a', groupBid); game = finishBid(game); assert.equal(game.decision?.kind, 'auctionPayment'); game = act(game, 'a', {type:'decision', karama:false}); assert.equal(game.response?.kind, 'emperorIncome'); game = passResponse(game); assert.equal(game.players[1].spice, 12); assert.equal(pledged(game, 'b'), 1); assert.equal(game.players[3].spice, 10); assert.equal(spice(game), 32); }); void test('invalid or insufficient group bid splits reject atomically', () => { const game = pledge(fixture()); for (const shares of [ [{donor:'d', amount:3}], [{donor:'b', amount:5}, {donor:'c', amount:1}], [{donor:'b', amount:3}, {donor:'b', amount:1}], [{donor:'b', amount:3}], [{donor:'b', amount:4}, {donor:'c', amount:3}], ]) rejected(game, 'a', {...groupBid, coalitionContributions: shares}); }); void test('paying with the real Karama releases the group shares without charging donors or Emperor', () => { let game = act(pledge(fixture()), 'a', groupBid); game = finishBid(game); assert.equal(game.decision?.kind, 'auctionPayment'); const card = game.players[0].hand.find(item => item.effect === 'karama'); assert.ok(card); game = act(game, 'a', {type:'decision', karama:true, card:card.id}); if (game.response) game = passResponse(game); assert.equal(game.players[0].spice, 2); assert.equal(game.players[3].spice, 10); assert.equal(pledged(game, 'b'), 4); assert.equal(pledged(game, 'c'), 3); assert.equal(game.discard.filter(item => item.id === card.id).length, 1); game = act(game, 'b', {type:'coalitionPledge', recipient:'a', amount:0}); game = act(game, 'c', {type:'coalitionPledge', recipient:'a', amount:0}); assert.equal(spice(game), 32); assert.equal(game.players[1].spice, 10); assert.equal(game.players[2].spice, 10); }); void test('unused group bid pledges return to each donor when Bidding closes', () => { let game = act(pledge(fixture()), 'a', groupBid); game = finishBid(game); assert.equal(game.decision?.kind, 'auctionPayment'); game = act(game, 'a', {type:'decision', karama:false}); if (game.response) game = passResponse(game); for (let i = 0; game.phase === 3 && i < 25; i++) { if (game.response) { game = passResponse(game); continue; } assert.ok(!game.decision, 'No unrelated decision should interrupt this staged basic auction.'); assert.ok(game.auction?.active); game = act(game, game.auction.active, {type:'passBid'}); } assert.notEqual(game.phase, 3); assert.equal(pledged(game, 'b'), 0); assert.equal(pledged(game, 'c'), 0); assert.equal(game.players[1].spice, 7); assert.equal(game.players[2].spice, 8); assert.equal(game.players[3].spice, 16); assert.equal(spice(game), 32); });