Files
Dune/tests/fixture-caladan-victory.ts
T
Quintine 28ba1618ba Implement Homeworld mechanics through Caladan reinforcement checkpoint
Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
2026-09-10 08:12:26 +10:00

260 lines
7.1 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeHomeworldGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { baseDeck, ixDeck } from '../game/cards';
import {
homeworldContext,
homeworldGameIntegrity,
} from '../game/homeworld-game';
import { quoteHomeworldCustody } from '../game/homeworld-custody';
export const victoryPlayer = (g: Game, id: string) =>
g.players.find((player) => player.id === id)!;
export const victoryReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
/** Genuine setup and physical cards; only the later phase and force positions
* are staged. Optional Tleilaxu uses its actual Ix setup, not a faction swap. */
export function caladanVictoryFixture(
options: { advanced?: boolean; tleilaxu?: boolean; native?: number } = {},
): Game {
let g = createGame(
'CALADANVICTORY',
newPlayer('a', 'Atreides', 'atreides'),
options.advanced ?? false,
options.tleilaxu ? ['ix'] : [],
);
joinGame(g, newPlayer('g', 'Guild', 'guild'));
if (options.tleilaxu) joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g = applyAction(g, 'a', { type: 'homeworlds', enabled: true });
for (const player of g.players)
g = applyAction(g, player.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let n = 0; g.status === 'setup' && n < 100; n++) {
let next: Game | undefined;
for (const player of g.players) {
const view = viewGame(g, player.id);
view.players.find((seat) => seat.id === player.id)!.bot = 'Easy';
const command = botActions(view)[0];
if (command) {
next = applyAction(g, player.id, command);
break;
}
}
assert.ok(next, 'Genuine Homeworld setup must supply an action.');
g = next;
}
assert.equal(g.status, 'playing');
for (const player of g.players) {
g.deck.push(...player.hand.splice(0));
player.spice = 20;
}
Object.assign(g, {
phase: 6,
turn: 2,
storm: 18,
phaseOpening: null,
response: null,
decision: null,
active: 'a',
ready: [],
order: g.players.map((player) => player.id),
});
positionVictoryArmy(g, 'a', options.native ?? 6, 3);
positionVictoryArmy(g, 'g', 17, 3);
if (options.tleilaxu) positionVictoryArmy(g, 't', 20, 0);
victoryInventory(g);
return g;
}
export function positionVictoryArmy(
g: Game,
id: string,
native: number,
battleForces: number,
tanks = 0,
) {
const player = victoryPlayer(g, id);
assert.equal(player.elites, undefined);
const rest = 20 - native - battleForces - tanks;
assert.ok(rest >= 0);
Object.assign(player, {
reserves: native,
tanks,
forces: {
...(battleForces ? { 'hagga_basin:12': battleForces } : {}),
...(rest ? { 'polar_sink:0': rest } : {}),
},
});
homeworldGameIntegrity(g);
}
/** A scenario invasion conserves exact native/visitor custody. Actual shipment
* availability and its phase-five reactions are outside these battle tests. */
export function positionVictoryInvader(
g: Game,
id: string,
destination: string,
amount: number,
) {
const quote = quoteHomeworldCustody(
homeworldContext(g),
g.homeworlds!.custody!,
[
{
homeworld: `homeworld:${victoryPlayer(g, id).faction}`,
player: id,
withdraw: { normal: amount, elite: 0 },
deposit: { normal: 0, elite: 0 },
},
{
homeworld: destination,
player: id,
withdraw: { normal: 0, elite: 0 },
deposit: { normal: amount, elite: 0 },
},
],
);
g.homeworlds!.custody = quote.state;
for (const player of quote.players)
victoryPlayer(g, player.id).reserves = player.reserves;
victoryInventory(g);
}
export function holdVictoryCard(g: Game, player: string, kind: string): string {
const index = g.deck.findIndex(
(card) => card.kind === kind || card.effect === kind,
);
assert.ok(index >= 0, `An actual ${kind} must be available.`);
const [card] = g.deck.splice(index, 1);
victoryPlayer(g, player).hand.push(card);
return card.id;
}
export function prepareVictoryBattle(
state: Game,
territory = 'hagga_basin',
attacker = 'a',
defender = 'g',
): Game {
let g = applyAction(state, attacker, {
type: 'chooseBattle',
territory,
target: defender,
});
for (let n = 0; n < 40; n++) {
if (g.response) {
const responder = g.players.find(
(player) => !g.response!.passed.includes(player.id),
)!;
g = applyAction(g, responder.id, { type: 'passResponse' });
} else if (g.battle?.preparation) {
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
} else if (g.decision) {
assert.equal(g.decision.kind, 'fullPlanOffer');
g = applyAction(g, g.decision.player, {
type: 'decision',
decline: true,
});
} else break;
}
assert.ok(g.battle && !g.battle.preparation && !g.decision && !g.response);
return g;
}
export function commitVictoryPlans(
state: Game,
options: {
a?: Partial<Action>;
g?: Partial<Action>;
} = {},
): Game {
let g = state;
const atreides = g.players.find((player) => player.faction === 'atreides')!;
const guildSeat = g.players.find((player) => player.faction === 'guild')!;
const a = atreides.leaders.reduce((best, leader) =>
leader.strength > best.strength ? leader : best,
);
const guild = guildSeat.leaders.reduce((best, leader) =>
leader.strength < best.strength ? leader : best,
);
g = applyAction(g, atreides.id, {
type: 'battlePlan',
leader: a.id,
dial: 1,
support: g.advanced ? 1 : 0,
...options.a,
});
g = applyAction(g, guildSeat.id, {
type: 'battlePlan',
leader: guild.id,
dial: 0,
support: 0,
...options.g,
});
return g;
}
export function finishVictoryCalls(
state: Game,
calls: Record<string, boolean> = {},
): Game {
let g = state;
for (let n = 0; g.battle && n < 10; n++) {
const view = viewGame(g, g.battle!.attacker).battle!;
const voter = view.traitorVoters.find(
(id) => g.battle!.traitorCalls[id] === undefined,
);
assert.ok(
voter,
'The revealed battle must have an outstanding real traitor voter.',
);
g = applyAction(g, voter, {
type: 'traitorCall',
call: calls[voter] ?? false,
});
}
assert.equal(g.battle, null);
return g;
}
export function victoryInventory(g: Game) {
homeworldGameIntegrity(g);
for (const player of g.players) {
const visitors = Object.values(g.homeworlds!.custody!.visitors).reduce(
(sum, world) =>
sum + (world[player.id]?.normal ?? 0) + (world[player.id]?.elite ?? 0),
0,
);
assert.equal(
player.reserves +
player.tanks +
Object.values(player.forces).reduce((a, b) => a + b, 0) +
visitors,
20,
);
assert.ok(player.spice >= 0);
}
const cards = [
...g.deck,
...g.discard,
...g.players.flatMap((player) => player.hand),
];
assert.deepEqual(
cards.map((card) => card.id).sort(),
[...baseDeck(), ...(g.expansions.includes('ix') ? ixDeck() : [])]
.map((card) => card.id)
.sort(),
);
}