275 lines
7.8 KiB
TypeScript
275 lines
7.8 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 type { FactionId } from '../game/catalog';
|
|
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;
|
|
seatIds?: Record<string, string>;
|
|
extraSeats?: readonly { id: string; faction: FactionId }[];
|
|
} = {},
|
|
): Game {
|
|
const id = (key: string) => options.seatIds?.[key] ?? key;
|
|
let g = createGame(
|
|
'CALADANVICTORY',
|
|
newPlayer(id('a'), 'Atreides', 'atreides'),
|
|
options.advanced ?? false,
|
|
options.tleilaxu ? ['ix'] : [],
|
|
);
|
|
joinGame(g, newPlayer(id('g'), 'Guild', 'guild'));
|
|
if (options.tleilaxu) joinGame(g, newPlayer(id('t'), 'Tleilaxu', 'tleilaxu'));
|
|
// Audit-only final roster is fixed before setup signs public history.
|
|
for (const extra of options.extraSeats ?? [])
|
|
g.players.push(newPlayer(id(extra.id), extra.faction, extra.faction));
|
|
g = applyAction(g, id('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: id('a'),
|
|
ready: [],
|
|
order: g.players.map((player) => player.id),
|
|
});
|
|
positionVictoryArmy(g, id('a'), options.native ?? 6, 3);
|
|
positionVictoryArmy(g, id('g'), 17, 3);
|
|
if (options.tleilaxu) positionVictoryArmy(g, id('t'), 20, 0);
|
|
for (const extra of options.extraSeats ?? []) {
|
|
const player = victoryPlayer(g, id(extra.id));
|
|
Object.assign(player, { reserves: 20, tanks: 0, forces: {} });
|
|
}
|
|
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(),
|
|
);
|
|
}
|