Files
Dune/tests/fixture-grumman-collection.ts
T

193 lines
5.5 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeHomeworldGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck, type Card } from '../game/cards';
import { botActions } from '../game/bots';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { placeTerror, type TerrorKind } from '../game/moritani-terror';
export const grummanPlayer = (g: Game, id: string) =>
g.players.find((p) => p.id === id)!;
export const grummanReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
/** Real Homeworld audit initialization and faction setup. Only the conserved
* pre-Collection board position is staged; no signed phase/arrival is fabricated.
* Runtime Moritani support here does not lift its public expansion-start gate. */
export function grummanCollectionFixture(
options: {
native?: number;
advanced?: boolean;
seatIds?: [string, string, string];
} = {},
): Game {
const [m, a, h] = options.seatIds ?? ['m', 'a', 'g'];
let g = createGame(
'GRUMMANCOLLECTION',
newPlayer(m, 'Moritani', 'moritani'),
options.advanced ?? false,
[],
);
joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen'));
g = applyAction(g, m, { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let n = 0; g.status === 'setup' && n < 60; n++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
g = next;
}
assert.equal(g.status, 'playing');
for (const p of g.players) {
g.deck.push(...p.hand.splice(0));
Object.assign(p, {
spice: 20,
forces: {},
reserves: 20,
tanks: 0,
shipped: true,
moved: 0,
});
}
const owner = grummanPlayer(g, m);
owner.reserves = options.native ?? 8;
owner.forces = { 'polar_sink:0': 20 - owner.reserves };
assert.ok(g.moritaniTerror);
const robbery = g.moritaniTerror.tokens.find(
(token) => token.kind === 'robbery',
)!;
g.moritaniTerror = placeTerror(g.moritaniTerror, robbery.id, 'arrakeen', 1);
Object.assign(g, {
phase: 5,
turn: 2,
active: a,
movementRemaining: [a],
order: [a, m, h],
ready: [],
phaseOpening: null,
response: null,
decision: null,
storm: 18,
spice: {},
});
grummanInventory(g);
return g;
}
export function enterGrummanCollection(g: Game): Game {
const before = grummanReload(g);
const next = applyAction(g, g.active!, { type: 'endMovement' });
assert.deepEqual(g, before);
assert.equal(next.phase, 7);
grummanInventory(next);
return next;
}
export function grummanToken(g: Game, kind: TerrorKind) {
return g.moritaniTerror!.tokens.find((token) => token.kind === kind)!;
}
export function addGrummanToken(
g: Game,
kind: TerrorKind = 'sabotage',
territory = 'arrakeen',
): Game {
assert.equal(g.decision?.kind, 'grummanCollection');
return applyAction(g, g.grummanCollection!.player, {
type: 'decision',
event: g.grummanCollection!.event,
mode: 'add',
token: grummanToken(g, kind).id,
territory,
});
}
export function holdGrummanCard(g: Game, player: string, kind: string): Card {
const index = g.deck.findIndex(
(card) => card.kind === kind || card.effect === kind,
);
assert.ok(index >= 0, `Missing ${kind}`);
const card = g.deck.splice(index, 1)[0];
grummanPlayer(g, player).hand.push(card);
return card;
}
/** Later movement checkpoint preserves the actual Collection receipt/stack.
* IDs derive from factions so SQL fixtures can bind seats before signing. */
export function stageGrummanArrival(g: Game): Game {
const next = grummanReload(g);
const entrant = next.players.find((p) => p.faction === 'atreides')!;
Object.assign(next, {
turn: g.turn + 1,
phase: 5,
active: entrant.id,
movementRemaining: [entrant.id],
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
entrant.shipped = false;
entrant.moved = 0;
grummanInventory(next);
return next;
}
export function enterGrummanStack(g: Game, amount = 1): Game {
return applyAction(g, g.active!, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount,
});
}
export function grummanReject(
g: Game,
id: string,
action: Action,
match: RegExp = /./,
): void {
const before = grummanReload(g);
assert.throws(() => applyAction(g, id, action), match);
assert.deepEqual(g, before);
}
export function grummanInventory(g: Game): void {
homeworldGameIntegrity(g);
for (const p of g.players)
assert.equal(
p.reserves +
p.tanks +
Object.values(p.forces).reduce((sum, n) => sum + n, 0),
20,
);
assert.deepEqual(
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((card) => card.id)
.sort(),
baseDeck()
.map((card) => card.id)
.sort(),
);
assert.equal(g.moritaniTerror!.tokens.length, 6);
assert.equal(
new Set(g.moritaniTerror!.tokens.map((token) => token.id)).size,
6,
);
assert.equal(
new Set(g.moritaniTerror!.tokens.map((token) => token.kind)).size,
6,
);
}