import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, type Game, type GameView, } from '../game/engine'; import { baseDeck } from '../game/cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { createAmbassadors, validateAmbassadors, type AmbassadorEffect, } from '../game/ecaz-ambassadors'; function fixture(effect: AmbassadorEffect = 'emperor') { const g = createGame('ECAZBOTS', newPlayer('ecaz', 'Ecaz', 'ecaz')); g.players.push( newPlayer( 'entrant', 'Entrant', effect === 'atreides' ? 'emperor' : 'atreides', ), newPlayer('ally', 'Ally', 'harkonnen'), ); g.status = 'playing'; g.phase = 5; g.turn = 2; g.storm = 18; g.order = ['entrant', 'ecaz', 'ally']; g.active = 'entrant'; g.movementRemaining = [...g.order]; g.deck = baseDeck(); for (const p of g.players) { p.hand = []; p.forces = {}; p.reserves = 20; p.spice = 10; p.traitors = [p.leaders[0].id]; } for (const p of g.players) { for (const kind of p.id === 'entrant' ? ['projectile'] : ['worthless', 'shield']) { const index = g.deck.findIndex((c) => c.kind === kind); p.hand.push(g.deck.splice(index, 1)[0]); } } const state = createAmbassadors(() => 0); const cohortEffects = effect === 'beneGesserit' ? ['beneGesserit', 'fremen', 'richese', 'guild', 'tleilaxu'] : [ effect, ...state.tokens .map((t) => t.effect) .filter((e) => e !== effect && e !== 'ecaz'), ].slice(0, 5); state.cohort = state.tokens .filter((t) => cohortEffects.includes(t.effect)) .map((t) => t.id); for (const token of state.tokens) { token.zone = token.effect === 'ecaz' || state.cohort.includes(token.id) ? 'supply' : 'pool'; token.location = null; } const token = state.tokens.find((t) => t.effect === effect)!; token.zone = 'placed'; token.location = 'arrakeen'; validateAmbassadors(state); g.ecazAmbassadors = state; return g; } function enter(g: Game) { return applyAction(g, 'entrant', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10, }); } function botView( g: Game, id: string, difficulty: (typeof DIFFICULTIES)[number], ) { const view = viewGame(g, id); view.players.find((p) => p.id === id)!.bot = difficulty; return view; } function choose(g: Game, difficulty: (typeof DIFFICULTIES)[number]) { assert.equal(g.decision?.kind, 'ecazAmbassador'); const id = g.decision!.player; const before = structuredClone(g); const actions = botActions(botView(g, id, difficulty)); assert.equal(actions.length, 1, difficulty); assert.equal(actions[0].event, g.pendingAmbassador!.event); assert.deepEqual(g, before, 'policy must not mutate authoritative state'); return { action: actions[0], next: applyAction(g, id, actions[0]) }; } function finish(g: Game, difficulty: (typeof DIFFICULTIES)[number]) { for (let steps = 0; g.pendingAmbassador && steps < 4; steps++) g = choose(JSON.parse(JSON.stringify(g)) as Game, difficulty).next; assert.equal( g.pendingAmbassador, null, `${difficulty}: bounded entry completion`, ); return g; } void test('all four profiles legally resolve each supported ordinary effect from only the decision owner’s view', () => { for (const difficulty of DIFFICULTIES) for (const effect of [ 'emperor', 'atreides', 'harkonnen', 'choam', 'ixians', 'tleilaxu', ] as const) { const initial = enter(fixture(effect)); for (const id of ['entrant', 'ally']) assert.deepEqual(botActions(botView(initial, id, difficulty)), []); const done = finish(initial, difficulty); assert.equal(done.players[1].reserves, 19); assert.equal(done.players[1].spice, 9); assert.equal(done.players[1].forces['arrakeen:10'], 1); assert.equal( done.ecazAmbassadors!.tokens.find((t) => t.effect === effect)!.zone, 'used', ); if (effect === 'emperor') assert.equal(done.players[0].spice, 15); if (effect === 'choam') { assert.equal(done.players[0].spice, 13); assert.equal(done.players[0].hand.length, 1); } if (effect === 'ixians') assert.equal(done.players[0].hand.length, 2); if (effect === 'atreides' || effect === 'harkonnen') { assert.equal(viewGame(done, 'ecaz').ambassadorInsights.length, 1); assert.deepEqual(viewGame(done, 'entrant').ambassadorInsights, []); assert.deepEqual(viewGame(done, 'ally').ambassadorInsights, []); if (effect === 'atreides') assert.deepEqual( viewGame(done, 'ecaz').ambassadorInsights[0].cards, initial.players[1].hand, ); else assert.equal( viewGame(done, 'ecaz').ambassadorInsights[0].traitor, initial.players[1].traitors[0], ); } for (const player of done.players) assert.equal( player.reserves + player.tanks + Object.values(player.forces).reduce((a, b) => a + b, 0), 20, ); } }); void test('the Tleilaxu force return is offered and declined when the beneficiary has nothing in the Tanks', () => { for (const difficulty of DIFFICULTIES) { const entered = enter(fixture('tleilaxu')); const offered = choose(entered, difficulty).next; assert.equal(offered.pendingAmbassador?.stage, 'revival'); assert.equal(offered.decision?.kind, 'ecazAmbassador'); const { action, next } = choose(offered, difficulty); assert.equal(action.decline, true); assert.equal(next.pendingAmbassador, null); assert.equal( next.ecazAmbassadors!.tokens.find((t) => t.effect === 'tleilaxu')!.zone, 'used', ); assert.equal(next.players[0].spice, 10); assert.equal(next.players[0].tanks, 0); } }); void test('Bene Gesserit copies choose only supported projected effects and complete without another acknowledgement', () => { for (const difficulty of DIFFICULTIES) { const entered = enter(fixture('beneGesserit')); const copying = choose(entered, difficulty).next; assert.equal(copying.pendingAmbassador?.stage, 'copy'); const view = botView(copying, 'ecaz', difficulty); const action = botActions(view)[0]; assert.ok( view.ambassadorEntry!.copies.some( (option) => !option.blocked && option.effect === action.effect, ), ); const done = finish(copying, difficulty); assert.equal( done.ecazAmbassadors!.tokens.find((t) => t.effect === 'beneGesserit')! .zone, 'removed', ); } }); void test('an unavailable own Ixian effect can be assigned to an eligible ally whose own bot selects its private card', () => { for (const difficulty of DIFFICULTIES) { const g = fixture('ixians'); g.players[0].ally = 'ally'; g.players[2].ally = 'ecaz'; g.deck.push(...g.players[0].hand); g.players[0].hand = []; const entered = enter(g); const { action, next } = choose(entered, difficulty); assert.equal(action.beneficiary, 'ally'); assert.equal(next.decision?.player, 'ally'); assert.deepEqual(viewGame(next, 'ecaz').ambassadorEntry!.cards, []); assert.deepEqual(botActions(botView(next, 'ecaz', difficulty)), []); const done = finish(next, difficulty); assert.equal(done.players[2].hand.length, 2); assert.equal(done.players[0].hand.length, 0); } }); void test('card policies respect projected blocked IDs, CHOAM permits zero cards, and Ix chooses exactly one', () => { for (const difficulty of DIFFICULTIES) for (const effect of ['choam', 'ixians'] as const) { const cards = choose(enter(fixture(effect)), difficulty).next; const view = botView(cards, 'ecaz', difficulty); const worthless = view.players[0].hand!.find( (c) => c.kind === 'worthless', )!; view.ambassadorEntry!.cards.find( (option) => option.card === worthless.id, )!.blocked = 'Reserved card'; const action = botActions(view)[0]; assert.ok(Array.isArray(action.cards)); assert.ok(!action.cards.includes(worthless.id)); assert.equal(action.cards.length, effect === 'choam' ? 0 : 1); assert.doesNotThrow(() => applyAction(cards, 'ecaz', action)); } }); void test('entry policy is independent of opponent hand identities and unprojected balances', () => { for (const difficulty of DIFFICULTIES) { const g = enter(fixture('choam')); const first = botView(g, 'ecaz', difficulty); const changed = structuredClone(g); changed.players[1].spice = 999; changed.players[1].hand = baseDeck().slice(-4); const second = botView(changed, 'ecaz', difficulty); assert.equal(first.players[1].spice, undefined); assert.equal(second.players[1].hand, undefined); assert.deepEqual(botActions(first), botActions(second)); const empty: GameView = structuredClone(first); empty.ambassadorEntry = null; assert.deepEqual(botActions(empty), []); } });