521 lines
17 KiB
TypeScript
521 lines
17 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeHomeworldGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { baseDeck } from '../game/cards';
|
|
import {
|
|
homeworldContext,
|
|
homeworldGameIntegrity,
|
|
} from '../game/homeworld-game';
|
|
import {
|
|
homeworldForceGroups,
|
|
quoteHomeworldCustody,
|
|
type HomeworldCustodyChange,
|
|
} from '../game/homeworld-custody';
|
|
|
|
const seat = (g: Game, id: string) =>
|
|
g.players.find((player) => player.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
const sum = (counts: Record<string, number>) =>
|
|
Object.values(counts).reduce((a, b) => a + b, 0);
|
|
|
|
function inventory(g: Game, supplementalCards = 0) {
|
|
homeworldGameIntegrity(g);
|
|
const groups = homeworldForceGroups(
|
|
homeworldContext(g),
|
|
g.homeworlds!.custody!,
|
|
);
|
|
for (const player of g.players) {
|
|
const visitors = groups
|
|
.filter((home) => home.native !== player.id)
|
|
.map((home) => home.forces[player.id] ?? { normal: 0, elite: 0 });
|
|
assert.equal(
|
|
player.reserves +
|
|
player.tanks +
|
|
sum(player.forces) +
|
|
visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0),
|
|
20,
|
|
);
|
|
if (player.elites)
|
|
assert.equal(
|
|
player.elites.reserves +
|
|
player.elites.tanks +
|
|
sum(player.elites.forces) +
|
|
visitors.reduce((n, pool) => n + pool.elite, 0),
|
|
player.faction === 'emperor' ? 5 : player.faction === 'fremen' ? 3 : 7,
|
|
);
|
|
}
|
|
const cards = [
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((player) => player.hand),
|
|
];
|
|
assert.equal(cards.length, baseDeck().length + supplementalCards);
|
|
assert.equal(new Set(cards.map((card) => card.id)).size, cards.length);
|
|
}
|
|
function setup(roster: FactionId[], advanced = false) {
|
|
let g = createGame(
|
|
'HOMEWORLDBATTLEENGINE',
|
|
newPlayer(roster[0], roster[0], roster[0]),
|
|
advanced,
|
|
);
|
|
for (const faction of roster.slice(1))
|
|
joinGame(g, newPlayer(faction, faction, faction));
|
|
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
|
|
for (const player of g.players)
|
|
g = applyAction(g, player.id, { type: 'ready' });
|
|
g = initializeHomeworldGameForAudit(g);
|
|
for (let step = 0; g.status === 'setup' && step < 30; step++) {
|
|
let next: Game | undefined;
|
|
for (const player of g.players) {
|
|
const view = viewGame(g, player.id);
|
|
view.players.find((owner) => owner.id === player.id)!.bot = 'Easy';
|
|
const action = botActions(view)[0];
|
|
if (action) {
|
|
next = applyAction(g, player.id, action);
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(next, 'Genuine audit setup must have a legal decision.');
|
|
g = next;
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
for (const player of g.players) g.deck.push(...player.hand.splice(0));
|
|
Object.assign(g, {
|
|
phase: 6,
|
|
phaseOpening: null,
|
|
response: null,
|
|
decision: null,
|
|
active: roster[0],
|
|
order: roster,
|
|
ready: [],
|
|
storm: 18,
|
|
});
|
|
inventory(g);
|
|
return g;
|
|
}
|
|
function custody(g: Game, changes: HomeworldCustodyChange[]) {
|
|
const quote = quoteHomeworldCustody(
|
|
homeworldContext(g),
|
|
g.homeworlds!.custody!,
|
|
changes,
|
|
);
|
|
g.homeworlds!.custody = quote.state;
|
|
for (const update of quote.players) {
|
|
const player = seat(g, update.id);
|
|
player.reserves = update.reserves;
|
|
if (player.elites) player.elites.reserves = update.eliteReserves;
|
|
}
|
|
inventory(g);
|
|
}
|
|
function invade(
|
|
g: Game,
|
|
player: string,
|
|
destination: string,
|
|
normal: number,
|
|
elite = 0,
|
|
) {
|
|
// Explicit conserved invasion position, not a claim that invasion shipment
|
|
// actions are open: withdraw the exact native pieces before depositing them.
|
|
custody(g, [
|
|
{
|
|
homeworld: `homeworld:${seat(g, player).faction}`,
|
|
player,
|
|
withdraw: { normal, elite },
|
|
deposit: { normal: 0, elite: 0 },
|
|
},
|
|
{
|
|
homeworld: destination,
|
|
player,
|
|
withdraw: { normal: 0, elite: 0 },
|
|
deposit: { normal, elite },
|
|
},
|
|
]);
|
|
}
|
|
function hold(g: Game, player: string, kind: string) {
|
|
const index = g.deck.findIndex((card) => card.kind === kind);
|
|
assert.ok(index >= 0);
|
|
const [card] = g.deck.splice(index, 1);
|
|
seat(g, player).hand.push(card);
|
|
return card.id;
|
|
}
|
|
function prepared(state: Game, actor: string, target: string, home: string) {
|
|
let g = applyAction(state, actor, {
|
|
type: 'chooseBattle',
|
|
territory: home,
|
|
target,
|
|
});
|
|
for (
|
|
let step = 0;
|
|
(g.response || g.battle?.preparation || g.decision) && step < 30;
|
|
step++
|
|
) {
|
|
if (g.response) {
|
|
const owner = g.players.find(
|
|
(player) => !g.response!.passed.includes(player.id),
|
|
)!;
|
|
g = applyAction(g, owner.id, { type: 'passResponse' });
|
|
} else if (g.battle?.preparation)
|
|
g = applyAction(g, g.battle.preparation.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
else {
|
|
assert.equal(g.decision!.kind, 'fullPlanOffer');
|
|
g = applyAction(g, g.decision!.player, {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
}
|
|
}
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.decision, null);
|
|
assert.equal(g.battle!.territory, home);
|
|
assert.equal(g.battle!.preparation ?? null, null);
|
|
inventory(g);
|
|
return g;
|
|
}
|
|
function commit(
|
|
g: Game,
|
|
player: string,
|
|
dial = 0,
|
|
extras: Partial<Action> = {},
|
|
) {
|
|
const leader =
|
|
seat(g, player).leaders.find((leader) => leader.strength === 1) ??
|
|
seat(g, player).leaders[0];
|
|
return applyAction(g, player, {
|
|
type: 'battlePlan',
|
|
dial,
|
|
support: 0,
|
|
leader: leader.id,
|
|
...extras,
|
|
});
|
|
}
|
|
function reject(g: Game, player: string, action: Action) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, player, action));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
|
|
function passAll(state: Game) {
|
|
let g = state;
|
|
for (let n = 0; g.response && n < 20; n++) {
|
|
const player = g.players.find((p) => !g.response!.passed.includes(p.id))!;
|
|
g = applyAction(reload(g), player.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(g.response, null);
|
|
return g;
|
|
}
|
|
function strongest(g: Game, id: string) {
|
|
return seat(g, id)
|
|
.leaders.slice()
|
|
.sort((a, b) => b.strength - a.strength)[0].id;
|
|
}
|
|
function actualIx(native: boolean, advanced: boolean) {
|
|
let g = setup(['ixians', 'atreides'], advanced);
|
|
const home = native ? 'homeworld:ixians' : 'homeworld:atreides';
|
|
if (native) invade(g, 'atreides', home, 2);
|
|
else invade(g, 'ixians', home, 6, 2);
|
|
const shield = hold(g, 'ixians', 'shield');
|
|
const index = g.deck.findIndex((card) => card.effect === 'karama');
|
|
assert.ok(index >= 0);
|
|
const [card] = g.deck.splice(index, 1);
|
|
seat(g, 'atreides').hand.push(card);
|
|
seat(g, 'ixians').spice = 10;
|
|
g = prepared(g, 'ixians', 'atreides', home);
|
|
g = commit(g, 'ixians', 6, {
|
|
leader: strongest(g, 'ixians'),
|
|
support: advanced ? 2 : 0,
|
|
defense: shield,
|
|
});
|
|
g = commit(reload(g), 'atreides');
|
|
const voter = native ? 'ixians' : 'atreides';
|
|
g = applyAction(g, voter, { type: 'traitorCall', call: false });
|
|
assert.equal(g.lastBattleContext?.winner, 'ixians');
|
|
if (g.decision?.kind === 'battleLosses') {
|
|
const choice = g.decision.options.findIndex(
|
|
(loss) => loss.normal === 4 && loss.elite === 2,
|
|
);
|
|
assert.ok(
|
|
choice >= 0,
|
|
'Actual dial six must allow four Suboids plus two Cyborgs.',
|
|
);
|
|
g = applyAction(reload(g), 'ixians', { type: 'decision', choice });
|
|
}
|
|
assert.equal(g.decision?.kind, 'ixSubstitution');
|
|
assert.equal(g.pendingIxSubstitution!.homeworld!.cyborgsLost, 2);
|
|
inventory(g);
|
|
return { g, home, shield, karama: card.id };
|
|
}
|
|
for (const native of [true, false])
|
|
for (const advanced of [true, false]) {
|
|
void test(`actual ${advanced ? 'Advanced' : 'Basic'} ${native ? 'native' : 'visitor'} Homeworld Ixian substitution survives declaration and cleanup reload`, () => {
|
|
const f = actualIx(native, advanced);
|
|
const original = reload(f.g);
|
|
const before = seat(original, 'ixians');
|
|
for (const level of DIFFICULTIES) {
|
|
const view = viewGame(reload(original), 'ixians');
|
|
view.players.find((p) => p.id === 'ixians')!.bot = level;
|
|
const actions = botActions(view);
|
|
assert.ok(
|
|
actions.length,
|
|
`${level} must supply an Ixian substitution choice.`,
|
|
);
|
|
for (const action of actions)
|
|
inventory(applyAction(reload(original), 'ixians', action));
|
|
}
|
|
reject(original, 'ixians', {
|
|
type: 'decision',
|
|
sources: { [`${f.home}:0`]: 1 },
|
|
recover: { [f.home]: 1 },
|
|
});
|
|
let g = applyAction(reload(original), 'ixians', {
|
|
type: 'decision',
|
|
sources: { [f.home]: 2 },
|
|
recover: { [f.home]: 2 },
|
|
});
|
|
assert.equal(g.response?.kind, 'ixSubstitution');
|
|
g = passAll(reload(g));
|
|
assert.equal(g.pendingIxSubstitution, null);
|
|
const after = seat(g, 'ixians');
|
|
assert.equal(after.tanks, before.tanks);
|
|
assert.equal(after.battleLosses, before.battleLosses);
|
|
assert.equal(after.elites!.tanks, before.elites!.tanks - 2);
|
|
assert.equal(
|
|
after.elites!.reserves,
|
|
before.elites!.reserves + (native ? 2 : 0),
|
|
);
|
|
assert.deepEqual(after.forces, before.forces);
|
|
assert.deepEqual(after.elites!.forces, before.elites!.forces);
|
|
if (!native)
|
|
assert.deepEqual(g.homeworlds!.custody!.visitors[f.home].ixians, {
|
|
normal: 0,
|
|
elite: 2,
|
|
});
|
|
assert.equal(g.decision?.kind, 'battleCards');
|
|
g = applyAction(reload(g), 'ixians', { type: 'decision', discard: [] });
|
|
assert.equal(
|
|
seat(g, 'ixians').hand.filter((card) => card.id === f.shield).length,
|
|
1,
|
|
);
|
|
inventory(g);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
|
|
});
|
|
}
|
|
|
|
for (const native of [true, false]) {
|
|
void test(`actual ${native ? 'native' : 'visitor'} Homeworld Karama denies Ixian substitution without repeating casualties or payment`, () => {
|
|
const f = actualIx(native, true);
|
|
let g = applyAction(reload(f.g), 'ixians', {
|
|
type: 'decision',
|
|
sources: { [f.home]: 1 },
|
|
recover: { [f.home]: 1 },
|
|
});
|
|
const before = g.players.map((p) => ({
|
|
reserves: p.reserves,
|
|
tanks: p.tanks,
|
|
elites: structuredClone(p.elites),
|
|
losses: p.battleLosses,
|
|
}));
|
|
const custodyBefore = structuredClone(g.homeworlds!.custody);
|
|
g = applyAction(reload(g), 'atreides', {
|
|
type: 'card',
|
|
card: f.karama,
|
|
mode: 'cancel',
|
|
});
|
|
g = passAll(reload(g));
|
|
assert.deepEqual(g.homeworlds!.custody, custodyBefore);
|
|
assert.deepEqual(
|
|
g.players.map((p) => ({
|
|
reserves: p.reserves,
|
|
tanks: p.tanks,
|
|
elites: p.elites,
|
|
losses: p.battleLosses,
|
|
})),
|
|
before,
|
|
);
|
|
assert.equal(g.pendingIxSubstitution, null);
|
|
assert.equal(g.discard.filter((card) => card.id === f.karama).length, 1);
|
|
assert.equal(g.decision?.kind, 'battleCards');
|
|
reject(g, 'atreides', { type: 'card', card: f.karama, mode: 'cancel' });
|
|
inventory(g);
|
|
});
|
|
}
|
|
|
|
function actualFaceDance() {
|
|
let g = setup(['tleilaxu', 'atreides', 'guild']);
|
|
g.order = ['atreides', 'guild', 'tleilaxu'];
|
|
g.active = 'atreides';
|
|
const home = 'homeworld:tleilaxu';
|
|
invade(g, 'atreides', home, 4);
|
|
invade(g, 'guild', home, 2);
|
|
const native = seat(g, 'tleilaxu');
|
|
// A conserved earlier shipment supplies real external replacement counters.
|
|
native.reserves -= 3;
|
|
native.forces['polar_sink:0'] = 3;
|
|
const leader = strongest(g, 'atreides');
|
|
const dancer = native.faceDancers![0];
|
|
if (!native.faceDancers!.some((card) => card.leader === leader)) {
|
|
const reserve = g.traitorReserve!.indexOf(leader);
|
|
if (reserve >= 0) g.traitorReserve![reserve] = dancer.leader;
|
|
else {
|
|
const owner = g.players.find((p) => p.traitors.includes(leader))!;
|
|
assert.ok(
|
|
owner,
|
|
'The matching physical traitor card must exist in a setup zone.',
|
|
);
|
|
owner.traitors[owner.traitors.indexOf(leader)] = dancer.leader;
|
|
}
|
|
dancer.leader = leader;
|
|
}
|
|
inventory(g);
|
|
g = prepared(g, 'atreides', 'guild', home);
|
|
assert.deepEqual(viewGame(g, 'tleilaxu').battle!.traitorVoters, []);
|
|
g = commit(g, 'atreides', 1, { leader });
|
|
g = commit(reload(g), 'guild');
|
|
assert.equal(g.lastBattleContext!.winner, 'atreides');
|
|
// This fixture tests Face Dance after the winner explicitly declines its
|
|
// Caladan option, keeping the original replacement-only baseline.
|
|
assert.equal(g.decision?.kind, 'caladanReinforcement');
|
|
assert.equal(viewGame(g, 'atreides').caladanReinforcement!.blocked, null);
|
|
const beforeReinforcement = structuredClone(g.players);
|
|
g = applyAction(reload(g), 'atreides', {
|
|
type: 'decision',
|
|
event: g.homeworldVictoryReinforcement!.event,
|
|
decline: true,
|
|
});
|
|
assert.deepEqual(g.players, beforeReinforcement);
|
|
assert.equal(g.decision?.kind, 'faceDance');
|
|
assert.equal(g.decision!.player, 'tleilaxu');
|
|
inventory(g);
|
|
return { g, home, leader };
|
|
}
|
|
|
|
void test('native noncombatant Tleilaxu Face Dance returns a foreign winner and adds only selected Arrakis replacements', () => {
|
|
const f = actualFaceDance();
|
|
const before = reload(f.g);
|
|
for (const level of DIFFICULTIES) {
|
|
const view = viewGame(reload(before), 'tleilaxu');
|
|
view.players.find((p) => p.id === 'tleilaxu')!.bot = level;
|
|
const snapshot = structuredClone(view);
|
|
const actions = botActions(view);
|
|
assert.ok(
|
|
actions.length,
|
|
`${level} must supply the native Face Dance choice.`,
|
|
);
|
|
for (const action of actions)
|
|
inventory(applyAction(reload(before), 'tleilaxu', action));
|
|
assert.deepEqual(view, snapshot);
|
|
}
|
|
assert.equal(
|
|
viewGame(before, 'atreides').players.find((p) => p.id === 'tleilaxu')!
|
|
.faceDancers,
|
|
undefined,
|
|
);
|
|
const nativeReserves = seat(before, 'tleilaxu').reserves;
|
|
const winnerReserves = seat(before, 'atreides').reserves;
|
|
const winnerSpice = seat(before, 'atreides').spice;
|
|
reject(before, 'guild', { type: 'decision', reveal: true, sources: {} });
|
|
reject(before, 'tleilaxu', {
|
|
type: 'decision',
|
|
reveal: true,
|
|
sources: { [f.home]: 1 },
|
|
});
|
|
reject(before, 'tleilaxu', {
|
|
type: 'decision',
|
|
reveal: true,
|
|
sources: { 'polar_sink:0': 4 },
|
|
});
|
|
const g = applyAction(reload(before), 'tleilaxu', {
|
|
type: 'decision',
|
|
reveal: true,
|
|
sources: { 'polar_sink:0': 2, reserves: 1 },
|
|
});
|
|
assert.equal(seat(g, 'tleilaxu').reserves, nativeReserves + 2);
|
|
assert.equal(seat(g, 'tleilaxu').forces['polar_sink:0'], 1);
|
|
assert.equal(seat(g, 'atreides').reserves, winnerReserves + 3);
|
|
assert.equal(seat(g, 'atreides').spice, winnerSpice);
|
|
assert.equal(g.homeworlds!.custody!.visitors[f.home], undefined);
|
|
assert.equal(g.lastBattleContext!.winner, 'atreides');
|
|
assert.equal(
|
|
seat(g, 'atreides').leaders.find((leader) => leader.id === f.leader)!.dead,
|
|
true,
|
|
);
|
|
assert.equal(
|
|
seat(g, 'tleilaxu').faceDancers!.find((card) => card.leader === f.leader)!
|
|
.revealed,
|
|
true,
|
|
);
|
|
assert.deepEqual(seat(g, 'guild').forces, seat(before, 'guild').forces);
|
|
inventory(g);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
|
|
});
|
|
|
|
void test('native Face Dance permits zero replacement and does not manufacture forces from its already present reserves', () => {
|
|
const f = actualFaceDance();
|
|
for (const sources of [{}, { reserves: 3 }]) {
|
|
const g = applyAction(reload(f.g), 'tleilaxu', {
|
|
type: 'decision',
|
|
reveal: true,
|
|
sources,
|
|
});
|
|
assert.equal(seat(g, 'tleilaxu').reserves, seat(f.g, 'tleilaxu').reserves);
|
|
assert.deepEqual(seat(g, 'tleilaxu').forces, seat(f.g, 'tleilaxu').forces);
|
|
assert.equal(
|
|
seat(g, 'atreides').reserves,
|
|
seat(f.g, 'atreides').reserves + 3,
|
|
);
|
|
inventory(g);
|
|
}
|
|
});
|
|
|
|
void test('saved Homeworld substitution rejects changed survivors, Tanks and battle identity before a Karama discard', () => {
|
|
const f = actualIx(false, true);
|
|
const declared = applyAction(reload(f.g), 'ixians', {
|
|
type: 'decision',
|
|
sources: { [f.home]: 1 },
|
|
recover: { [f.home]: 1 },
|
|
});
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.pendingIxSubstitution!.homeworld!.pool.normal++;
|
|
},
|
|
(g) => {
|
|
g.pendingIxSubstitution!.homeworld!.eliteTanks--;
|
|
},
|
|
(g) => {
|
|
g.pendingIxSubstitution!.homeworld!.normalTanks++;
|
|
},
|
|
(g) => {
|
|
g.pendingIxSubstitution!.homeworld!.battleLosses++;
|
|
},
|
|
(g) => {
|
|
g.lastBattleContext!.winner = 'atreides';
|
|
},
|
|
(g) => {
|
|
g.pendingIxSubstitution!.recover = { [`${f.home}:0`]: 1 };
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
const g = reload(declared);
|
|
mutate(g);
|
|
reject(g, 'atreides', { type: 'card', card: f.karama, mode: 'cancel' });
|
|
assert.equal(
|
|
seat(g, 'atreides').hand.filter((card) => card.id === f.karama).length,
|
|
1,
|
|
);
|
|
}
|
|
});
|