Files
Dune/tests/movement-phase-quote.test.ts
2026-09-08 07:37:52 +10:00

322 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});