import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, type Game, type GameView, } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { baseDeck } from '../game/cards'; import { createAmbassadors, placeAmbassador, copiedAmbassadorEffects, } from '../game/ecaz-ambassadors'; import { createDukeVidal, acquireDuke } from '../game/duke-vidal'; function fixture(advanced = false, allied = false) { const g = createGame( 'ECAZDUKEBOTS', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, ); g.players.push( newPlayer('in', 'Entrant', 'atreides'), newPlayer('al', 'Emperor', 'emperor'), ); Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'in', order: ['in', 'ec', 'al'], movementRemaining: ['in', 'ec', 'al'], deck: baseDeck(), dukeVidal: createDukeVidal(), }); for (const p of g.players) Object.assign(p, { hand: [], forces: {}, reserves: 20, spice: 20, traitors: [], traitorChoices: [], }); if (allied) { g.players[0].ally = 'al'; g.players[2].ally = 'ec'; } // Construct the existing placed-component checkpoint; the entry and every bot // resolution below use actual public actions, never a manually built offer. const inventory = createAmbassadors(() => 0); const token = inventory.tokens.find((t) => t.effect === 'ecaz')!; g.ecazAmbassadors = placeAmbassador(inventory, token.id, { turn: 1, availableSpice: 20, destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true, }, }).state; return g; } function enter(g: Game) { return applyAction(g, 'in', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, }); } function botView(g: Game, level: (typeof DIFFICULTIES)[number], id = 'ec') { const view = viewGame(g, id); view.players.find((p) => p.id === id)!.bot = level; return view; } function action(g: Game, level: (typeof DIFFICULTIES)[number]) { const view = botView(g, level), before = structuredClone(view); const actions = botActions(view); assert.equal(actions.length, 1); assert.deepEqual(view, before, 'bot policy does not mutate its projection'); assert.equal(actions[0].event, g.pendingAmbassador!.event); return actions[0]; } for (const level of DIFFICULTIES) void test(`${level} acquires for Ecaz in Basic/Advanced when an existing alliance rules out a new offer`, () => { for (const advanced of [false, true]) for (const allied of [false, true]) { const initial = fixture(advanced, allied); if (!allied) { initial.players[1].ally = 'al'; initial.players[2].ally = 'in'; } const g = enter(initial); assert.deepEqual(viewGame(g, 'ec').ambassadorEntry!.dukeAcquisition, { blocked: null, }); const selected = action(g, level); assert.deepEqual(selected, { type: 'decision', event: g.pendingAmbassador!.event, trigger: true, beneficiary: 'ec', choice: 'duke', }); const done = applyAction( JSON.parse(JSON.stringify(g)) as Game, 'ec', selected, ); assert.equal(done.pendingAmbassador, null); assert.equal(done.dukeVidal!.controller, 'ec'); assert.equal(done.dukeVidal!.source, 'ecaz'); assert.equal(done.dukeVidal!.acquiredTurn, 2); assert.deepEqual(done.dukeVidal!.leader, g.dukeVidal!.leader); assert.equal( done.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone, 'supply', ); assert.deepEqual( done.ecazAmbassadors!.cohort, g.ecazAmbassadors!.cohort, ); assert.deepEqual( done.players, g.players, 'no force, spice, hand, alliance or native-leader changes', ); assert.equal(done.active, g.active); assert.deepEqual(done.movementRemaining, g.movementRemaining); } }); const unavailable: [string, (g: Game) => void][] = [ [ 'Duke in Tanks', (g) => { g.dukeVidal!.leader.dead = true; g.dukeVidal!.leader.deaths = 1; }, ], [ 'Advanced Harkonnen table remains gated', (g) => { g.advanced = true; g.players[2] = newPlayer('al', 'Harkonnen', 'harkonnen'); Object.assign(g.players[2], { hand: [], forces: {}, reserves: 20, spice: 20, traitors: [], }); }, ], ]; for (const [name, change] of unavailable) void test(`all four profiles leave the token in place when already allied and ${name}`, () => { const state = fixture(); change(state); state.players[0].ally = 'al'; state.players[2].ally = 'ec'; const g = enter(state); assert.ok(viewGame(g, 'ec').ambassadorEntry!.dukeAcquisition!.blocked); for (const level of DIFFICULTIES) { const selected = action(g, level); assert.deepEqual(selected, { type: 'decision', event: g.pendingAmbassador!.event, decline: true, }); const done = applyAction(g, 'ec', selected); assert.equal(done.pendingAmbassador, null); assert.deepEqual(done.dukeVidal, g.dukeVidal); assert.deepEqual(done.ecazAmbassadors, g.ecazAmbassadors); } }); void test('only the actual owner gets the choice, and bots cannot acquire from an unrelated seat', () => { const g = enter(fixture(true, true)); for (const id of ['in', 'al']) for (const level of DIFFICULTIES) { assert.equal(viewGame(g, id).ambassadorEntry!.dukeAcquisition, null); assert.deepEqual(botActions(botView(g, level, id)), []); } }); void test('all profiles share an Ecaz-held Duke with its offered existing ally', () => { const state = fixture(false, true); state.dukeVidal = acquireDuke(state.dukeVidal!, 'ec', 1, 'ecaz'); const g = enter(state); for (const level of DIFFICULTIES) { const selected = action(g, level); assert.deepEqual(selected, { type: 'decision', event: g.pendingAmbassador!.event, trigger: true, beneficiary: 'al', choice: 'duke' }); const done = applyAction(g, 'ec', selected); assert.equal(done.dukeVidal!.controller, 'al'); assert.equal(done.dukeVidal!.source, 'ecaz'); assert.deepEqual(done.players, g.players); assert.equal(done.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!.zone, 'supply'); assert.deepEqual(done.ecazAmbassadors!.cohort, g.ecazAmbassadors!.cohort); } }); void test('all profiles rely on offered recipient descriptors, with safe decline when all are missing', () => { const g = enter(fixture(false, true)); for (const level of DIFFICULTIES) { const view = botView(g, level); Object.defineProperty(view, 'dukeVidal', { get() { throw Error('Policy read raw Duke custody'); }, }); assert.equal(botActions(view)[0].choice, 'duke'); view.ambassadorEntry!.dukeAcquisition = null; assert.equal(botActions(view)[0].choice, 'duke'); view.ambassadorEntry!.beneficiaries = []; assert.deepEqual(botActions(view), [ { type: 'decision', event: g.pendingAmbassador!.event, decline: true }, ]); } }); void test('private opponent hands, resources and traitors do not change acquisition choices or become projected evidence', () => { const g = enter(fixture(false, true)); const changed = structuredClone(g); changed.players[1].hand.push(changed.deck.shift()!); changed.players[1].spice = 1; changed.players[1].traitors = [changed.players[2].leaders[0].id]; for (const level of DIFFICULTIES) { assert.deepEqual(action(g, level), action(changed, level)); const view: GameView = botView(changed, level); assert.equal(view.players[1].hand, undefined); assert.equal(view.players[1].spice, undefined); assert.equal(view.players[1].traitors, undefined); assert.deepEqual(view.ambassadorEntry!.dukeAcquisition, { blocked: null }); } }); void test('enabling direct Ecaz acquisition never adds the reusable token to BG copy candidates', () => { const state = fixture().ecazAmbassadors!; assert.ok(!copiedAmbassadorEffects(state).includes('ecaz')); });