Files
Dune/tests/homeworld-collection-engine.test.ts
T

296 lines
9.0 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeHomeworldGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { botActions } from '../game/bots';
import { baseDeck } from '../game/cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { createTechTokens } from '../game/tech-tokens';
const own = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
type Options = {
advanced?: boolean;
native?: number;
shared?: boolean;
multiple?: boolean;
stronghold?: boolean;
empty?: boolean;
technology?: boolean;
};
function fixture(options: Options = {}) {
let g = createGame(
'GIEDICOLLECTION',
newPlayer('h', 'Harkonnen', 'harkonnen'),
options.advanced ?? true,
[],
);
joinGame(g, newPlayer('g', 'Guild', 'guild'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
// Select the audit-only final faction before real setup signs its roster.
if (options.shared) g.players[1] = newPlayer('g', 'Ecaz', 'ecaz');
g = applyAction(g, 'h', { 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 < 40; 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 command = botActions(view)[0];
if (command) {
next = applyAction(g, p.id, command);
break;
}
}
assert.ok(next);
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 hark = own(g, 'h');
hark.forces = {
'wind_pass:14': 2,
...(options.multiple ? { 'hagga_basin:12': 2 } : {}),
...(options.stronghold ? { 'arrakeen:10': 1 } : {}),
};
hark.reserves = options.native ?? 7;
hark.forces['imperial_basin:10'] =
20 - hark.reserves - Object.values(hark.forces).reduce((a, b) => a + b, 0);
if (options.shared) {
// The final Ecaz roster passed through genuine Homeworld setup; only
// the conserved collection positions and alliance are staged here.
const ecaz = own(g, 'g');
ecaz.forces = {
'wind_pass:14': 2,
...(options.multiple ? { 'hagga_basin:12': 2 } : {}),
...(options.stronghold ? { 'arrakeen:10': 1 } : {}),
};
ecaz.reserves = 20 - Object.values(ecaz.forces).reduce((a, b) => a + b, 0);
ecaz.ally = hark.id;
hark.ally = ecaz.id;
ecaz.allySinceTurn = hark.allySinceTurn = 1;
}
Object.assign(g, {
phase: 5,
turn: 2,
active: 'a',
movementRemaining: ['a'],
order: ['g', 'h', 'a'],
ready: [],
phaseOpening: null,
response: null,
decision: null,
storm: 18,
spice: options.empty
? {}
: {
'wind_pass:14': 3,
...(options.multiple ? { 'hagga_basin:12': 5 } : {}),
},
});
if (options.technology) {
g.techTokens = createTechTokens(g.players);
g.techTokens.heighliners = { owner: 'h', spice: 2 };
}
inventory(g);
return g;
}
function inventory(g: Game) {
homeworldGameIntegrity(g);
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
const view = viewGame(reload(g), p.id);
for (const other of view.players.filter((other) => other.id !== p.id)) {
assert.equal(other.hand, undefined);
assert.equal(other.spice, undefined);
}
}
assert.deepEqual(
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort(),
baseDeck()
.map((c) => c.id)
.sort(),
);
}
function start(g: Game) {
const before = reload(g);
const next = applyAction(g, 'a', { type: 'endMovement' });
assert.deepEqual(g, before);
assert.equal(next.phase, 7);
return next;
}
function choose(g: Game, allocation: Record<string, unknown>) {
assert.equal(g.decision?.kind, 'ecazSpice');
return applyAction(reload(g), g.decision.player, {
type: 'decision',
event: g.ecazCollection!.event,
allocation,
});
}
function equal(g: Game) {
return choose(g, { kind: 'equal' });
}
function receipt(g: Game) {
assert.ok(g.giediCollection);
const { signature, ...facts } = g.giediCollection;
assert.equal(typeof signature, 'string');
return facts;
}
function stable(g: Game) {
inventory(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
}
void test('actual collection entry grants high Giedi two spice once across multiple deserts in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const original = fixture({ advanced, multiple: true });
const g = start(original);
assert.equal(own(g, 'h').spice, 29);
assert.deepEqual(receipt(g), {
turn: 2,
player: 'h',
awarded: true,
qualifying: 7,
});
assert.equal(g.spice['wind_pass:14'], 0);
assert.equal(g.spice['hagga_basin:12'], 1);
assert.deepEqual(own(g, 'h').forces, own(original, 'h').forces);
stable(g);
}
});
void test('below-threshold Giedi receives ordinary desert spice without the two-spice bonus', () => {
const g = start(fixture({ native: 6, multiple: true }));
assert.equal(own(g, 'h').spice, 27);
assert.deepEqual(receipt(g), {
turn: 2,
player: 'h',
awarded: false,
qualifying: 7,
});
stable(g);
});
void test('stronghold and technology credits with no actual desert collection do not qualify high Giedi', () => {
const g = start(fixture({ empty: true, stronghold: true, technology: true }));
assert.equal(own(g, 'h').spice, 24);
assert.equal(g.techTokens!.heighliners.spice, 0);
assert.deepEqual(receipt(g), {
turn: 2,
player: 'h',
awarded: false,
qualifying: 0,
});
stable(g);
});
void test('shared desert escrow does not pay Giedi until a positive actual allotment, including a zero first agreement', () => {
let g = start(fixture({ shared: true, multiple: true }));
assert.equal(own(g, 'h').spice, 20);
assert.deepEqual(receipt(g), {
turn: 2,
player: 'h',
awarded: false,
qualifying: 0,
});
const first =
g.ecazCollection!.allocation!.lots[g.ecazCollection!.allocation!.index];
g = choose(g, { kind: 'propose', ecazShare: first.amount });
stable(g);
g = choose(g, { kind: 'accept' });
assert.equal(own(g, 'h').spice, 20);
assert.equal(g.giediCollection!.awarded, false);
assert.equal(g.giediCollection!.qualifying, 0);
stable(g);
const next =
g.ecazCollection!.allocation!.lots[g.ecazCollection!.allocation!.index];
g = equal(g);
const income = Math.ceil(next.amount / 2);
assert.equal(own(g, 'h').spice, 20 + income + 2);
assert.equal(g.giediCollection!.qualifying, income);
assert.equal(g.giediCollection!.awarded, true);
assert.equal(g.ecazCollection!.stage, 'complete');
stable(g);
});
void test('multiple positive shared allotments award high Giedi only once', () => {
let g = start(fixture({ shared: true, multiple: true }));
let total = 0;
while (g.decision?.kind === 'ecazSpice') {
const lot =
g.ecazCollection!.allocation!.lots[g.ecazCollection!.allocation!.index];
total += Math.ceil(lot.amount / 2);
g = equal(g);
assert.equal(own(g, 'h').spice, 20 + total + 2);
assert.equal(g.giediCollection!.qualifying, total);
stable(g);
}
});
void test('canceling Ecaz stronghold income preserves later shared desert and independent Giedi income', () => {
for (const canceled of [false, true]) {
let g = fixture({ shared: true, stronghold: true });
const at = g.deck.findIndex((c) => c.effect === 'karama');
const [card] = g.deck.splice(at, 1);
own(g, 'a').hand.push(card);
g = start(g);
assert.equal(g.response?.kind, 'ecazCollection');
g = applyAction(
reload(g),
'a',
canceled
? { type: 'card', card: card.id, mode: 'cancel' }
: { type: 'passResponse' },
);
assert.equal(own(g, 'h').spice, 22);
assert.equal(own(g, 'g').spice, canceled ? 20 : 22);
assert.equal(g.giediCollection!.awarded, false);
g = equal(g);
assert.equal(own(g, 'h').spice, 26);
assert.equal(g.giediCollection!.awarded, true);
assert.equal(g.ecazCollection!.canceled, canceled);
stable(g);
}
});
void test('stale shared allocation commands cannot replay Giedi income or alter the settled receipt', () => {
let g = start(fixture({ shared: true }));
const actor = g.decision!.player;
const command: Action = {
type: 'decision',
event: g.ecazCollection!.event,
allocation: { kind: 'equal' },
};
g = applyAction(g, actor, command);
const before = reload(g);
assert.throws(() => applyAction(g, actor, command));
assert.deepEqual(g, before);
assert.equal(own(g, 'h').spice, 24);
stable(g);
});