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, type Difficulty } from '../game/bot-profiles'; import { baseDeck } from '../game/cards'; import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; import type { FactionId } from '../game/catalog'; function fixture(faction: FactionId = 'ixians', copy = false) { const g = createGame('GAMBOTS', newPlayer('ec', 'Ecaz', 'ecaz'), true, [ 'ecaz', 'ix', 'choam', ]); g.players.push( newPlayer('in', 'Entrant', 'emperor'), newPlayer('al', 'Ally', faction), ); Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'in', order: ['in', 'ec', 'al'], movementRemaining: ['in', 'ec', 'al'], deck: baseDeck(), }); for (const p of g.players) Object.assign(p, { hand: [], forces: {}, elites: undefined, reserves: 20, tanks: 0, spice: 10, traitors: [], traitorChoices: [], }); g.players[0].ally = 'al'; g.players[2].ally = 'ec'; const inventory = createAmbassadors(() => 0); const effect = copy ? 'beneGesserit' : 'guild'; const token = inventory.tokens.find((t) => t.effect === effect)!; inventory.cohort = [ token.id, ...inventory.tokens .filter( (t) => t.effect !== 'ecaz' && t.effect !== 'guild' && t.id !== token.id, ) .slice(0, 4) .map((t) => t.id), ]; for (const t of inventory.tokens) { t.zone = t.effect === 'ecaz' || inventory.cohort.includes(t.id) ? 'supply' : 'pool'; t.location = null; } g.ecazAmbassadors = placeAmbassador(inventory, token.id, { turn: 1, availableSpice: 10, destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true, }, }).state; return g; } function army(g: Game, id: string, forces: Record) { const p = g.players.find((p) => p.id === id)!; p.forces = forces; p.reserves = 20 - Object.values(forces).reduce((n, amount) => n + amount, 0); } function enter(g: Game) { return applyAction(g, 'in', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10, }); } // Entry IDs are generated by the actual arrival; obtain and use one arrival only. function entered(g: Game, beneficiary: string, copy = false) { let out = enter(g); out = applyAction(out, 'ec', { type: 'decision', event: out.pendingAmbassador!.event, trigger: true, beneficiary, }); if (copy) out = applyAction(out, beneficiary, { type: 'decision', event: out.pendingAmbassador!.event, effect: 'guild', }); return out; } function view(g: Game, id: string, level: Difficulty) { const v = viewGame(g, id); v.players.find((p) => p.id === id)!.bot = level; return v; } function selected(v: GameView) { const before = structuredClone(v); const actions = botActions(v); assert.deepEqual(v, before); assert.equal(actions.length, 1); return actions[0]; } for (const level of DIFFICULTIES) { void test(`${level} uses a real Guild Ambassador reserve shipment without spending ordinary allowances`, () => { const initial = fixture(); initial.players[0].shipped = true; initial.players[0].moved = 1; initial.hajr = ['ec']; const g = entered(initial, 'ec'); assert.equal(g.pendingAmbassador!.stage, 'ship'); const a = selected(view(g, 'ec', level)); assert.equal(a.amount, 4); assert.equal(a.elite, 0); assert.equal(a.noField, undefined); assert.equal(a.event, g.pendingAmbassador!.event); const before = structuredClone(g); const done = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'ec', a); assert.deepEqual(g, before); assert.equal(done.pendingAmbassador, null); assert.equal(done.players[0].reserves, 16); assert.equal(done.players[0].spice, g.players[0].spice); assert.equal(done.players[0].shipped, true); assert.equal(done.players[0].moved, 1); assert.deepEqual(done.hajr, ['ec']); assert.equal(done.active, 'in'); assert.deepEqual(done.movementRemaining, g.movementRemaining); assert.equal(done.players[1].forces['arrakeen:10'], 1); assert.equal(done.players[1].reserves, g.players[1].reserves); assert.throws(() => applyAction(done, 'ec', a)); }); void test(`${level} allied elite-only reserves require canonical elite allocation and a private descriptor`, () => { const initial = fixture(); army(initial, 'al', { 'wind_pass:14': 17 }); initial.players[2].elites = { reserves: 3, tanks: 0, revived: 0, forces: {}, }; const g = entered(initial, 'al'); for (const id of ['ec', 'in']) { assert.equal(viewGame(g, id).ambassadorEntry!.shipment, null); assert.deepEqual(botActions(view(g, id, level)), []); } const a = selected(view(g, 'al', level)); assert.equal(a.amount, 3); assert.equal(a.elite, 3); const done = applyAction(g, 'al', a); assert.equal(done.pendingAmbassador, null); assert.equal(done.players[2].reserves, 0); assert.equal(done.players[2].elites!.reserves, 0); assert.equal( Object.values(done.players[2].elites!.forces).reduce((n, x) => n + x, 0), 3, ); assert.equal(done.players[2].spice, g.players[2].spice); }); void test(`${level} filters every blocked destination before scoring and explicitly sends zero if none remain`, () => { const g = entered(fixture(), 'ec'); const v = view(g, 'ec', level); const descriptor = v.ambassadorEntry!.shipment!; const legal = descriptor.destinations.find( (d) => d.territory === 'polar_sink', )!; assert.ok(legal); descriptor.destinations.forEach((d) => { d.blocked = d === legal ? null : 'Unresolved competing arrival'; }); const a = selected(v); assert.equal(a.territory, 'polar_sink'); assert.doesNotThrow(() => applyAction(g, 'ec', a)); descriptor.destinations.forEach((d) => { d.blocked = 'Unresolved competing arrival'; }); const zero = selected(v); assert.deepEqual(zero, { type: 'decision', event: g.pendingAmbassador!.event, amount: 0, }); const done = applyAction(g, 'ec', zero); assert.equal(done.pendingAmbassador, null); assert.deepEqual(done.players[0].forces, g.players[0].forces); assert.equal(done.players[0].reserves, g.players[0].reserves); assert.equal(done.players[0].shipped, g.players[0].shipped); assert.equal(done.active, 'in'); }); } void test('hidden opponent balances, cards, traitors and deck order do not change a Guild shipment candidate', () => { const g = entered(fixture(), 'ec'), changed = structuredClone(g); changed.players[2].hand.push(changed.deck.shift()!); changed.players[2].spice = 0; changed.players[2].traitors = [changed.players[1].leaders[0].id]; changed.deck.reverse(); for (const level of DIFFICULTIES) assert.deepEqual( selected(view(g, 'ec', level)), selected(view(changed, 'ec', level)), ); }); void test('mixed reserves enforce the elite minimum for Easy and the available maximum for other levels', () => { const initial = fixture(); army(initial, 'al', { 'wind_pass:14': 15 }); initial.players[2].elites = { reserves: 2, tanks: 0, revived: 0, forces: {}, }; const g = entered(initial, 'al'); for (const level of DIFFICULTIES) { const a = selected(view(g, 'al', level)); assert.equal(a.amount, 4); assert.equal(a.elite, level === 'Easy' ? 1 : 2); const done = applyAction(g, 'al', a); assert.equal(done.players[2].reserves, 1); assert.equal(done.players[2].elites!.reserves, 2 - Number(a.elite)); } }); void test('BG copy explicitly selects Guild and completes its real shipment at every level', () => { let g = enter(fixture('ixians', true)); g = applyAction(g, 'ec', { type: 'decision', event: g.pendingAmbassador!.event, trigger: true, beneficiary: 'ec', }); assert.equal(g.pendingAmbassador!.stage, 'copy'); for (const level of DIFFICULTIES) { const v = view(g, 'ec', level); v.ambassadorEntry!.copies = v.ambassadorEntry!.copies.filter( (c) => c.effect === 'guild', ); const copy = selected(v); assert.equal(copy.effect, 'guild'); const shipping = applyAction(g, 'ec', copy); const done = applyAction( shipping, 'ec', selected(view(shipping, 'ec', level)), ); assert.equal(done.pendingAmbassador, null); assert.equal( done.ecazAmbassadors!.tokens.find((t) => t.effect === 'beneGesserit')! .zone, 'removed', ); const empty = structuredClone(g); army(empty, 'ec', { 'wind_pass:14': 20 }); assert.notEqual(selected(view(empty, 'ec', level)).effect, 'guild'); } }); function advisorWindow() { const initial = fixture('beneGesserit'); initial.storm = 14; army(initial, 'in', { 'imperial_basin:10': 1 }); // A real move avoids the entrant's independent off-planet accompaniment. let g = applyAction(initial, 'in', { type: 'move', forces: { 'imperial_basin:10': 1 }, territory: 'arrakeen', sector: 10, }); g = applyAction(g, 'ec', { type: 'decision', event: g.pendingAmbassador!.event, trigger: true, beneficiary: 'ec', }); g = applyAction(g, 'ec', { type: 'decision', event: g.pendingAmbassador!.event, amount: 1, elite: 0, territory: 'wind_pass', sector: 15, }); assert.equal(g.decision?.kind, 'advisor'); assert.equal(g.decision.player, 'al'); return g; } void test('Guild Ambassador accompaniment candidates exclude the storm sector at every level', () => { const g = advisorWindow(); assert.equal(viewGame(g, 'ec').guildAmbassadorAdvisorChoices, null); assert.equal(viewGame(g, 'in').guildAmbassadorAdvisorChoices, null); for (const level of DIFFICULTIES) { const v = view(g, 'al', level); const before = structuredClone(v); const descriptor = v.spiritualAdvisorChoices ?? v.guildAmbassadorAdvisorChoices!; assert.ok( descriptor.choices.some( (c) => c.accompany && c.sector === 14 && c.blocked, ), ); assert.ok( descriptor.choices.some( (c) => c.accompany && c.sector === 15 && !c.blocked, ), ); const actions = botActions(v); assert.deepEqual(v, before); assert.ok(actions.length > 1); for (const a of actions) { if (a.accept) { assert.ok( descriptor.choices.some( (c) => !c.blocked && c.accompany === a.accompany && c.territory === a.territory && c.sector === a.sector, ), ); assert.notEqual(a.sector, 14); } assert.doesNotThrow(() => applyAction(g, 'al', a)); } const done = applyAction(g, 'al', actions[0]); assert.equal(done.pendingAmbassador, null); assert.equal(done.players[2].reserves, 19); assert.equal(done.players[1].moved, g.players[1].moved); assert.equal(done.active, 'in'); } }); void test('projected blocked accompaniment falls back to Polar Sink, then decline when every choice is blocked', () => { const g = advisorWindow(); for (const level of DIFFICULTIES) { const v = view(g, 'al', level); const choices = (v.spiritualAdvisorChoices ?? v.guildAmbassadorAdvisorChoices!).choices; // The policy consumes projected blocking regardless of its public cause. // Server-side competing-arrival rules have separate engine fixtures. for (const c of choices) if (c.accompany) c.blocked = 'Competing arrival is unavailable'; const actions = botActions(v); assert.equal(actions[0].accept, true); assert.equal(actions[0].accompany, false); assert.equal(actions[0].territory, 'polar_sink'); assert.equal(actions[0].sector, 0); const done = applyAction(g, 'al', actions[0]); assert.equal(done.players[2].forces['polar_sink:0'], 1); assert.equal(done.pendingAmbassador, null); for (const c of choices) c.blocked = 'No available reserve shipment'; assert.deepEqual(botActions(v), [{ type: 'decision', accept: false }]); assert.doesNotThrow(() => applyAction(g, 'al', botActions(v)[0])); } });