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 { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; const seat = (g: Game, id: string) => { const p = g.players.find(row => row.id === id); assert.ok(p); return p; }; const act = (g: Game, id: string, action: Action) => applyAction(g, id, action); const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); function allow(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'}); } assert.equal(g.response, null); return g; } function payment(g: Game, pay: boolean): Action { const d = g.decision; assert.ok(d?.kind === 'moritaniExtortion'); return {type:'decision', event:d.event, pay}; } function counter(g: Game) { const p = seat(g, 'h'); let card = p.hand.find(row => row.effect === 'karama'); if (!card) { // Starting cards are shuffled: the physical Karama may already be in // another hand rather than the unopened deck. const owner = g.players.find(player => player.id !== p.id && player.hand.some(row => row.effect === 'karama')); const source = owner?.hand ?? g.deck; const index = source.findIndex(row => row.effect === 'karama'); assert.ok(index >= 0); [card] = source.splice(index, 1); p.hand.push(card); } assert.ok(card); return card.id; } /** Genuine public E3/Basic or Advanced start and native setup. Stage the * reciprocal alliance, clean movement boundary, a physical Extortion token's * legal prior placement and the named test wallets. A real shipment triggers * revelation, and actual Mentat controls collect bank5 and create the queue. */ function pending(advanced: boolean) { let g = createGame('EMPEROREXTORTION', newPlayer('m', 'Moritani', 'moritani'), advanced, ['ecaz']); joinGame(g, newPlayer('e', 'Emperor', 'emperor')); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen')); for (const p of g.players) g = act(g, p.id, {type:'ready'}); g = act(g, 'm', advanced ? {type:'start', advancedPreview:true} : {type:'start'}); for (let i = 0; i < 100 && g.status === 'setup'; i++) { if (g.decision?.kind === 'moritaniSetup') { g = act(g, 'm', {type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]}); } else { const step = nextStrongholdFactionsNativeStep(g); assert.ok(step); g = act(g, step.actor, step.action); } } assert.equal(g.status, 'playing'); seat(g, 'e').ally = 'a'; seat(g, 'a').ally = 'e'; // A stopped ordinary shipment is not involved: the original entry commits // before the native Terror decision. The source physical token is unchanged. const token = g.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token); g.moritaniTerror = placeTerror(g.moritaniTerror!, token.id, 'habbanya_ridge_sietch', 1); Object.assign(g, {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(g, 'e').spice = 10; seat(g, 'a').spice = 2; const karama = counter(g); g = act(g, 'e', {type:'ship', territory:'habbanya_ridge_sietch', sector:17, amount:1}); assert.equal(g.decision?.kind, 'moritaniTerror'); g = act(g, 'm', {type:'decision', reveal:true}); assert.equal(g.moritaniTerror!.tokens.find(row => row.id === token.id)?.status, 'extortion'); const beforeAward = seat(g, 'm').spice; // Enter the original Mentat pipeline after the played reveal, like the // existing Extortion engine fixture; the physical reveal remains intact. g.phase = 7; g.ready = []; for (const p of g.players) g = act(g, p.id, {type:'ready'}); assert.equal(g.phase, 8); assert.equal(g.decision?.kind, 'moritaniPlacement'); g = act(g, 'm', {type:'decision', decline:true}); assert.equal(seat(g, 'm').spice, beforeAward + 5); assert.equal(g.decision?.kind, 'moritaniExtortion'); assert.equal(g.decision?.player, 'e'); g = act(g, 'e', payment(g, false)); assert.equal(g.decision?.kind, 'moritaniExtortion'); assert.equal(g.decision?.player, 'a'); assert.equal(viewGame(g, 'a').extortion.pending?.canPay, false); return {g, token:token.id, karama, beforeAward}; } function parent(g: Game) { return {decision:structuredClone(g.decision), obligation:structuredClone(g.moritaniExtortion), order:[...g.order], turn:g.turn, phase:g.phase}; } for (const advanced of [false, true]) { void test(`${advanced ? 'Advanced' : 'Basic'} Emperor gift lets an insolvent ally pay the original Extortion`, () => { const fixture = pending(advanced), {token, beforeAward} = fixture; let g = fixture.g; const decision = g.decision; assert.ok(decision?.kind === 'moritaniExtortion'); const before = parent(g), emperor = seat(g, 'e').spice, event = decision.event; assert.deepEqual(viewGame(g, 'e').emperorExtortionGift, {recipient:'a', maximum:emperor}); for (const other of ['a','h','m']) assert.equal(viewGame(g, other).emperorExtortionGift, null); g = act(g, 'e', {type:'emperorGift', amount:1}); assert.equal(g.response?.kind, 'emperorGift'); assert.equal(viewGame(g, 'e').response?.amount, 1); assert.equal(viewGame(g, 'a').response?.amount, 1); assert.equal(viewGame(g, 'h').response?.amount, undefined); assert.equal(viewGame(g, 'm').response?.amount, undefined); assert.equal(seat(g, 'a').spice, 2); g = allow(reload(g)); assert.deepEqual(parent(g), before); assert.equal(seat(g, 'e').spice, emperor - 1); assert.equal(seat(g, 'a').spice, 3); assert.equal(seat(g, 'm').spice, beforeAward + 5); assert.equal(viewGame(g, 'a').extortion.pending?.canPay, true); g = act(g, 'a', {type:'decision', event, pay:true}); assert.equal(seat(g, 'a').spice, 0); assert.equal(seat(g, 'm').spice, beforeAward + 8); assert.equal(g.moritaniTerror!.tokens.find(row => row.id === token)?.status, 'removed'); assert.equal(g.moritaniExtortion, undefined); }); void test(`${advanced ? 'Advanced' : 'Basic'} Karama cancels the gift without changing the unaffordable payer or event`, () => { const fixture = pending(advanced), {token, karama, beforeAward} = fixture; let g = fixture.g; const before = parent(g), emperor = seat(g, 'e').spice; g = act(g, 'e', {type:'emperorGift', amount:1}); assert.equal(g.response?.kind, 'emperorGift'); g = act(reload(g), 'h', {type:'card', card:karama, mode:'cancel'}); assert.equal(g.response, null); assert.deepEqual(parent(g), before); assert.equal(seat(g, 'e').spice, emperor); assert.equal(seat(g, 'a').spice, 2); assert.equal(viewGame(g, 'a').extortion.pending?.canPay, false); assert.equal(seat(g, 'm').spice, beforeAward + 5); assert.equal(g.moritaniTerror!.tokens.find(row => row.id === token)?.status, 'extortion'); assert.equal(g.discard.filter(card => card.id === karama).length, 1); const snapshot = reload(g); assert.throws(() => act(g, 'a', payment(g, true))); assert.deepEqual(g, snapshot); g = act(g, 'a', payment(g, false)); assert.equal(seat(g, 'm').spice, beforeAward + 5); }); } void test('unauthorized, invalid, and stale Extortion actions do not change the live opportunity', () => { const {g} = pending(false), before = reload(g); for (const [actor, action] of [ ['a', {type:'emperorGift', amount:1}], ['h', {type:'emperorGift', amount:1}], ['e', {type:'emperorGift', amount:0}], ['e', {type:'emperorGift', amount:seat(g, 'e').spice + 1}], ['a', {type:'decision', event:'stale', pay:false}], ] as [string, Action][]) assert.throws(() => act(g, actor, action)); assert.deepEqual(g, before); }); void test('all minimal bot profiles choose a legal response to the original gift', () => { let {g} = pending(false); g = act(g, 'e', {type:'emperorGift', amount:1}); assert.equal(g.response?.kind, 'emperorGift'); for (const difficulty of DIFFICULTIES) { const view = viewGame(g, 'h'); view.players.find(p => p.id === 'h')!.bot = difficulty; const actions = botActions(view); assert.ok(actions.length > 0); assert.doesNotThrow(() => act(reload(g), 'h', actions[0])); } }); void test('the active gift remains bound to the original Extortion decision and amount', () => { const {g: start} = pending(false); const g = act(start, 'e', {type:'emperorGift', amount:1}); const before = reload(g); assert.throws(() => act(g, 'a', payment(g, false))); assert.deepEqual(g, before); const changed = reload(g); assert.ok(changed.response); changed.response.amount = 2; assert.throws(() => viewGame(changed, 'a')); assert.throws(() => act(changed, 'h', {type:'passResponse'})); });