Files
Dune/tests/fixture-world-module-occupation.ts

232 lines
16 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { distance } from '../game/board';
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { discoveryAction } from '../game/discovery-options';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { ownedTech } from '../game/tech-tokens';
import { withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
import {
actMixedPhaseModules, createMixedPhaseModulesFixture, mixedPhaseModulesClean,
mixedPhaseModulesInventory, mixedPhaseModulesPlayer, nextMixedPhaseModules,
type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
} from './fixture-mixed-phase-modules';
export type WorldModuleOccupationOptions = {
advanced?: boolean; nexus?: boolean; discoveries?: boolean; skills?: boolean;
tech?: boolean; strongholds?: boolean;
};
export type WorldModuleOccupationStep = MixedPhaseModulesStep;
export type WorldModuleOccupationTransition = MixedPhaseModulesTransition;
export const WORLD_MODULE_OCCUPATION_TRAINING = {
atreides: 'warmaster', emperor: 'spice-banker', fremen: 'mentat',
} as const;
export const actWorldModuleOccupation = actMixedPhaseModules;
export const worldModuleOccupationClean = mixedPhaseModulesClean;
export const worldModuleOccupationPlayer = mixedPhaseModulesPlayer;
export const worldModuleOccupationInventory = mixedPhaseModulesInventory;
export function nextWorldModuleOccupation(game: Game): WorldModuleOccupationStep | null {
return nextMixedPhaseModules(game);
}
export function advanceWorldModuleOccupation(game: Game, until: (state: Game) => boolean,
actions: WorldModuleOccupationStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original World/module source ended before its owned control');
const next = nextWorldModuleOccupation(game); assert.ok(next);
game = actWorldModuleOccupation(game, next.actor, next.action, actions);
}
throw Error('The original World/module source did not reach its finite boundary');
}
export function finishWorldModuleOccupation(game: Game, actions: WorldModuleOccupationStep[] = []): Game {
return advanceWorldModuleOccupation(game, state => state.turn === 2 && state.phase === 0 &&
(worldModuleOccupationClean(state) || state.decision?.kind === 'discoveryEntry'), actions);
}
export function worldModuleOccupationDeferred(game: Game, owner = 'emperor') {
return viewGame(game, owner).spiceBankerIncome?.deferred.find(row => row.owner === owner)?.amount ?? 0;
}
/** Original physical inventories at the source/settled boundaries only. This
* does not manufacture hidden deals or assert complete module certification. */
export function worldModuleOccupationComponents(game: Game) {
const skills = game.leaderSkills ? [...game.leaderSkills.deck,
...game.leaderSkills.assignments.map(assignment => assignment.skill),
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort() : [];
if (game.leaderSkills) assert.deepEqual(skills, LEADER_SKILL_CARDS.map(card => card.id).sort());
const nexus = game.nexusCards?.cards ? [...game.nexusCards.cards.deck, ...game.nexusCards.cards.discard,
...Object.values(game.nexusCards.cards.hands).filter(card => card !== null)].sort() : [];
if (game.nexusCards?.cards) assert.deepEqual(nexus, [...NEXUS_FACTIONS].sort());
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceResolution?.skipped ?? []),
...(game.spiceSequence?.skipped ?? [])].map(card => JSON.stringify(card)).sort();
const tokens = game.discoveries?.tokens.map(token => `${token.id}:${token.face}`).sort() ?? [];
if (game.discoveries) assert.equal(tokens.length, 8);
return { cards: worldModuleOccupationInventory(game), skills, nexus, spice, tokens };
}
/** Public classic A/E/F original33 Worlds, optionally all14, Nexus12,
* Discovery7/8, Tech and Advanced Strongholds. Actual A Bank10 evacuates all10
* Caladan normals into held Arrakeen; actual E Bank1 sends an original native
* normal into empty Caladan. No played army, hand, wallet, skill, qualification,
* owner, income ledger or clock is installed. Only original unopened scalar,
* Skill/Spice and supply-token lotteries are selected and disclosed. */
export function createWorldModuleOccupationFixture(options: WorldModuleOccupationOptions = {}) {
const advanced = options.advanced ?? false, nexus = options.nexus ?? true;
const discoveries = options.discoveries ?? false, skills = options.skills ?? false;
const tech = options.tech ?? true, strongholds = options.strongholds ?? advanced;
const native = 'atreides', owner = 'emperor', last = 'fremen';
const world = 'homeworld:atreides', card = 'caladan' as const;
const source = createMixedPhaseModulesFixture({ factions: [native, owner, last], expansions: [],
advanced, homeworlds: true, nexus, discoveries, skills, tech, strongholds,
training: skills ? WORLD_MODULE_OCCUPATION_TRAINING : undefined });
let game = structuredClone(source.game);
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actWorldModuleOccupation(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceWorldModuleOccupation(game, until, actions); };
const transition = (run: () => void): WorldModuleOccupationTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
const originalComponents = worldModuleOccupationComponents(source.setup);
assert.equal(originalComponents.cards.length, 33);
assert.ok(game.homeworldOccupationPreview && game.homeworldOccupationHistory);
assert.equal(game.homeworldOccupationHistory.qualifications.length, 0);
const moduleSpiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
let discoveryPlacement: WorldModuleOccupationTransition | null = null;
let token: NonNullable<Game['discoveries']>['tokens'][number] | null = null;
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
if (discoveries) {
// This is still the original unopened first blow, after the real first
// Storm. Its printed token destination is separate from its Spice pile.
assert.equal(game.turn, 1); assert.equal(game.phase, 1);
const from = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === 'discovery-hagga-basin');
assert.ok(from >= 0);
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(selected);
moduleSpiceOrder.push({ position: 0, from, card: structuredClone(selected) });
discoveryPlacement = transition(() => {
for (let count = 0; count < 100 && !game.discoveries!.tokens.some(candidate => candidate.face === 'cistern' && candidate.status === 'placed'); count++) {
const next = nextWorldModuleOccupation(game); assert.ok(next);
// Wrap the actual native action directly: the generic source act's
// scalar policy must not override this original physical supply draw.
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action));
actions.push(structuredClone(next));
}
});
token = game.discoveries!.tokens.find(candidate => candidate.face === 'cistern' && candidate.status === 'placed') ?? null;
assert.ok(token); assert.equal(token.territory, placement.territory); assert.equal(token.sector, placement.sector);
assert.equal(token.revealedTurn, null);
}
reach(state => state.turn === 1 && state.phase === 5 && state.active === native && worldModuleOccupationClean(state));
const evacuationSources = nativeShipmentSources(viewGame(game, native), 10, 0); assert.ok(evacuationSources);
const evacuationAction: Action = { type: 'ship', territory: 'arrakeen', sector: 10,
amount: 10, elite: 0, allyPayment: 0, homeworldSources: evacuationSources };
const evacuation = transition(() => { act(native, evacuationAction); reach(worldModuleOccupationClean); });
assert.equal(worldModuleOccupationPlayer(game, native).reserves, 0);
assert.equal(worldModuleOccupationPlayer(game, native).forces['arrakeen:10'], 20);
assert.equal(worldModuleOccupationPlayer(evacuation.before, native).spice - worldModuleOccupationPlayer(game, native).spice, 10);
assert.equal(quoteStableHomeworldOccupation(game, card).entitlement, null);
let discoveryMovement: WorldModuleOccupationTransition | null = null;
if (discoveries) {
assert.equal(distance('arrakeen:10', `${placement.territory}:${placement.sector}`), 3);
const movement: Action = { type: 'move', from: 'arrakeen:10', amount: 1, elite: 0,
territory: placement.territory, sector: placement.sector };
discoveryMovement = transition(() => { act(native, movement); reach(worldModuleOccupationClean); });
assert.equal(worldModuleOccupationPlayer(game, native).forces['arrakeen:10'], 19);
assert.equal(worldModuleOccupationPlayer(game, native).forces[`${placement.territory}:${placement.sector}`], 1);
}
reach(state => state.phase === 5 && state.active === owner && worldModuleOccupationClean(state));
const incomingReserveSources = nativeShipmentSources(viewGame(game, owner), 1, 0); assert.ok(incomingReserveSources);
// Native withdrawal quotes enumerate both Emperor Worlds, including a
// zero Salusa allocation. Cross-World shipment selects only positive groups,
// just as the ordinary Homeworld Shipment control does.
const incomingSources = Object.fromEntries(Object.entries(incomingReserveSources)
.filter(([, group]) => group.normal + group.elite > 0));
const invasionQuote = homeworldShipmentChoice(viewGame(game, owner), world, incomingSources);
assert.equal(invasionQuote.blocked, null); assert.ok(invasionQuote.action);
const invasionAction = invasionQuote.action;
const invasion = transition(() => { act(owner, invasionAction); reach(worldModuleOccupationClean); });
assert.equal(worldModuleOccupationPlayer(invasion.before, owner).spice - worldModuleOccupationPlayer(game, owner).spice, 1);
assert.deepEqual(game.homeworlds?.custody?.visitors[world]?.[owner], { normal: 1, elite: 0 });
const qualification = game.homeworldOccupationHistory!.qualifications.find(row => row.world === world && row.player === owner);
assert.ok(qualification, 'The accepted sole arrival earns the original qualification');
const stable = quoteStableHomeworldOccupation(game, card);
assert.equal(stable.blocked, null); assert.ok(stable.entitlement);
assert.equal(stable.entitlement.qualification, qualification.event);
assert.equal(stable.entitlement.occupier, owner); assert.equal(stable.entitlement.spice, 2);
assert.equal(stable.entitlement.ally, null);
reach(state => state.phase === 5 && state.active === last && worldModuleOccupationClean(state));
assert.equal(game.order.at(-1), last);
const techOwner = game.techTokens?.heighliners.owner ?? null;
const techAmount = game.techTokens?.heighliners.spice ?? 0;
if (techOwner) assert.equal(techAmount, ownedTech(game.techTokens, techOwner).length);
const bankerSource = game.spiceBankerIncome?.sources.find(receipt => receipt.source.kind === 'shipment' &&
receipt.source.bankLegs.some(leg => leg.payer === native && leg.amount === 10)) ?? null;
if (skills) {
assert.ok(bankerSource?.grant); assert.equal(bankerSource.grant.owner, owner);
assert.equal(worldModuleOccupationDeferred(game, owner), 1);
}
const trainingActions = actions.filter(step => step.action.type === 'leaderSkill');
assert.deepEqual(worldModuleOccupationComponents(game), originalComponents);
homeworldGameIntegrity(game);
return { ...source, game, sourceGame: structuredClone(game), advanced, nexus, discoveries, skills, tech, strongholds,
native, owner, banker: skills ? owner : null, world, card, current: last, actor: last,
action: { type: 'endMovement' } as Action, techOwner, techAmount,
evacuationSources, evacuationAction, evacuation, incomingReserveSources, incomingSources, invasionQuote, invasionAction, invasion,
qualification: structuredClone(qualification), entitlement: structuredClone(stable.entitlement),
moduleSpiceOrder, discoveryPlacement, token: structuredClone(token), discoveryMovement,
bankerSource: structuredClone(bankerSource), training: skills ? WORLD_MODULE_OCCUPATION_TRAINING : null,
trainingActions, originalComponents, actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Original public A/E/F base33 setup and optional module controls; unopened Skill/scalar/Spice lotteries only. Discovery selects original Hagga card and Cistern supply lottery, then earns ordinary city-range3 movement, Collection reveal and next-Storm entry. All payments, native counters and occupation events are actual actions.' };
}
export type WorldModuleOccupationFixture = ReturnType<typeof createWorldModuleOccupationFixture>;
/** Original End Movement, automatic occupied Collection2, actual optional
* inspect/reveal, Mentat Banker and first Stronghold claims. Discovery's next
* Storm entry is captured as its own genuine control, not a quiet-phase claim. */
export function completeWorldModuleOccupation(f: WorldModuleOccupationFixture, state = f.game) {
let game = state;
const actions: WorldModuleOccupationStep[] = [];
const act = (actor: string, action: Action) => { game = actWorldModuleOccupation(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceWorldModuleOccupation(game, until, actions); };
const transition = (run: () => void): WorldModuleOccupationTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
const collection = transition(() => { act(f.current, f.action); reach(state => state.phase === 7 && worldModuleOccupationClean(state)); });
const incomeHistory = game.homeworldOccupiedIncomeHistory?.find(history => history.turn === game.turn &&
history.queue.some(row => row.world === f.world)); assert.ok(incomeHistory);
const incomeReceipt = incomeHistory.receipts.find(receipt => receipt.world === f.world); assert.ok(incomeReceipt);
let revelation: WorldModuleOccupationTransition | null = null;
if (f.token) revelation = transition(() => {
const inspect = discoveryAction(viewGame(game, f.native), f.token!.id, false); assert.ok(inspect); act(f.native, inspect);
const reveal = discoveryAction(viewGame(game, f.native), f.token!.id, true); assert.ok(reveal); act(f.native, reveal);
assert.equal(game.discoveries!.tokens.find(token => token.id === f.token!.id)!.revealedTurn, 1);
});
const mentat = transition(() => { game = finishWorldModuleOccupation(game, actions); });
const bankerCollection = f.bankerSource ? game.spiceBankerIncome?.collections.find(receipt =>
receipt.turn === 1 && receipt.grants.includes(f.bankerSource!.cursor)) ?? null : null;
if (f.skills) assert.ok(bankerCollection);
let entryAction: Action | null = null, entry: WorldModuleOccupationTransition | null = null;
const beforeEntry = f.token ? structuredClone(game) : null;
if (f.token) {
assert.equal(game.decision?.kind, 'discoveryEntry'); assert.equal(game.decision!.player, f.native);
const offer = viewGame(game, f.native).discoveryEntry; assert.ok(offer);
entryAction = discoveryEntryMoveAction(viewGame(game, f.native), offer.sources); assert.ok(entryAction);
const selected = entryAction;
entry = transition(() => { act(f.native, selected); });
assert.equal(worldModuleOccupationPlayer(game, f.native).forces['cistern:0'], 1);
}
assert.equal(game.turn, 2); assert.equal(game.phase, 0);
homeworldGameIntegrity(game);
assert.deepEqual(worldModuleOccupationComponents(game), f.originalComponents);
return { game, collection, incomeHistory: structuredClone(incomeHistory), incomeReceipt: structuredClone(incomeReceipt),
revelation, mentat, bankerCollection: structuredClone(bankerCollection), beforeEntry, entryAction, entry,
actions, actionCount: actions.length };
}