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 { createRicheseNoField, deployRicheseNoField, } from '../game/richese-no-field'; import type { FactionId } from '../game/catalog'; function fixture(faction: FactionId = 'ixians', copy = false) { const g = createGame('FAMBOTS', 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' : 'fremen'; const token = inventory.tokens.find((t) => t.effect === effect)!; inventory.cohort = [ token.id, ...inventory.tokens .filter( (t) => t.effect !== 'ecaz' && t.effect !== 'fremen' && 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: 'fremen', }); 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} relocates own multi-sector forces through the real event without spending ordinary movement`, () => { const initial = fixture(); army(initial, 'ec', { 'wind_pass:14': 4, 'wind_pass:15': 3 }); initial.players[0].shipped = true; initial.players[0].moved = 1; initial.hajr = ['ec']; const g = entered(initial, 'ec'), before = structuredClone(g); assert.equal(g.pendingAmbassador!.stage, 'move'); const a = selected(view(g, 'ec', level)); assert.equal(a.type, 'decision'); assert.equal(a.event, g.pendingAmbassador!.event); assert.equal(a.accept, undefined); assert.equal(a.decline, undefined); const done = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'ec', a); assert.equal(done.pendingAmbassador, null); assert.deepEqual(g, before); assert.equal(done.players[0].moved, 1); assert.equal(done.players[0].shipped, true); assert.deepEqual(done.hajr, ['ec']); assert.equal(done.active, 'in'); assert.deepEqual(done.movementRemaining, g.movementRemaining); assert.equal(done.players[1].reserves, g.players[1].reserves); assert.equal(done.players[1].forces['arrakeen:10'], 1); assert.equal( Object.values(done.players[0].forces).reduce( (n, amount) => n + amount, 0, ), 7, ); assert.throws(() => applyAction(done, 'ec', a)); }); void test(`${level} the ally chooses its own typed force allocation and Ecaz gets no relocation descriptor`, () => { const initial = fixture(); army(initial, 'al', { 'wind_pass:14': 4, 'wind_pass:15': 3 }); initial.players[2].elites = { reserves: 0, tanks: 0, revived: 0, forces: { 'wind_pass:14': 4, 'wind_pass:15': 3 }, }; const g = entered(initial, 'al'); for (const id of ['ec', 'in']) { assert.equal(viewGame(g, id).ambassadorEntry!.movement, null); assert.deepEqual(botActions(view(g, id, level)), []); } const a = selected(view(g, 'al', level)); assert.equal( Object.values(a.forces as Record).reduce( (n, amount) => n + amount, 0, ), Object.values(a.eliteForces as Record).reduce( (n, amount) => n + amount, 0, ), ); const done = applyAction(g, 'al', a); assert.equal(done.pendingAmbassador, null); assert.equal( Object.values(done.players[2].elites!.forces).reduce( (n, amount) => n + amount, 0, ), 7, ); assert.equal(done.players[2].moved, g.players[2].moved); }); void test(`${level} marker-only movement uses separate private custody and preserves zero-valued No-Field`, () => { const initial = fixture('richese'); const p = initial.players[2]; const state = createRicheseNoField(['zero', 'three', 'five']); p.noField = deployRicheseNoField(state, { tokenId: 'zero', controller: 'al', location: { territory: 'wind_pass', sector: 14 }, }); p.noFieldEvent = 'private-marker-event'; const g = entered(initial, 'al'), a = selected(view(g, 'al', level)); assert.deepEqual(a.forces, {}); assert.deepEqual(a.noField, { tokenId: 'zero', event: 'private-marker-event', }); assert.notEqual(a.event, 'private-marker-event'); const done = applyAction(g, 'al', a); assert.equal(done.pendingAmbassador, null); assert.deepEqual(done.players[2].forces, {}); assert.equal(done.players[2].reserves, 20); assert.deepEqual(done.players[2].noField!.tokens, p.noField.tokens); assert.equal( done.players[2].noField!.deployed!.location.territory, a.territory, ); }); } void test('all profiles filter blocked destinations before selection and omit stationary destination-sector forces', () => { const initial = fixture(); army(initial, 'ec', { 'wind_pass:14': 4, 'wind_pass:15': 3 }); const g = entered(initial, 'ec'); for (const level of DIFFICULTIES) { const v = view(g, 'ec', level); const source = v.ambassadorEntry!.movement!.sources[0]; source.destinations = source.destinations.map((d) => ({ ...d, blocked: d.territory === 'wind_pass' && d.sector === 15 ? null : 'Blocked test projection', })); const a = selected(v); assert.equal(a.territory, 'wind_pass'); assert.equal(a.sector, 15); assert.equal( (a.forces as Record)['wind_pass:15'], undefined, ); assert.doesNotThrow(() => applyAction(g, 'ec', a)); source.destinations.forEach((d) => { d.blocked = 'Blocked test projection'; }); assert.deepEqual( botActions(v), [], 'no invented decline or rejected destination when none is supported', ); } }); void test('bots preserve one source stronghold occupant when moving to another territory', () => { const initial = fixture(); army(initial, 'ec', { 'carthag:11': 5 }); const g = entered(initial, 'ec'); for (const level of DIFFICULTIES) { const a = selected(view(g, 'ec', level)); assert.equal((a.forces as Record)['carthag:11'], 4); const done = applyAction(g, 'ec', a); assert.equal(done.players[0].forces['carthag:11'], 1); } }); void test('private opponent state and deck order do not change a relocation candidate', () => { const initial = fixture(); army(initial, 'ec', { 'wind_pass:14': 4 }); const g = entered(initial, '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('BG copy explicitly considers Fremen and skips it without a group when another effect is available', () => { const initial = fixture('ixians', true); army(initial, 'ec', { 'wind_pass:14': 2 }); let g = enter(initial); 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 === 'fremen', ); const a = selected(v); assert.equal(a.effect, 'fremen'); const moving = applyAction(g, 'ec', a); const done = applyAction(moving, 'ec', selected(view(moving, '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', {}); assert.notEqual(selected(view(empty, 'ec', level)).effect, 'fremen'); } });