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 { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; import { truthTurnAmalInventory as inventory } from './fixture-truth-turn-amal'; 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)); /** Public Basic setup seats both native Ix factions. Only the reciprocal * alliance and the physical Amal/Karama custody are staged after setup. */ function fixture(recipient: 'ixians' | 'tleilaxu') { let game = createGame('EMPIXOPEN', newPlayer('emperor', 'Emperor', 'emperor'), false, ['ix']); for (const id of ['ixians', 'tleilaxu', 'atreides'] as const) joinGame(game, newPlayer(id, id, id)); for (const player of game.players) game = act(game, player.id, {type:'ready'}); game = act(game, 'emperor', {type:'start'}); for (let i = 0; game.status === 'setup' && i < 100; i++) { const next = nextStrongholdFactionsNativeStep(game); assert.ok(next); game = act(game, next.actor, next.action); } assert.equal(game.status, 'playing'); assert.ok(game.phaseOpening); const original = inventory(game); seat(game, 'emperor').ally = recipient; seat(game, recipient).ally = 'emperor'; // Move existing cards, including any originally dealt copy, into the deck; // then give the unpassed rival one real copy of each required card. for (const player of game.players) { const held = player.hand.filter(card => card.effect === 'amal' || card.effect === 'karama'); player.hand = player.hand.filter(card => card.effect !== 'amal' && card.effect !== 'karama'); game.deck.push(...held); } function transfer(effect: 'amal' | 'karama') { const index = game.deck.findIndex(card => card.effect === effect); assert.ok(index >= 0); const [card] = game.deck.splice(index, 1); seat(game, 'atreides').hand.push(card); return card.id; } const amal = transfer('amal'), counter = transfer('karama'); assert.deepEqual(inventory(game), original); game = act(game, 'emperor', {type:'ready'}); game = act(game, recipient, {type:'ready'}); assert.deepEqual(game.phaseOpening?.passed, ['emperor', recipient]); return {game, original, amal, counter}; } function passResponses(game: Game) { for (let i = 0; game.response && i < 16; i++) { 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; } for (const recipient of ['ixians', 'tleilaxu'] as const) { for (const cancel of [false, true]) { void test(`Basic Ix native ${recipient} opening gift ${cancel ? 'cancellation' : 'allowance'} preserves passes and later Amal`, () => { const f = fixture(recipient), opening = structuredClone(f.game.phaseOpening); const beforeOwner = seat(f.game, 'emperor').spice, beforeAlly = seat(f.game, recipient).spice; const phase = f.game.phase, turn = f.game.turn; assert.deepEqual(viewGame(f.game, 'emperor').emperorPhaseOpeningGift, {recipient, maximum:beforeOwner}); assert.equal(viewGame(f.game, recipient).emperorPhaseOpeningGift, null); assert.equal(viewGame(f.game, 'atreides').emperorPhaseOpeningGift, null); let game = act(f.game, 'emperor', {type:'emperorGift', amount:3}); assert.equal(game.response?.kind, 'emperorGift'); assert.equal(game.phaseOpening, null); assert.equal(viewGame(game, 'atreides').response?.amount, undefined); game = cancel ? act(game, 'atreides', {type:'card', card:f.counter, mode:'cancel'}) : passResponses(game); assert.equal(game.response, null); assert.deepEqual(game.phaseOpening, opening); assert.equal(game.phase, phase); assert.equal(game.turn, turn); assert.equal(seat(game, 'emperor').spice, beforeOwner - (cancel ? 0 : 3)); assert.equal(seat(game, recipient).spice, beforeAlly + (cancel ? 0 : 3)); assert.equal(game.discard.filter(card => card.id === f.counter).length, Number(cancel)); const wallets = game.players.map(player => player.spice); game = act(game, 'atreides', {type:'card', card:f.amal}); assert.deepEqual(game.players.map(player => player.spice), wallets.map(amount => Math.floor(amount / 2))); assert.deepEqual(game.phaseOpening?.passed, []); 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.equal(game.phase, phase); assert.equal(game.turn, turn); assert.deepEqual(inventory(game), f.original); }); } } void test('all minimal bots can respond to a native Ix opening gift', () => { const f = fixture('ixians'), opening = structuredClone(f.game.phaseOpening); const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1}); for (const difficulty of DIFFICULTIES) { let game = copy(pending); const view = viewGame(game, 'atreides'); view.players.find(player => player.id === 'atreides')!.bot = difficulty; const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card'); assert.ok(action); game = act(game, 'atreides', action); game = passResponses(game); assert.deepEqual(game.phaseOpening, opening); assert.equal(game.phase, f.game.phase); } });