import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { createTruthTurnAmalFixture, 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)); /** Original public Basic Ix Harkonnen/Atreides/Emperor setup opens the first * real phase. Stage only the pair and conserved Amal/Karama custody; passes, * gift, response and phase initialization use native actions. */ function fixture() { const source = createTruthTurnAmalFixture(); let game = source.game; const original = inventory(game); seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor'; const amalIndex = seat(game, 'emperor').hand.findIndex(card => card.effect === 'amal'); assert.ok(amalIndex >= 0); seat(game, 'harkonnen').hand.push(seat(game, 'emperor').hand.splice(amalIndex, 1)[0]); // A real held opposing Karama keeps the gift's response open; existing // physical copies remain in their actual zones, never fabricated. for (const player of game.players) { const cards = player.hand.filter(card => card.effect === 'karama'); player.hand = player.hand.filter(card => card.effect !== 'karama'); game.deck.push(...cards); } const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); const [counter] = game.deck.splice(index, 1); seat(game, 'harkonnen').hand.push(counter); assert.deepEqual(inventory(game), original); assert.ok(game.phaseOpening); game = act(game, 'emperor', {type:'ready'}); game = act(game, 'atreides', {type:'ready'}); assert.deepEqual(game.phaseOpening?.passed, ['emperor','atreides']); return {game, original, counter:counter.id, amal:source.amal}; } function passResponses(game: Game) { for (let count = 0; game.response && count < 15; 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; } for (const cancel of [false,true]) { void test(`Basic Ix opening gift ${cancel ? 'cancellation' : 'allowance'} resumes exact passes through the original opening`, () => { const f = fixture(), initial = f.game, opening = structuredClone(initial.phaseOpening); const emperor = seat(initial, 'emperor').spice, ally = seat(initial, 'atreides').spice; const phase = initial.phase, turn = initial.turn; assert.deepEqual(viewGame(initial, 'emperor').emperorPhaseOpeningGift, {recipient:'atreides', maximum:emperor}); assert.equal(viewGame(initial, 'atreides').emperorPhaseOpeningGift, null); assert.equal(viewGame(initial, 'harkonnen').emperorPhaseOpeningGift, null); let game = act(initial, 'emperor', {type:'emperorGift', amount:3}); assert.equal(game.response?.kind, 'emperorGift'); assert.equal(game.phaseOpening, null); assert.equal(seat(game, 'emperor').spice, emperor); assert.equal(seat(game, 'atreides').spice, ally); assert.equal(viewGame(game, 'emperor').response?.amount, 3); assert.equal(viewGame(game, 'atreides').response?.amount, 3); assert.equal(viewGame(game, 'harkonnen').response?.amount, undefined); assert.equal(viewGame(game, 'emperor').emperorPhaseOpeningGift, null); assert.throws(() => act(game, 'emperor', {type:'emperorGift', amount:1})); game = cancel ? act(copy(game), 'harkonnen', {type:'card', card:f.counter, mode:'cancel'}) : passResponses(copy(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, emperor - (cancel ? 0 : 3)); assert.equal(seat(game, 'atreides').spice, ally + (cancel ? 0 : 3)); assert.equal(game.discard.filter(card => card.id === f.counter).length, Number(cancel)); assert.throws(() => act(game, 'harkonnen', {type:'passResponse'})); // The unpassed Harkonnen can still play the original physical Amal. const beforeAmal = game.players.map(player => player.spice); game = act(game, 'harkonnen', {type:'card', card:f.amal}); assert.deepEqual(game.players.map(player => player.spice), beforeAmal.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 id of ['emperor','atreides','harkonnen']) game = act(game, id, {type:'ready'}); assert.equal(game.phaseOpening, null); assert.equal(game.phase, phase); assert.equal(game.turn, turn); assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2))); assert.throws(() => act(game, 'harkonnen', {type:'card', card:f.amal})); assert.deepEqual(inventory(game), f.original); }); } void test('all minimal bots can legally respond to an opening gift without losing its parent', () => { const f = fixture(), 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, 'harkonnen'); view.players.find(player => player.id === 'harkonnen')!.bot = difficulty; const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card'); assert.ok(action); game = act(game, 'harkonnen', action); game = passResponses(game); assert.deepEqual(game.phaseOpening, opening); assert.equal(game.phase, f.game.phase); } }); void test('invalid opening gifts and a mismatched original opening cannot commit', () => { const f = fixture(), before = copy(f.game); for (const [actor, action] of [ ['atreides', {type:'emperorGift', amount:1}], ['emperor', {type:'emperorGift', amount:0}], ['emperor', {type:'emperorGift', amount:11}], ] as [string, Action][]) { assert.throws(() => act(f.game, actor, action)); assert.deepEqual(f.game, before); } const unallied = copy(f.game); seat(unallied, 'emperor').ally = null; seat(unallied, 'atreides').ally = null; assert.equal(viewGame(unallied, 'emperor').emperorPhaseOpeningGift, null); assert.throws(() => act(unallied, 'emperor', {type:'emperorGift', amount:1})); const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1}); const altered = copy(pending); altered.response!.emperorPhaseOpeningGift!.opening.passed = []; assert.throws(() => viewGame(altered, 'emperor'), /original phase opening/); assert.throws(() => act(altered, 'harkonnen', {type:'passResponse'}), /original phase opening/); }); void test('a deferred Mentat initialization follows a gift and credits the original bribe once', () => { const f = createTruthTurnAmalFixture({phase:8, incoming:4}); let game = f.game; seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor'; assert.equal(game.phaseOpening?.initialize, true); assert.equal(seat(game, 'emperor').spice, 10); assert.equal(seat(game, 'emperor').bribes, 4); game = act(game, 'emperor', {type:'ready'}); assert.deepEqual(viewGame(game, 'emperor').emperorPhaseOpeningGift, {recipient:'atreides', maximum:10}); assert.throws(() => act(game, 'emperor', {type:'emperorGift', amount:11})); game = act(game, 'emperor', {type:'emperorGift', amount:3}); game = passResponses(game); assert.deepEqual(game.phaseOpening, {passed:['emperor'], initialize:true}); assert.equal(seat(game, 'emperor').spice, 7); assert.equal(seat(game, 'emperor').bribes, 4); game = act(game, 'atreides', {type:'ready'}); game = act(game, 'harkonnen', {type:'ready'}); assert.equal(game.phaseOpening, null); assert.equal(seat(game, 'emperor').spice, 11); assert.equal(seat(game, 'emperor').bribes, 0); });