Files
Dune/tests/homeworld-classic-discovery-nexus-runtime.test.ts
T

295 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { discoveryEntryDeclineAction, discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import {
advanceHomeworldClassicDiscoveryNexus, closeHomeworldClassicDiscoveryNexusAlliance,
createHomeworldClassicDiscoveryNexusFixture, enterHomeworldClassicDiscoveryNexus,
homeworldClassicDiscoveryNexusClean, homeworldClassicDiscoveryNexusInventory,
homeworldClassicDiscoveryNexusPlayer, openHomeworldClassicDiscoveryNexusEncounter,
voteHomeworldClassicDiscoveryNexus,
} from './fixture-homeworld-classic-discovery-nexus';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
function rejected(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Every original difficulty must expose one owned legal continuation and keep
* the physical counters, cards and native custody conserved. */
function legalPolicies(game: Game, owner: string): void {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, owner);
view.players.find(player => player.id === owner)!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length, `${difficulty} needs an owned minimal legal continuation.`);
const after = applyAction(structuredClone(game), owner, actions[0]);
homeworldClassicDiscoveryNexusInventory(after);
}
}
/** Root rulebook physical pp.22–23 and the printed Nexus/Discovery components.
* Only original create/join/ready/initializer/actions run; the tests select
* conserved unplayed Spice positions and supply lotteries, never hands,
* custody, clocks, alliances, casualty frames or outcomes. */
void test('original no-Skills Homeworld/Nexus admits Basic and Advanced classic rosters with undealt native components', () => {
const compositions = [
{ advanced: false, discovery: false, tech: true, strongholds: false },
{ advanced: false, discovery: true, tech: false, strongholds: false },
{ advanced: true, discovery: true, tech: true, strongholds: true },
{ advanced: true, discovery: false, tech: true, strongholds: true },
] as const;
for (const composition of compositions) for (const seats of [3, 6] as const) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ ...composition, seats });
assert.equal(fixture.game.turn, 2);
homeworldClassicDiscoveryNexusInventory(fixture.game);
const worlds = viewGame(fixture.game, fixture.collector).homeworlds!.worlds!;
for (const player of fixture.game.players)
assert.ok(worlds.some(world => world.native === player.id),
`Seat ${player.id} must keep its original native Homeworld.`);
assert.equal(fixture.afterSetup.status, 'playing');
if (composition.discovery) {
assert.equal(fixture.blowAmount, 6);
assert.equal(fixture.game.decision?.kind, 'discoveryEntry');
assert.equal(fixture.game.decision!.player, fixture.collector);
assert.equal(fixture.game.phase, 0);
} else {
assert.equal(fixture.game.nexus, false);
}
}
});
void test('real source-qualified native shipments fund the blow army and the Collection payment exactly once', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: true, tech: false });
assert.equal(fixture.firstStorm.phase, 1);
assert.equal(fixture.firstStorm.techTokens ?? null, null);
assert.equal(fixture.afterBlow.spice[fixture.blowKey], fixture.blowAmount,
'The printed blow must be placed, not storm-lost.');
assert.equal(fixture.blowAmount, 6);
for (const shipment of fixture.shipments) {
const owner = homeworldClassicDiscoveryNexusPlayer(fixture.collection, shipment.actor);
const key = `${shipment.territory}:${shipment.sector}`;
assert.equal(owner.forces[key], shipment.amount);
assert.equal(owner.elites?.forces[key] ?? 0, shipment.elite);
const home = homeworldClassicDiscoveryNexusPlayer(fixture.afterSetup, shipment.actor);
assert.equal(owner.reserves, home.reserves - shipment.amount, 'Only real native counters left the reserve.');
assert.equal(owner.elites?.reserves ?? 0, (home.elites?.reserves ?? 0) - shipment.elite);
for (const source of Object.keys(shipment.homeworldSources))
assert.ok(source === `homeworld:${home.faction}` || source === 'homeworld:emperor:salusa',
'A native shipment may only spend the shipper’s own original Homeworlds.');
}
const baseline = Object.fromEntries(fixture.beforeShipment.players.map(player => [player.id, player.spice]));
const collected = Object.fromEntries(fixture.collection.players.map(player => [player.id, player.spice]));
const ordinaryIncome = quoteSpiceCollection(fixture.afterSetup).receipts;
for (const player of fixture.collection.players) {
const paid = fixture.shipments.filter(row => row.actor === player.id)
.reduce((sum, row) => sum + row.cost, 0);
const harvested: number = player.id === fixture.victim ? fixture.blowAmount : 0;
const income = ordinaryIncome.find(receipt => receipt.player === player.id)!.balance -
homeworldClassicDiscoveryNexusPlayer(fixture.afterSetup, player.id).spice;
assert.equal(collected[player.id], baseline[player.id] - paid + harvested + income,
'Shipment price and printed Collection must settle exactly once.');
}
const idle = homeworldClassicDiscoveryNexusPlayer(fixture.collection, fixture.fremen!);
assert.equal(idle.reserves, homeworldClassicDiscoveryNexusPlayer(fixture.afterSetup, fixture.fremen!).reserves);
homeworldClassicDiscoveryNexusInventory(fixture.collection);
}
});
void test('original worm losses resolve before Great Maker votes and a typed Fremen ride without repeating casualties', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: true,
tech: advanced, ordinaryWormFirst: advanced });
const before = homeworldClassicDiscoveryNexusPlayer(fixture.game, fixture.victim);
assert.equal(before.forces[fixture.wormSource], 2);
let game = openHomeworldClassicDiscoveryNexusEncounter(fixture, false);
assert.equal(game.turn, 2);
assert.equal(game.greatMaker!.stage, 'vote');
const devoured = homeworldClassicDiscoveryNexusPlayer(game, fixture.victim);
assert.equal(devoured.forces[fixture.wormSource] ?? 0, 0);
assert.equal(devoured.tanks, before.tanks + 2);
assert.equal(devoured.reserves, before.reserves);
assert.equal(homeworldClassicDiscoveryNexusPlayer(game, fixture.collector)
.forces[`${fixture.parent}:${fixture.parentSector}`], fixture.amount,
'The army shipped to the separate parent site must survive the worm.');
const order = [...game.order];
for (let index = 0; index < order.length; index++) {
assert.equal(game.decision?.kind, 'greatMakerVote');
assert.equal(game.decision!.player, order[index]);
legalPolicies(game, order[index]);
// Advanced reaches a real majority; Basic stays a minority, so only the
// preceding original Shai-Hulud can add the Nexus there.
const vote: Action = { type: 'decision', event: game.decision!.event,
yes: advanced ? index < order.length - 1 : index === order.length - 1 };
rejected(game, order[(index + 1) % order.length], vote);
game = applyAction(game, order[index], vote);
}
assert.equal(game.nexus, advanced, 'A strict majority adds the Nexus; a tie with no preceding worm adds none.');
assert.equal(game.decision?.kind, 'greatMakerRide');
assert.equal(game.decision!.player, fixture.fremen);
const fremen = homeworldClassicDiscoveryNexusPlayer(game, fixture.fremen!);
const elite = advanced ? 1 : 0;
assert.ok(fremen.reserves >= 2 && (fremen.elites?.reserves ?? 0) >= elite);
const view = viewGame(game, fixture.fremen!), destinations = view.greatMaker!.ride!.destinations;
assert.ok(destinations.every(destination => !destination.territory.startsWith('homeworld:')));
const target = destinations.find(destination => destination.territory === 'cistern');
assert.ok(target, 'The revealed nested Cistern is a real Great Maker reserve destination.');
const ride = greatMakerRideAction(view, target.territory, target.sector, 2, elite);
assert.ok(ride);
assert.equal(greatMakerRideAction(view, 'homeworld:fremen', 0, 2, elite), null);
rejected(game, fixture.fremen!, { ...ride, territory: 'homeworld:fremen', sector: 0 });
rejected(game, fixture.fremen!, { ...ride, elite: view.greatMaker!.ride!.eliteMax + 1 });
legalPolicies(game, fixture.fremen!);
game = applyAction(game, fixture.fremen!, ride);
const after = homeworldClassicDiscoveryNexusPlayer(game, fixture.fremen!);
assert.equal(after.forces['cistern:0'], 2);
assert.equal(after.elites?.forces['cistern:0'] ?? 0, elite);
assert.equal(after.reserves, fremen.reserves - 2);
assert.equal(after.elites?.reserves ?? 0, (fremen.elites?.reserves ?? 0) - elite);
assert.equal(after.spice, fremen.spice);
assert.equal(after.shipped, fremen.shipped);
assert.equal(after.moved, fremen.moved);
assert.equal(game.greatMaker?.stage, 'complete');
assert.deepEqual(game.wormRides, []);
homeworldClassicDiscoveryNexusInventory(game);
}
});
void test('the next-turn parent-board free entry reaches the revealed nested site without shipment, movement or spice', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: true, tech: false });
const game = fixture.game, view = viewGame(game, fixture.collector);
assert.equal(game.turn, 2);
assert.equal(game.phase, 0);
assert.equal(game.decision?.kind, 'discoveryEntry');
assert.equal(game.decision!.player, fixture.collector);
const sources = view.discoveryEntry!.sources;
assert.deepEqual(sources, [{ source: `${fixture.parent}:${fixture.parentSector}`,
normal: fixture.amount - fixture.elite, elite: fixture.elite }]);
const accept = discoveryEntryMoveAction(view, sources), decline = discoveryEntryDeclineAction(view);
assert.ok(accept && decline);
legalPolicies(game, fixture.collector);
rejected(game, fixture.fremen!, accept);
rejected(game, fixture.collector, { ...accept, groups: [{ source: 'homeworld:emperor', normal: 1, elite: 0 }] });
rejected(game, fixture.collector, { ...accept, groups: [{ ...sources[0], normal: 21 }] });
assert.equal(discoveryEntryMoveAction(view, [{ source: 'homeworld:emperor', normal: 1, elite: 0 }]), null);
assert.equal(discoveryEntryMoveAction(view, [{ ...sources[0], normal: 21 }]), null);
assert.equal(nativeShipmentSources(view, 21, 0), null);
const before = structuredClone(homeworldClassicDiscoveryNexusPlayer(game, fixture.collector));
const declined = applyAction(reload(game), fixture.collector, decline);
assert.deepEqual(homeworldClassicDiscoveryNexusPlayer(declined, fixture.collector).forces, before.forces);
const entered = enterHomeworldClassicDiscoveryNexus(reload(game), fixture.collector);
const after = homeworldClassicDiscoveryNexusPlayer(entered, fixture.collector);
assert.equal(after.forces['cistern:0'], fixture.amount);
assert.equal(after.elites?.forces['cistern:0'] ?? 0, fixture.elite);
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`] ?? 0, 0);
assert.equal(after.reserves, before.reserves);
assert.equal(after.elites?.reserves, before.elites?.reserves);
assert.equal(after.spice, before.spice);
assert.equal(after.shipped, before.shipped);
assert.equal(after.moved, before.moved);
rejected(entered, fixture.collector, accept);
homeworldClassicDiscoveryNexusInventory(entered);
}
});
void test('without Discovery the original worm alone creates the Nexus, and the settled alliance closes one deal', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: false,
tech: true, strongholds: advanced });
assert.equal(fixture.token, null);
assert.equal(fixture.game.turn, 2);
assert.equal(fixture.game.nexus, false, 'No Nexus exists before the turn-two worm.');
const production = fixture.firstStorm.techTokens!.production;
assert.equal(production.owner, fixture.fremen, 'The first Storm assigns the Fremen’s original Technology.');
const heighliner = fixture.afterShipments!.techTokens!.heighliners;
if (heighliner.owner) {
assert.equal(heighliner.triggeredTurn, 1, 'A real native shipment triggers Heighliners once per phase.');
assert.equal(heighliner.spice, ownedTech(fixture.afterShipments!.techTokens, heighliner.owner).length);
}
const before = homeworldClassicDiscoveryNexusPlayer(fixture.game, fixture.victim);
assert.equal(before.forces[fixture.wormSource], 2);
const window = advanceHomeworldClassicDiscoveryNexus(fixture.game,
state => state.nexus && !state.spiceWindow && !state.spiceResolution &&
homeworldClassicDiscoveryNexusClean(state), fixture.actions);
assert.equal(window.nexus, true, 'The original Shai-Hulud creates the Nexus by itself.');
const devoured = homeworldClassicDiscoveryNexusPlayer(window, fixture.victim);
assert.equal(devoured.forces[fixture.wormSource] ?? 0, 0);
assert.equal(devoured.tanks, before.tanks + 2);
assert.equal(devoured.reserves, before.reserves);
assert.equal(homeworldClassicDiscoveryNexusPlayer(window, fixture.collector)
.forces[`${fixture.parent}:${fixture.parentSector}`], fixture.amount);
const closed = closeHomeworldClassicDiscoveryNexusAlliance(fixture, window);
assert.equal(closed.drawing.nexusCards!.phase!.stage, 'drawing');
assert.equal(closed.after.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
assert.equal(closed.after.nexusCards!.phase!.stage, 'complete');
assert.equal(closed.after.nexusCards!.cards!.deck.includes('ecaz'), false);
assert.equal(homeworldClassicDiscoveryNexusPlayer(closed.alliance, fixture.victim).ally, fixture.fremen);
assert.equal(homeworldClassicDiscoveryNexusPlayer(closed.alliance, fixture.fremen!).ally, fixture.victim);
rejected(closed.after, fixture.collector, closed.draw);
homeworldClassicDiscoveryNexusInventory(closed.after);
}
});
void test('the Advanced second pile and the settled alliance close exactly one original Nexus deal', () => {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced: true, discovery: true,
tech: true, strongholds: true, ordinaryWormFirst: true });
let game = enterHomeworldClassicDiscoveryNexus(fixture.game, fixture.collector);
game = advanceHomeworldClassicDiscoveryNexus(game, state => state.greatMaker?.stage === 'vote', fixture.actions);
const order = [...game.order];
game = voteHomeworldClassicDiscoveryNexus(game, order.map((_, index) => index < 2));
assert.equal(game.nexus, true);
game = advanceHomeworldClassicDiscoveryNexus(game, state => state.nexus && !state.spiceWindow &&
!state.spiceResolution && homeworldClassicDiscoveryNexusClean(state), fixture.actions);
const closed = closeHomeworldClassicDiscoveryNexusAlliance(fixture, game);
assert.equal(closed.drawing.turn, 2);
assert.equal(closed.drawing.nexusCards!.phase!.stage, 'drawing');
assert.deepEqual(closed.drawing.nexusCards!.phase!.eligible, [fixture.collector]);
assert.equal(closed.after.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
assert.equal(closed.after.nexusCards!.cards!.discard.includes('ecaz'), false);
assert.equal(homeworldClassicDiscoveryNexusPlayer(closed.after, fixture.collector).spice,
homeworldClassicDiscoveryNexusPlayer(closed.drawing, fixture.collector).spice);
assert.equal(closed.after.nexusCards!.phase!.stage, 'complete');
assert.equal(closed.after.strongholdCards!.claimedTurn, 1);
assert.deepEqual(closed.after.wormRides, []);
rejected(closed.after, fixture.collector, closed.draw);
homeworldClassicDiscoveryNexusInventory(closed.after);
});
void test('all four minimal legal policies answer the real free entry, vote, ride and closing draw', () => {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced: true, discovery: true,
tech: true, strongholds: true });
const vote = openHomeworldClassicDiscoveryNexusEncounter(fixture, false);
const ride = voteHomeworldClassicDiscoveryNexus(reload(vote), [...vote.order].map(() => true));
const closed = closeHomeworldClassicDiscoveryNexusAlliance(fixture, ride);
const cases = [
{ game: fixture.game, owner: fixture.collector, type: 'decision' },
{ game: vote, owner: vote.decision!.player, type: 'decision' },
{ game: ride, owner: ride.decision!.player, type: 'decision' },
{ game: closed.drawing, owner: fixture.collector, type: 'nexusCardChoice' },
];
for (const difficulty of DIFFICULTIES) for (const current of cases) {
const view = viewGame(current.game, current.owner);
view.players.find(player => player.id === current.owner)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === current.type);
assert.ok(action, `${difficulty} must answer the owned ${current.game.decision?.kind ?? 'closing Nexus draw'}.`);
const after = applyAction(reload(current.game), current.owner, action);
if (current.game.decision?.kind === 'greatMakerVote')
assert.equal(after.greatMaker!.votes.length, current.game.greatMaker!.votes.length + 1);
else if (current.game.decision?.kind === 'greatMakerRide')
assert.equal(after.greatMaker!.stage, 'complete');
else if (current.game.decision?.kind === 'discoveryEntry')
rejected(after, current.owner, action);
else assert.equal(after.nexusCards!.phase!.stage, 'complete');
homeworldClassicDiscoveryNexusInventory(after);
}
});