Initial commit

This commit is contained in:
Quintine committed 2026-09-08 07:37:52 +10:00
commit 8c709fb52f
771 files changed
+222983

No files matched your search

+321
View File
@@ -0,0 +1,321 @@
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);
});