import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame, type Game, type Action, } from '../game/engine'; import { baseDeck } from '../game/cards'; import type { FactionId } from '../game/catalog'; import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; import { acquireDuke, createDukeVidal } from '../game/duke-vidal'; import { richeseCards } from '../game/richese-cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const json = (g: Game): Game => JSON.parse(JSON.stringify(g)); function fixture(advanced = false, entrant: FactionId = 'emperor') { const g = createGame('ECAZALLY', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, [ 'ecaz', ]); g.players.push( newPlayer('in', 'Entrant', entrant), newPlayer('a', 'Other', 'atreides'), newPlayer('bg', 'Predictor', 'beneGesserit'), ); Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'in', order: ['in', 'ec', 'a', 'bg'], movementRemaining: ['in', 'ec', 'a', 'bg'], deck: baseDeck(), dukeVidal: createDukeVidal(), ready: [], }); for (const p of g.players) Object.assign(p, { forces: {}, reserves: 20, tanks: 0, spice: 20, hand: [], traitors: [], traitorChoices: [], }); // No reserve advisor option interferes with the tested original shipment. seat(g, 'bg').reserves = 0; seat(g, 'bg').tanks = 20; seat(g, 'bg').prediction = { faction: entrant, turn: 7 }; const state = createAmbassadors(() => 0); const token = state.tokens.find((t) => t.effect === 'ecaz')!; g.ecazAmbassadors = placeAmbassador(state, token.id, { turn: 1, availableSpice: 20, destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true, }, }).state; return g; } function enter(g = fixture()) { return applyAction(g, 'in', { type: 'ship', amount: 2, territory: 'arrakeen', sector: 10, }); } function proposal(g: Game): Action { return { type: 'decision', event: g.pendingAmbassador!.event, trigger: true, choice: 'alliance', beneficiary: 'ec', }; } function propose(g: Game) { return applyAction(g, 'ec', proposal(g)); } function answer(g: Game, accept: boolean) { let done = applyAction(json(g), 'in', { type: 'decision', event: g.pendingAmbassador!.event, accept, }); // These scenarios exercise alliance-only continuation, declining the optional loan. if (done.pendingAmbassador?.stage === 'loan') done = applyAction(done, 'ec', { type: 'decision', event: done.pendingAmbassador.event, loan: false, }); return done; } function reject(g: Game, id: string, action: Action) { const before = structuredClone(g); assert.throws(() => applyAction(g, id, action)); assert.deepEqual(g, before); } function conserved(g: Game) { for (const p of g.players) assert.equal( p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0), 20, ); const cards = [ ...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...g.players.flatMap((p) => p.hand), ].map((c) => c.id); assert.equal(new Set(cards).size, cards.length); } for (const advanced of [false, true]) void test(`${advanced ? 'Advanced grants the available Duke' : 'Basic can decline the loan'} while bilateral Ecaz alliance resumes the paid entrant`, () => { const initial = fixture(advanced); const token = initial.ecazAmbassadors!.tokens.find( (t) => t.effect === 'ecaz', )!.id; const duke = json(initial).dukeVidal; const entered = enter(initial); assert.equal(entered.decision?.player, 'ec'); assert.equal( viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked, null, ); const before = json(entered); const offered = propose(entered); assert.deepEqual(entered, before); assert.equal(offered.pendingAmbassador?.stage, 'allianceReply'); assert.equal(offered.pendingAmbassador?.beneficiary, 'in'); assert.deepEqual(offered.decision, { kind: 'ecazAmbassador', player: 'in', }); assert.equal(seat(offered, 'ec').ally, null); assert.equal( offered.ecazAmbassadors!.tokens.find((t) => t.id === token)!.zone, 'supply', ); assert.deepEqual( offered.ecazAmbassadors!.cohort, initial.ecazAmbassadors!.cohort, ); const done = answer(normalizeAutomaticGame(json(offered)), true); assert.equal(seat(done, 'ec').ally, 'in'); assert.equal(seat(done, 'in').ally, 'ec'); assert.equal(seat(done, 'ec').allySinceTurn, 2); assert.equal(seat(done, 'in').allySinceTurn, 2); if (advanced) { assert.equal(done.dukeVidal!.controller, 'in'); assert.equal(done.dukeVidal!.source, 'ally'); assert.deepEqual(done.dukeVidal!.leader, duke!.leader); } else assert.deepEqual(done.dukeVidal, duke); assert.equal(done.pendingAmbassador, null); assert.equal(done.decision, null); assert.equal(done.active, 'in'); assert.equal(done.phase, 5); assert.equal(seat(done, 'in').shipped, true); assert.equal(seat(done, 'in').spice, 18); assert.equal(seat(done, 'in').moved, 0); assert.deepEqual(seat(done, 'in').forces, { 'arrakeen:10': 2 }); assert.deepEqual(normalizeAutomaticGame(json(done)), json(done)); reject(done, 'in', { type: 'decision', event: offered.pendingAmbassador!.event, accept: true, }); conserved(done); }); void test('refusal consumes the chosen reusable trigger but does not form an alliance or fall back to Duke acquisition', () => { const entered = enter(); const done = answer(propose(entered), false); assert.equal(seat(done, 'ec').ally, null); assert.equal(seat(done, 'in').ally, null); assert.deepEqual(done.dukeVidal, entered.dukeVidal); assert.equal( done.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone, 'supply', ); assert.equal(done.pendingAmbassador, null); assert.equal(done.active, 'in'); const untouched = applyAction(entered, 'ec', { type: 'decision', event: entered.pendingAmbassador!.event, decline: true, }); assert.equal( untouched.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone, 'placed', ); }); void test('only Ecaz proposes and only the original entrant answers the exact event with a boolean', () => { const g = enter(); reject(g, 'in', proposal(g)); reject(g, 'ec', { ...proposal(g), event: 'stale' }); reject(g, 'ec', { ...proposal(g), beneficiary: 'in' }); const offered = propose(g); for (const id of ['ec', 'a', 'bg']) reject(offered, id, { type: 'decision', event: offered.pendingAmbassador!.event, accept: true, }); for (const accept of [undefined, 'yes', 1]) reject(offered, 'in', { type: 'decision', event: offered.pendingAmbassador!.event, accept, }); reject(offered, 'in', { type: 'decision', event: 'stale', accept: true }); const changed = json(offered); changed.turn++; reject(changed, 'in', { type: 'decision', event: offered.pendingAmbassador!.event, accept: true, }); }); for (const already of ['ec', 'in']) void test(`existing ${already === 'ec' ? 'Ecaz' : 'entrant'} alliance cannot be replaced by this Ambassador`, () => { const g = fixture(); seat(g, already).ally = 'a'; seat(g, 'a').ally = already; const entered = enter(g); assert.ok(viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked); reject(entered, 'ec', proposal(entered)); assert.equal( entered.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone, 'placed', ); }); void test('acceptance clears obsolete offers involving the new pair and leaves unrelated offers and private prediction intact', () => { const g = fixture(); g.allianceOffers = { ec: 'a', a: 'ec', in: 'bg', bg: 'a' }; g.ready = ['a']; const prediction = json(g).players.find((p) => p.id === 'bg')!.prediction; const done = answer(propose(enter(g)), true); assert.deepEqual(done.allianceOffers, { bg: 'a' }); assert.deepEqual(done.ready, []); assert.deepEqual(seat(done, 'bg').prediction, prediction); for (const id of ['ec', 'in', 'a']) assert.equal( viewGame(done, id).players.find((p) => p.id === 'bg')!.prediction, undefined, ); }); void test('Advanced new-ally Duke grant transfers available shared custody while a dead Duke never blocks the alliance', () => { for (const dead of [false, true]) { const g = fixture(true); g.dukeVidal = acquireDuke(g.dukeVidal!, 'a', 1, 'moritani'); if (dead) { g.dukeVidal.leader.dead = true; g.dukeVidal.leader.deaths = 1; } const duke = json(g).dukeVidal; const entered = enter(g); assert.equal( viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked, null, ); const done = answer(propose(entered), true); if (dead) assert.deepEqual(done.dukeVidal, duke); else { assert.equal(done.dukeVidal!.controller, 'in'); assert.deepEqual(done.dukeVidal!.leader, duke!.leader); } } const g = fixture(true); g.players[2] = newPlayer('a', 'Harkonnen', 'harkonnen'); const entered = enter(g); assert.ok(viewGame(entered, 'ec').ambassadorEntry!.dukeAcquisition!.blocked); assert.equal( viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked, null, ); assert.equal(seat(answer(propose(entered), true), 'ec').ally, 'in'); }); void test('changing BG private prediction and unrelated private hands does not change the public alliance offer', () => { const g = enter(); // Exchange custody between hidden hands without changing the public draw count. seat(g, 'bg').hand.push(g.deck.shift()!); const changed = json(g); seat(changed, 'bg').prediction = { faction: 'atreides', turn: 9 }; seat(changed, 'a').hand.push(seat(changed, 'bg').hand.pop()!); assert.deepEqual(g.deck, changed.deck); assert.deepEqual(g.players.flatMap(p => p.hand).map(c => c.id).sort(), changed.players.flatMap(p => p.hand).map(c => c.id).sort()); assert.deepEqual(viewGame(g, 'ec'), viewGame(changed, 'ec')); const offered = propose(g); assert.equal(viewGame(offered, 'a').ambassadorEntry!.allianceOffer, null); assert.deepEqual(viewGame(offered, 'a').decision, { kind: 'ecazAmbassador', player: 'in', }); }); for (const accept of [false, true]) void test(`actual worm ride resumes its next ride once after alliance ${accept ? 'acceptance' : 'refusal'}`, () => { const g = fixture(true, 'fremen'); Object.assign(g, { phase: 1, active: null, nexus: false, wormRides: ['hagga_basin'], decision: { kind: 'wormRide', player: 'in', territory: 'imperial_basin' }, }); seat(g, 'in').forces = { 'imperial_basin:10': 3, 'hagga_basin:11': 1 }; seat(g, 'in').reserves = 16; const arrived = applyAction(g, 'in', { type: 'decision', accept: true, territory: 'arrakeen', sector: 10, forces: { 'imperial_basin:10': 2 }, }); assert.equal(arrived.pendingAmbassador!.resume, 'wormRide'); const done = answer(propose(arrived), accept); assert.equal(done.phase, 1); assert.deepEqual(done.decision, { kind: 'wormRide', player: 'in', territory: 'hagga_basin', }); assert.deepEqual(done.wormRides, []); assert.deepEqual(seat(done, 'in').forces, seat(arrived, 'in').forces); assert.equal(seat(done, 'in').ally, accept ? 'ec' : null); conserved(done); }); void test('a genuine paid Box suspends the entrant reply and restores it after JSON without repeating the token trigger', () => { const g = fixture(); g.richeseCache = richeseCards(); const at = g.richeseCache.findIndex((c) => c.effect === 'nullentropyBox'); const box = g.richeseCache.splice(at, 1)[0]; seat(g, 'in').hand.push(box); for (const name of ['Shield', 'Maula Pistol']) { const i = g.deck.findIndex((c) => c.name === name); g.discard.push(g.deck.splice(i, 1)[0]); } const offered = propose(enter(g)); let saved = applyAction(offered, 'in', { type: 'card', card: box.id }); assert.equal(saved.decision?.kind, 'nullentropy'); assert.equal(saved.pendingNullentropy!.resume.decision?.player, 'in'); assert.deepEqual(saved.pendingAmbassador, offered.pendingAmbassador); saved = normalizeAutomaticGame(json(saved)); saved = applyAction(saved, 'in', { type: 'decision', event: saved.pendingNullentropy!.event, card: saved.discard[0].id, }); assert.equal(saved.decision?.kind, 'ecazAmbassador'); const done = answer(saved, true); assert.equal(seat(done, 'in').spice, 16); assert.equal(done.discard.filter((c) => c.id === box.id).length, 1); assert.deepEqual(done.ecazAmbassadors, offered.ecazAmbassadors); conserved(done); }); for (const level of DIFFICULTIES) void test(`${level} proposes from the authoritative offer and accepts its owned reply legally`, () => { const g = enter(); const owner = viewGame(g, 'ec'); owner.players.find((p) => p.id === 'ec')!.bot = level; const action = botActions(owner)[0]; assert.equal(action.choice, 'alliance'); const offered = applyAction(g, 'ec', action); const entrant = viewGame(offered, 'in'); entrant.players.find((p) => p.id === 'in')!.bot = level; const reply = botActions(entrant)[0]; assert.equal(reply.accept, true); const done = applyAction(offered, 'in', reply); assert.equal(seat(done, 'ec').ally, 'in'); assert.equal(done.pendingAmbassador?.stage, 'loan'); }); void test('saved alliance replies reject missing events, corrupted token custody and changed entrant bindings before any answer', () => { const offered = propose(enter()); const changes: [string, (g: Game) => void][] = [ [ 'empty event', (g) => { g.pendingAmbassador!.event = ''; }, ], [ 'missing event', (g) => { Reflect.deleteProperty(g.pendingAmbassador!, 'event'); }, ], [ 'missing entry', (g) => { g.pendingAmbassador = null; }, ], [ 'missing decision', (g) => { g.decision = null; }, ], [ 'foreign decision owner', (g) => { g.decision!.player = 'a'; }, ], [ 'changed entrant', (g) => { g.pendingAmbassador!.entrant = 'a'; }, ], [ 'foreign Ecaz owner', (g) => { g.pendingAmbassador!.owner = 'a'; }, ], [ 'missing token', (g) => { g.pendingAmbassador!.token = 'missing'; }, ], [ 'returned token replaced on board', (g) => { const token = g.ecazAmbassadors!.tokens.find( (t) => t.effect === 'ecaz', )!; token.zone = 'placed'; token.location = 'arrakeen'; }, ], [ 'returned token retains location', (g) => { g.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.location = 'arrakeen'; }, ], [ 'foreign token effect', (g) => { g.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.effect = 'guild'; }, ], [ 'crossed phase-five worm continuation', (g) => { g.pendingAmbassador!.resume = 'wormRide'; }, ], ]; for (const [name, change] of changes) { const malformed = json(offered); change(malformed); const before = structuredClone(malformed); assert.throws(() => normalizeAutomaticGame(malformed), name); assert.deepEqual( malformed, before, `${name}: normalization must not mutate`, ); reject(malformed, 'in', { type: 'decision', event: offered.pendingAmbassador!.event, accept: true, }); } }); void test('a real Box-suspended alliance reply cannot outlive its entry or restore a different entrant', () => { const g = fixture(); g.richeseCache = richeseCards(); const at = g.richeseCache.findIndex((c) => c.effect === 'nullentropyBox'); const box = g.richeseCache.splice(at, 1)[0]; seat(g, 'in').hand.push(box); for (const name of ['Shield', 'Maula Pistol']) { const index = g.deck.findIndex((c) => c.name === name); g.discard.push(g.deck.splice(index, 1)[0]); } const saved = applyAction(propose(enter(g)), 'in', { type: 'card', card: box.id, }); assert.equal( saved.pendingNullentropy!.resume.decision!.kind, 'ecazAmbassador', ); assert.deepEqual(normalizeAutomaticGame(json(saved)), json(saved)); const changes: [string, (g: Game) => void][] = [ [ 'missing suspended parent', (g) => { g.pendingAmbassador = null; }, ], [ 'missing suspended event', (g) => { Reflect.deleteProperty(g.pendingAmbassador!, 'event'); }, ], [ 'missing suspended decision', (g) => { g.pendingNullentropy!.resume.decision = null; }, ], [ 'foreign suspended decision owner', (g) => { g.pendingNullentropy!.resume.decision!.player = 'a'; }, ], ]; for (const [name, change] of changes) { const malformed = json(saved); change(malformed); const before = structuredClone(malformed); assert.throws(() => normalizeAutomaticGame(malformed), name); assert.deepEqual(malformed, before); reject(malformed, 'in', { type: 'decision', event: saved.pendingNullentropy!.event, card: saved.discard[0].id, }); } });