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 { territory } from '../game/board'; import { placeTerror } from '../game/moritani-terror'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; import { createTruthTurnAmalFixture, truthTurnAmalInventory as inventory } from './fixture-truth-turn-amal'; import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards'; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; }; const copy = (game: Game): Game => JSON.parse(JSON.stringify(game)); const pay = (target: string, amount: number): Action => ({type:'bribe', target, amount}); /** Public Basic Ix Harkonnen/Atreides/Emperor start. The Ix fixture uses a * real native phase-opening chronology and selects only unopened card order. */ void test('Basic Ix opening bribe preserves passes and the native Amal uses the post-payment wallets', () => { const f = createTruthTurnAmalFixture(); let game = f.game; const original = inventory(game); // Move the physical Amal to an unpassed seat so its later real play is possible. const owner = seat(game, 'emperor'), index = owner.hand.findIndex(card => card.id === f.amal); assert.ok(index >= 0); seat(game, 'atreides').hand.push(owner.hand.splice(index, 1)[0]); assert.deepEqual(inventory(game), original); game = act(game, 'harkonnen', {type:'ready'}); game = act(game, 'emperor', {type:'ready'}); const opening = structuredClone(game.phaseOpening), payer = seat(game, 'harkonnen').spice; const recipient = seat(game, 'emperor').spice, deferred = seat(game, 'emperor').bribes; assert.equal(viewGame(game, 'harkonnen').bribeOptions.blocked, null); assert.equal(viewGame(game, 'harkonnen').bribeOptions.receiptTiming, 'upcoming'); game = act(game, 'harkonnen', pay('emperor', 2)); assert.deepEqual(game.phaseOpening, opening); assert.equal(seat(game, 'harkonnen').spice, payer - 2); assert.equal(seat(game, 'emperor').spice, recipient); assert.equal(seat(game, 'emperor').bribes, deferred + 2); const beforeAmal = game.players.map(player => player.spice); game = act(game, 'atreides', {type:'card', card:f.amal}); assert.deepEqual(game.phaseOpening?.passed, []); assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2))); assert.equal(seat(game, 'emperor').bribes, deferred + 2); assert.equal(game.discard.filter(card => card.id === f.amal).length, 1); for (const player of game.players) game = act(game, player.id, {type:'ready'}); assert.equal(game.phaseOpening, null); assert.deepEqual(inventory(game), original); }); void test('bribe before real Mentat initialization is collected at that opening', () => { const f = createTruthTurnAmalFixture({phase:8, incoming:4}); let game = f.game; assert.equal(game.phaseOpening?.initialize, true); game = act(game, 'atreides', {type:'ready'}); const opening = structuredClone(game.phaseOpening), before = seat(game, 'emperor').spice; assert.equal(seat(game, 'emperor').bribes, 4); assert.equal(viewGame(game, 'atreides').bribeOptions.receiptTiming, 'upcoming'); game = act(game, 'atreides', pay('emperor', 2)); assert.deepEqual(game.phaseOpening, opening); assert.equal(seat(game, 'emperor').spice, before); assert.equal(seat(game, 'emperor').bribes, 6); game = act(game, 'emperor', {type:'ready'}); game = act(game, 'harkonnen', {type:'ready'}); assert.equal(game.phaseOpening, null); assert.equal(seat(game, 'emperor').spice, before + 6); assert.equal(seat(game, 'emperor').bribes, 0); }); /** Public Basic Ecaz-deck Moritani setup. As in the original Extortion gift * fixture, stage only a legal prior physical Terror placement, phase/turn and * two wallets; actual shipment, reveal, bank award and queue are native. */ function extortion() { let game = createGame('BRIBEEXTORTION', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']); joinGame(game, newPlayer('e', 'Emperor', 'emperor')); joinGame(game, newPlayer('a', 'Atreides', 'atreides')); joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen')); for (const player of game.players) game = act(game, player.id, {type:'ready'}); game = act(game, 'm', {type:'start'}); for (let i = 0; i < 100 && game.status === 'setup'; i++) { if (game.decision?.kind === 'moritaniSetup') game = act(game, 'm', {type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]}); else { const step = nextStrongholdFactionsNativeStep(game); assert.ok(step); game = act(game, step.actor, step.action); } } assert.equal(game.status, 'playing'); const token = game.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token); game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'habbanya_ridge_sietch', 1); Object.assign(game, {phase:5, turn:2, storm:18, order:['e','a','h','m'], active:'e', movementRemaining:['e','a','h','m'], response:null, decision:null, phaseOpening:null, ready:[]}); seat(game, 'e').spice = 10; seat(game, 'a').spice = 2; game = act(game, 'e', {type:'ship', territory:'habbanya_ridge_sietch', sector:17, amount:1}); assert.equal(game.decision?.kind, 'moritaniTerror'); game = act(game, 'm', {type:'decision', reveal:true}); assert.equal(game.moritaniTerror!.tokens.find(row => row.id === token.id)?.status, 'extortion'); game.phase = 7; game.ready = []; for (const player of game.players) game = act(game, player.id, {type:'ready'}); assert.equal(game.decision?.kind, 'moritaniPlacement'); game = act(game, 'm', {type:'decision', decline:true}); assert.equal(game.decision?.kind, 'moritaniExtortion'); game = act(game, 'e', {type:'decision', event:game.decision.event, pay:false}); assert.equal(game.decision?.kind, 'moritaniExtortion'); assert.equal(game.decision.player, 'a'); return {game, token:token.id}; } void test('Basic Extortion bribe leaves the queue intact and reaches the next real Mentat collection', () => { const f = extortion(); let game = f.game; const decision = structuredClone(game.decision), obligation = structuredClone(game.moritaniExtortion); const payer = seat(game, 'h').spice, current = seat(game, 'a').spice; assert.equal(viewGame(game, 'h').bribeOptions.blocked, null); assert.equal(viewGame(game, 'h').bribeOptions.receiptTiming, 'nextTurn'); assert.equal(viewGame(game, 'a').extortion.pending?.canPay, false); game = act(game, 'h', pay('a', 4)); assert.deepEqual(game.decision, decision); assert.deepEqual(game.moritaniExtortion, obligation); assert.equal(seat(game, 'h').spice, payer - 4); assert.equal(seat(game, 'a').spice, current); assert.equal(seat(game, 'a').bribes, 4); assert.equal(viewGame(game, 'a').extortion.pending?.canPay, false); assert.ok(game.decision?.kind === 'moritaniExtortion'); game = act(game, 'a', {type:'decision', event:game.decision.event, pay:false}); assert.ok(game.decision?.kind === 'moritaniExtortion'); assert.equal(game.decision.player, 'h'); game = act(game, 'h', {type:'decision', event:game.decision.event, pay:true}); assert.equal(game.moritaniExtortion, undefined); assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)?.status, 'removed'); // Continue with real native actions until the last readiness before the // next Mentat. Only the unopened turn-three Spice position is selected to // avoid an incidental worm; no future resource, phase, turn or escrow is staged. let ordered = false; for (let i = 0; i < 1900; i++) { if (!ordered && game.turn === 3 && beforeIxSpiceDraw(game)) { orderIxSpiceCards(game, ['land']); ordered = true; } if (game.turn === 3 && game.phase === 7 && !game.response && !game.decision && !game.phaseOpening && game.players.filter(player => !game.ready.includes(player.id)).length === 1) break; assert.notEqual(game.status, 'finished'); const next = nextStrongholdFactionsNativeStep(game); assert.ok(next); game = act(game, next.actor, next.action); } assert.equal(game.turn, 3); assert.equal(game.phase, 7); assert.equal(seat(game, 'a').bribes, 4); const before = seat(game, 'a').spice; const last = game.players.find(player => !game.ready.includes(player.id)); assert.ok(last); game = act(game, last.id, {type:'ready'}); assert.equal(game.phase, 8); assert.equal(seat(game, 'a').spice, before + 4); assert.equal(seat(game, 'a').bribes, 0); }); void test('invalid Basic pending bribes preserve their original parent and game', () => { const f = createTruthTurnAmalFixture(); const game = f.game; seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor'; const before = copy(game); for (const [actor, action] of [ ['emperor', pay('atreides', 1)], ['emperor', pay('emperor', 1)], ['emperor', pay('harkonnen', 0)], ['emperor', pay('harkonnen', seat(game, 'emperor').spice + 1)], ] as [string, Action][]) { assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); } const pending = extortion().game, saved = copy(pending); assert.throws(() => act(pending, 'h', pay('a', seat(pending, 'h').spice + 1))); assert.deepEqual(pending, saved); }); void test('an immediate deal cannot bypass its unchanged timing during an opening', () => { let game = createTruthTurnAmalFixture().game; game = act(game, 'harkonnen', {type:'dealOffer', target:'emperor', terms:'Information for spice.', payment:{kind:'immediateBribe', payer:'harkonnen', recipient:'emperor', amount:1}}); const deal = game.deals?.at(-1); assert.ok(deal); const before = copy(game); assert.throws(() => act(game, 'emperor', {type:'dealRespond', deal:deal.id, accept:true})); assert.deepEqual(game, before); assert.equal(viewGame(game, 'harkonnen').bribeOptions.blocked, null); game = act(game, 'harkonnen', pay('emperor', 1)); assert.deepEqual(game.phaseOpening, before.phaseOpening); assert.equal(game.deals?.at(-1)?.status, 'offered'); });