import test from 'node:test'; import assert from 'node:assert/strict'; import { createGame, newPlayer, applyAction, type Game } from '../game/engine'; import { baseDeck, spiceDeck } from '../game/cards'; import { quoteMovementPhaseStart, quoteAdvisorBattleOffer, MovementPhaseQuoteError, } from '../game/movement-phase-quote'; import { createAmbassadors } from '../game/ecaz-ambassadors'; const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; function fixture(advanced = true) { const g = createGame( 'MOVEMENTQUOTE', newPlayer('e', 'Emperor', 'emperor'), advanced, ); g.players.push(newPlayer('b', 'BG', 'beneGesserit')); Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18, order: ['e', 'b'], spiceDeck: spiceDeck(), spiceDiscard: [[], []], }); for (const p of g.players) Object.assign(p, { forces: {}, hand: [], advisors: {}, spice: 20 }); return g; } void test('every 2–6-player order initializes a detached complete movement queue', () => { for (let count = 2; count <= 6; count++) { const g = fixture(false); for (const faction of ['fremen', 'atreides', 'guild', 'harkonnen'] as const) if (g.players.length < count) g.players.push(newPlayer(faction, faction, faction)); g.order = g.players.map((p) => p.id).reverse(); const before = structuredClone(g); const q = quoteMovementPhaseStart(g); assert.equal(q.kind, 'initialize'); if (q.kind === 'initialize') { assert.deepEqual(q.remaining, g.order); assert.equal(q.active, g.order[0]); assert.equal(q.decision, null); q.remaining.pop(); } assert.deepEqual(g, before); } }); void test('Ix opening validates only current seating and stops before piles, board or private state are read', () => { const g = fixture(); for (const key of ['spiceDeck', 'spiceDiscard', 'storm'] as const) Object.defineProperty(g, key, { get() { throw new Error('past opening boundary'); }, }); for (const p of g.players) for (const key of ['forces', 'hand', 'leaders'] as const) Object.defineProperty(p, key, { get() { throw new Error('past opening boundary'); }, }); assert.deepEqual(quoteMovementPhaseStart(g, true), { kind: 'opening', phaseOpening: { passed: [], initialize: true }, }); g.order = ['e', 'e']; assert.throws( () => quoteMovementPhaseStart(g, true), MovementPhaseQuoteError, ); }); void test('advisor releases and current battle choice use existing public board positions without arrival effects', () => { const g = fixture(); player(g, 'b').forces = { 'arrakeen:10': 2, 'carthag:11': 1, 'sietch_tabr:14': 1, }; player(g, 'b').advisors = { arrakeen: {}, carthag: { lockedTurn: 2 }, sietch_tabr: {}, tueks_sietch: {}, }; player(g, 'e').forces = { 'arrakeen:10': 3, 'carthag:11': 2 }; const before = structuredClone(g), offer = quoteAdvisorBattleOffer(g); assert.deepEqual(offer.territories, ['arrakeen']); assert.deepEqual(offer.released, [ { player: 'b', territory: 'sietch_tabr' }, { player: 'b', territory: 'tueks_sietch' }, ]); const q = quoteMovementPhaseStart(g); assert.equal(q.kind, 'initialize'); if (q.kind === 'initialize') { assert.deepEqual(q.decision, { kind: 'advisorBattle', player: 'b', territories: ['arrakeen'], }); assert.equal(q.active, null); } assert.deepEqual(g, before); player(g, 'b').ally = 'e'; player(g, 'e').ally = 'b'; assert.deepEqual(quoteAdvisorBattleOffer(g).territories, []); }); void test('advisor opportunity precedes Guild timing; otherwise Guild receives the exact next/following order', () => { const g = fixture(); g.players.push(newPlayer('g', 'Guild', 'guild')); g.order = ['g', 'e', 'b']; let q = quoteMovementPhaseStart(g); assert.equal(q.kind, 'initialize'); if (q.kind === 'initialize') assert.deepEqual(q.decision, { kind: 'guildTiming', player: 'g', next: 'g', following: 'e', }); g.order = ['e', 'g', 'b']; q = quoteMovementPhaseStart(g); if (q.kind === 'initialize') assert.deepEqual(q.decision, { kind: 'guildTiming', player: 'g', next: 'e', following: 'e', }); player(g, 'b').forces = { 'arrakeen:10': 1 }; player(g, 'b').advisors = { arrakeen: {} }; player(g, 'e').forces = { 'arrakeen:10': 1 }; q = quoteMovementPhaseStart(g); if (q.kind === 'initialize') assert.equal(q.decision?.kind, 'advisorBattle'); g.advanced = false; q = quoteMovementPhaseStart(g); if (q.kind === 'initialize') { assert.equal(q.decision, null); assert.equal(q.active, 'e'); } }); void test('Atreides refill is a pure source prerequisite without a sampled card or random draw', () => { const g = fixture(); player(g, 'e').faction = 'atreides'; g.spiceDeck = []; g.spiceDiscard = [ [ { worm: true, thumper: true }, { worm: true, suppressed: true }, ], [spiceDeck()[0]], ]; const before = structuredClone(g), oldRandom = Math.random; try { Math.random = () => { throw new Error('quote must not sample'); }; const q = quoteMovementPhaseStart(g); assert.equal(q.kind, 'initialize'); if (q.kind === 'initialize') assert.deepEqual(q.spice, { owner: 'e', refill: true, hasCards: true }); } finally { Math.random = oldRandom; } assert.deepEqual(g, before); g.spiceDiscard = [[{ worm: true, thumper: true }], []]; let q = quoteMovementPhaseStart(g); if (q.kind === 'initialize') assert.deepEqual(q.spice, { owner: 'e', refill: true, hasCards: false }); g.spiceDeck = [spiceDeck()[0]]; g.spiceDiscard = null as never; q = quoteMovementPhaseStart(g); if (q.kind === 'initialize') assert.deepEqual(q.spice, { owner: 'e', refill: false, hasCards: true }); }); void test('without Atreides, movement start never reads a spice pile or an opponent hand', () => { const g = fixture(); for (const key of ['spiceDeck', 'spiceDiscard'] as const) Object.defineProperty(g, key, { get() { throw new Error('irrelevant pile'); }, }); for (const p of g.players) Object.defineProperty(p, 'hand', { get() { throw new Error('private hand'); }, }); const q = quoteMovementPhaseStart(g); assert.equal(q.kind, 'initialize'); if (q.kind === 'initialize') assert.deepEqual(q.spice, { owner: null, refill: false, hasCards: false }); }); void test('malformed seating, current board, advisor owner and refill sources reject without mutation', () => { const base = fixture(); player(base, 'e').faction = 'atreides'; const cases: [string, (g: Game) => void][] = [ [ 'duplicate order', (g) => { g.order = ['e', 'e']; }, ], [ 'missing order', (g) => { g.order = ['e']; }, ], [ 'unknown seat', (g) => { g.order = ['e', 'missing']; }, ], [ 'wrong phase', (g) => { g.phase = 4; }, ], [ 'zero turn', (g) => { g.turn = 0; }, ], [ 'negative forces', (g) => { player(g, 'b').forces = { 'arrakeen:10': -1 }; }, ], [ 'unknown ally', (g) => { player(g, 'b').ally = 'missing'; }, ], [ 'invalid location', (g) => { player(g, 'e').forces = { 'arrakeen:9': 1 }; }, ], [ 'invalid spice deck', (g) => { g.spiceDeck = [{ territory: 'arrakeen', amount: 100, sector: 10 }]; }, ], [ 'invalid refill cardinality', (g) => { g.spiceDeck = []; g.spiceDiscard = [[]] as never; }, ], [ 'invalid refill card', (g) => { g.spiceDeck = []; g.spiceDiscard = [[{ worm: false } as never], []]; }, ], ]; for (const [label, mutate] of cases) { const g = structuredClone(base); mutate(g); const before = structuredClone(g); assert.throws( () => quoteMovementPhaseStart(g), MovementPhaseQuoteError, label, ); assert.deepEqual(g, before, label); } }); void test('real Ecaz completion initializes once with Atreides preview and advisor choice through JSON recovery', () => { let g = fixture(); g.phase = 4; player(g, 'e').faction = 'ecaz'; g.players.push(newPlayer('a', 'Atreides', 'atreides')); g.order = ['e', 'b', 'a']; g.ecazAmbassadors = createAmbassadors(() => 0); g.deck = baseDeck(); const counter = g.deck.splice( g.deck.findIndex((c) => c.effect === 'karama'), 1, )[0]; player(g, 'e').hand = [counter]; player(g, 'b').forces = { 'arrakeen:10': 1 }; player(g, 'b').advisors = { arrakeen: {} }; player(g, 'e').forces = { 'arrakeen:10': 1 }; for (const p of g.players) { p.shipped = true; p.moved = 2; } for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); assert.equal(g.decision?.kind, 'ecazPlacement'); g = JSON.parse(JSON.stringify(g)); const expected = quoteMovementPhaseStart({ ...g, phase: 5 }); g = applyAction(g, 'e', { type: 'decision', decline: true }); assert.equal(g.phase, 5); assert.equal(g.response?.kind, 'atreidesSpice'); assert.equal(g.decision?.kind, 'advisorBattle'); assert.equal(expected.kind, 'initialize'); if (expected.kind === 'initialize') { assert.deepEqual(g.movementRemaining, expected.remaining); assert.deepEqual(g.decision, expected.decision); } assert.ok(g.players.every((p) => !p.shipped && p.moved === 0)); assert.equal(g.pendingAmbassador ?? null, null); assert.equal(g.pendingTerrorEntry ?? null, null); });