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, runBots } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, 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 => structuredClone(game); function passResponse(game: Game): Game { for (let count = 0; game.response && count < 30; count++) { const player = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(player); game = act(game, player.id, {type:'passResponse'}); } assert.equal(game.response, null); return game; } function payment(game: Game, pay: boolean): Action { assert.ok(game.decision?.kind === 'moritaniExtortion'); return {type:'decision', event:game.decision.event, pay}; } /** Actual mixed-deck public setup, physical Extortion placement, shipment, * reveal and Mentat queue. Alliance, turn boundary, wallets and physical * Karama custody are staged, as in the existing native Extortion fixture. */ function pending(native: 'ixians' | 'tleilaxu') { let game = createGame('MIXEDEXTORTION', newPlayer('m','Moritani','moritani'), false, ['ecaz','ix']); for (const [id, faction] of [['e','emperor'], ['a','atreides'], ['h','harkonnen'], ['i',native]] as const) joinGame(game, newPlayer(id, faction, faction)); for (const player of game.players) game = act(game, player.id, {type:'ready'}); game = act(game, 'm', {type:'start'}); for (let count = 0; game.status === 'setup' && count < 100; count++) { 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'); seat(game,'e').ally = 'a'; seat(game,'a').ally = 'e'; 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','i','m'], active:'e', movementRemaining:['e','a','h','i','m'], response:null, decision:null, phaseOpening:null, ready:[]}); seat(game,'e').spice = 11; seat(game,'a').spice = 2; const harkonnen = seat(game,'h'); let card = harkonnen.hand.find(row => row.effect === 'karama'); if (!card) { const sourceOwner = game.players.find(row => row.id !== 'h' && row.hand.some(item => item.effect === 'karama')); const source = sourceOwner?.hand ?? game.deck; const index = source.findIndex(item => item.effect === 'karama'); assert.ok(index >= 0); [card] = source.splice(index, 1); harkonnen.hand.push(card); } const karama = card.id; 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'); const beforeAward = seat(game,'m').spice; game.phase = 7; game.ready = []; for (const player of game.players) game = act(game,player.id,{type:'ready'}); assert.equal(game.phase,8); while (game.phaseOpening) { const step = nextStrongholdFactionsNativeStep(game); assert.ok(step); game = act(game,step.actor,step.action); } assert.equal(game.decision?.kind,'moritaniPlacement'); game = act(game,'m',{type:'decision',decline:true}); assert.equal(seat(game,'m').spice,beforeAward + 5); assert.equal(game.decision?.kind,'moritaniExtortion'); assert.equal(game.decision.player,'e'); game = act(game,'e',payment(game,false)); assert.equal(game.decision?.kind,'moritaniExtortion'); assert.equal(game.decision.player,'a'); assert.equal(viewGame(game,'a').extortion.pending?.canPay,false); return {game, token:token.id, karama, beforeAward}; } function original(game: Game) { return {decision:copy(game).decision, obligation:copy(game).moritaniExtortion, order:[...game.order], turn:game.turn, phase:game.phase}; } for (const native of ['ixians','tleilaxu'] as const) { void test(`Basic mixed ${native} Emperor gift enables the original Extortion payment`, () => { const f = pending(native); let game = f.game; const before = original(game), event = (game.decision as Extract,{kind:'moritaniExtortion'}>).event; assert.deepEqual(viewGame(game,'e').emperorExtortionGift,{recipient:'a',maximum:10}); for (const id of ['a','h','i','m']) assert.equal(viewGame(game,id).emperorExtortionGift,null); game = act(game,'e',{type:'emperorGift',amount:1}); assert.equal(game.response?.kind,'emperorGift'); game = passResponse(game); assert.deepEqual(original(game),before); assert.equal(seat(game,'e').spice,9); assert.equal(seat(game,'a').spice,3); assert.equal(viewGame(game,'a').extortion.pending?.canPay,true); game = act(game,'a',{type:'decision',event,pay:true}); assert.equal(seat(game,'a').spice,0); assert.equal(seat(game,'m').spice,f.beforeAward + 8); assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)?.status,'removed'); assert.equal(game.moritaniExtortion,undefined); }); void test(`Basic mixed ${native} Karama cancels the gift and preserves the payer`, () => { const f = pending(native); let game = f.game; const before = original(game); game = act(game,'e',{type:'emperorGift',amount:1}); game = act(game,'h',{type:'card',mode:'cancel',card:f.karama}); assert.equal(game.response,null); assert.deepEqual(original(game),before); assert.equal(seat(game,'e').spice,10); assert.equal(seat(game,'a').spice,2); assert.equal(viewGame(game,'a').extortion.pending?.canPay,false); assert.equal(game.discard.filter(card => card.id === f.karama).length,1); const unchanged = copy(game); assert.throws(() => act(game,'a',payment(game,true))); assert.deepEqual(game,unchanged); game = act(game,'a',payment(game,false)); assert.equal(seat(game,'m').spice,f.beforeAward + 5); }); } void test('all four minimal bot profiles can respond to a mixed Basic Extortion gift', () => { let {game} = pending('ixians'); game = act(game,'e',{type:'emperorGift',amount:1}); for (const difficulty of DIFFICULTIES) { const view = viewGame(game,'h'); view.players.find(player => player.id === 'h')!.bot = difficulty; const actions = botActions(view); assert.ok(actions.length > 0); assert.doesNotThrow(() => act(copy(game),'h',actions[0])); } }); for (const native of ['ixians','tleilaxu'] as const) void test( `Basic mixed ${native} bot ally waits for the human Emperor gift and Continue`, () => { const initial = pending(native); for (const difficulty of DIFFICULTIES) { let game = copy(initial.game); seat(game,'a').bot = difficulty; assert.ok(game.decision?.kind === 'moritaniExtortion'); const event = game.decision.event; assert.deepEqual(viewGame(game,'e').emperorExtortionPriority,{event,recipient:'a',canContinue:true}); assert.deepEqual(viewGame(game,'a').emperorExtortionPriority,{event,recipient:'a',canContinue:false}); assert.ok(!botActions(viewGame(game,'a')).some(action => action.type === 'decision' && action.event === event)); let waiting = runBots(game,5); assert.equal(waiting.decision?.kind,'moritaniExtortion'); assert.equal(waiting.decision.event,event); assert.equal(seat(waiting,'a').spice,2); game = act(game,'e',{type:'emperorGift',amount:1}); game = passResponse(game); assert.equal(seat(game,'a').spice,3); assert.deepEqual(viewGame(game,'e').emperorExtortionPriority,{event,recipient:'a',canContinue:true}); waiting = runBots(game,5); assert.equal(waiting.decision?.kind,'moritaniExtortion'); assert.equal(waiting.decision.event,event); game = act(game,'e',{type:'emperorExtortionContinue',event}); const action = botActions(viewGame(game,'a')).find(row => row.type === 'decision' && row.event === event); assert.ok(action, `${difficulty} must have a legal original Extortion response`); assert.doesNotThrow(() => act(copy(game),'a',action)); game = runBots(game,1); assert.ok(game.decision?.kind !== 'moritaniExtortion' || game.decision.event !== event); } });