Initialize occupation benefits in original World module setups

This commit is contained in:
Quintine committed 2026-10-26 12:10:32 +11:00
1 parent 396f9e0ccb
commit 09eb857583
18 files changed
+916 -12

No files matched your search

+21 -2
View File
@@ -20,6 +20,8 @@ export type MixedPhaseModulesOptions = {
homeworlds?: boolean;
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
nexus?: boolean;
discoveries?: boolean;
};
export const MIXED_PHASE_NATIVE_FACTIONS = ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const;
export const actMixedPhaseModules = actAdvisorPreparation;
@@ -28,7 +30,8 @@ export const mixedPhaseModulesPlayer = (game: Game, actor: string) => {
};
export const mixedPhaseModulesClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.truthtrance &&
!game.pendingShipment && !game.pendingKarama && !game.currentAuctionSale;
!game.pendingShipment && !game.pendingKarama && !game.currentAuctionSale &&
game.nexusCards?.phase?.stage !== 'drawing';
/** Current physical zones, excluding the ordinary auction's historical aliases. */
export function mixedPhaseModulesInventory(game: Game): string[] {
@@ -43,8 +46,19 @@ export function mixedPhaseModulesInventory(game: Game): string[] {
/** Native decisions only. Optional Terror placement is declined so the positive
* shipment source does not depend on a separately selected Terror effect. */
export function nextMixedPhaseModules(game: Game): MixedPhaseModulesStep | null {
if (!game.phaseOpening && !game.response && !game.decision && !game.truthtrance &&
!game.pendingTreacheryDiscard && game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id));
assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
}
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision) {
const d = game.decision;
if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
if (d.kind === 'greatMakerVote')
return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
if (d.kind === 'moritaniSetup') return { actor: d.player,
action: { type: 'decision', territory: 'carthag', sector: territory('carthag').sectors[0] } };
if (d.kind === 'moritaniPlacement' || d.kind === 'choamStorm' || d.kind === 'choamMovement' ||
@@ -103,6 +117,8 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
if (options.homeworlds) act(host, { type: 'homeworlds', enabled: true });
if (options.skills) act(host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
if (options.nexus) act(host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
if (options.discoveries) act(host, { type: 'moduleOption', module: 'discoveries', enabled: true });
if (options.independent) act(host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
if (options.tech) act(host, { type: 'techTokens', enabled: true });
if (options.strongholds) act(host, { type: 'strongholdCards', enabled: true });
@@ -160,6 +176,7 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
return { game, sourceGame: structuredClone(game), independent: options.independent ?? false,
homeworlds: options.homeworlds ?? false,
skills: options.skills ?? false,
nexus: options.nexus ?? false, discoveries: options.discoveries ?? false,
initial, lobby, setup, afterSetup, firstStorm,
originalInventory, originalSpice, spiceOrder, actions, sourceActions: structuredClone(actions),
sourceActionCount: actions.length, staging: [] as string[] };
@@ -172,12 +189,14 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
export function createMixedPhaseModulesProgramme(options: {
advanced?: boolean; strongholds?: boolean; independent?: boolean; homeworlds?: boolean;
skills?: boolean; training?: Partial<Record<FactionId, LeaderSkillId>>;
nexus?: boolean; discoveries?: boolean;
} = {}) {
const advanced = options.advanced ?? false, strongholds = options.strongholds ?? advanced;
const source = createMixedPhaseModulesFixture({ factions: MIXED_PHASE_NATIVE_FACTIONS,
expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds,
independent: options.independent, homeworlds: options.homeworlds,
skills: options.skills, training: options.training });
skills: options.skills, training: options.training,
nexus: options.nexus, discoveries: options.discoveries });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
+231
View File
@@ -0,0 +1,231 @@
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 };
}
@@ -178,7 +178,11 @@ for (const advanced of [false, true]) {
assert.equal(player(done, f.owner).tanks, advanced ? 3 : 4);
assert.equal(player(done, f.owner).elites?.tanks ?? 0, advanced ? 1 : 0);
assert.equal(done.nexusCards!.cards!.hands[f.owner], 'fremen', 'Battle rescue never spends the unrelated physically held Nexus singleton.');
const window = revivalWindow(done, f.actions, f.staging), offer = viewGame(window, f.owner).nexusFremenRevival!;
const opened = revivalWindow(done, f.actions, f.staging);
if (opened.revivalOrder) assert.ok(opened.revivalOrder.remaining.includes(f.owner));
const window = advance(opened, g => g.phase === 4 && clean(g) &&
(!g.revivalOrder || g.revivalOrder.remaining[0] === f.owner), f.actions);
const offer = viewGame(window, f.owner).nexusFremenRevival!;
assert.equal(offer.blocked, null);
assert.equal(viewGame(window, f.opponent).nexusFremenRevival, null);
legalPolicies(window, f.owner);
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
homeworldOccupationModulesProfile as supported,
homeworldOccupationLeaderSkillsProfile as cleanSkills,
homeworldPhaseModulesProfile as clean,
type HomeworldPhaseModulesProfile,
} from '../game/homeworld-module-profile';
import { independentDiscoveryProfile, nexusCoreProfile } from '../game/faction-module-profile';
import {
classicNexusLeaderSkillsProfile,
homeworldLeaderSkillsProfile,
pairedNexusLeaderSkillsProfile,
} from '../game/leader-skill-profile';
import {
classicDiscoveryLeaderSkillsProfile,
classicDiscoveryNexusProfile,
homeworldDiscoveryProfile,
pairedDiscoveryNexusProfile,
} from '../game/discovery-module-profile';
import { mixedE3NexusProfile, standaloneE3NexusProfile } from '../game/nexus-module-profile';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
function profile(factions: readonly string[] = ['atreides', 'emperor', 'guild'], expansions: readonly string[] = [],
extra: Partial<HomeworldPhaseModulesProfile> = {}): HomeworldPhaseModulesProfile {
return { advanced: false, expansions, players: factions.map(faction => ({ faction })),
homeworlds: { custody: null }, ...extra };
}
const nexus = () => ({ cards: null, phase: null });
void test('original classic World Nexus accepts its initialized core reactions with and without Skills', () => {
const game = profile(['atreides', 'harkonnen', 'guild'], [], { advanced: true, nexusCards: nexus(),
techTokens: createTechTokens(), strongholdCards: createStrongholdCards(),
homeworldOccupationPreview: true, nexusIxianReplacementPreview: true,
nexusHarkonnenBetrayalPreview: true, guildBetrayalPreview: true });
assert.equal(nexusCoreProfile(game), true); assert.equal(supported(game), true);
const trained = { ...game, leaderSkills: { deck: [], assignments: [] }, spiceBankerIncomePreview: true };
assert.equal(homeworldLeaderSkillsProfile(trained), true); assert.equal(supported(trained), true);
assert.equal(clean(game), false); assert.equal(cleanSkills(trained), false);
});
void test('original classic Discovery retains separate untrained and all14 World admission', () => {
const game = profile(undefined, undefined, { discoveryEnabled: true, techTokens: createTechTokens() });
assert.equal(homeworldDiscoveryProfile(game), true); assert.equal(supported(game), true);
const trained = { ...game, leaderSkills: { deck: [], offers: {} }, spiceBankerIncomePreview: true };
assert.equal(classicDiscoveryLeaderSkillsProfile(trained), true);
assert.equal(homeworldLeaderSkillsProfile(trained), true); assert.equal(supported(trained), true);
assert.equal(supported({ ...game, discoveryEnabled: false, discoveries: {} }), false);
assert.equal(supported({ ...game, discoveryEnabled: 'true' }), false);
});
void test('classic World Nexus and Discovery use their original joint profiles rather than hiding either module', () => {
const game = profile(undefined, undefined, { advanced: true, nexusCards: nexus(), discoveryEnabled: true,
techTokens: createTechTokens(), strongholdCards: createStrongholdCards() });
assert.equal(classicDiscoveryNexusProfile(game), true); assert.equal(supported(game), true);
const trained = { ...game, leaderSkills: true, spiceBankerIncomePreview: true };
assert.equal(classicNexusLeaderSkillsProfile(trained), true);
assert.equal(homeworldLeaderSkillsProfile(trained), true); assert.equal(supported(trained), true);
assert.equal(nexusCoreProfile(game), false, 'The non-Discovery core does not manufacture this separate admission');
});
void test('original native Nexus includes bare paired E1 and existing standalone and mixed E3 World rosters', () => {
const paired = profile(['ixians', 'tleilaxu', 'emperor'], ['ix'], { nexusCards: nexus() });
assert.equal(pairedNexusLeaderSkillsProfile(paired), true, 'The original paired configuration needs no phase module');
assert.equal(supported(paired), true);
const discovery = { ...paired, discoveryEnabled: true };
assert.equal(pairedDiscoveryNexusProfile(discovery), true); assert.equal(supported(discovery), true);
const standalone = profile(['moritani', 'emperor', 'guild'], ['ecaz'], { advanced: true,
nexusCards: nexus(), moritaniAssassinatePreview: true });
assert.equal(standaloneE3NexusProfile(standalone), true); assert.equal(supported(standalone), true);
const mixed = profile(['ixians', 'choam', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz'], {
advanced: true, nexusCards: nexus(), techTokens: createTechTokens(), strongholdCards: createStrongholdCards(),
ecazOccupyPreview: true, moritaniAssassinatePreview: true, homeworldOccupationPreview: true,
homeworldTupilePreview: true, homeworldEcazVictoryPreview: true });
assert.equal(mixedE3NexusProfile(mixed), true); assert.equal(supported(mixed), true);
const trained = { ...mixed, leaderSkills: true, discoveryEnabled: true, spiceBankerIncomePreview: true };
assert.equal(homeworldLeaderSkillsProfile(trained), true); assert.equal(supported(trained), true);
});
void test('independent cards retain already-admitted selected and unused families beside Nexus or Discovery', () => {
const selected = profile(['ecaz', 'ixians', 'emperor'], ['ix', 'choam', 'ecaz'], {
nexusCards: nexus(), ecazTreachery: true, nexusIxianBetrayalPreview: true,
ecazOccupyPreview: true, homeworldOccupationPreview: true, homeworldEcazVictoryPreview: true });
assert.equal(nexusCoreProfile(selected), true); assert.equal(supported(selected), true);
const unused = profile(undefined, ['ix', 'choam', 'ecaz'], {
discoveryEnabled: true, ecazTreachery: true, techTokens: createTechTokens() });
assert.equal(independentDiscoveryProfile(unused), true); assert.equal(supported(unused), true);
const both = profile(['ecaz', 'moritani', 'choam'], ['ix', 'choam', 'ecaz'], {
advanced: true, ecazTreachery: true, nexusCards: nexus(), discoveryEnabled: true, leaderSkills: true,
techTokens: createTechTokens(), strongholdCards: createStrongholdCards(), spiceBankerIncomePreview: true,
nexusIxianReplacementPreview: true, moritaniAssassinatePreview: true, ecazOccupyPreview: true,
homeworldOccupationPreview: true, homeworldTupilePreview: true, homeworldEcazVictoryPreview: true });
assert.equal(independentDiscoveryProfile(both), true); assert.equal(homeworldLeaderSkillsProfile(both), true);
assert.equal(supported(both), true);
});
void test('occupation selection preserves original Advanced roles, family requirements and phase limits', () => {
const guarded = profile(['ecaz', 'harkonnen', 'guild'], ['ecaz'], {
advanced: true, nexusCards: nexus(), leaderSkills: true });
assert.equal(homeworldLeaderSkillsProfile(guarded), false); assert.equal(supported(guarded), false);
const discoveryGuard = profile(['moritani', 'harkonnen', 'guild'], ['ecaz'], {
advanced: true, discoveryEnabled: true });
assert.equal(homeworldDiscoveryProfile(discoveryGuard), false); assert.equal(supported(discoveryGuard), false);
for (const game of [
profile(['ixians', 'emperor'], [], { nexusCards: nexus() }),
profile(['ixians', 'emperor', 'guild'], ['ix', 'choam'], { nexusCards: nexus() }),
profile(['atreides', 'emperor'], [], { nexusCards: nexus(), techTokens: createTechTokens() }),
profile(undefined, undefined, { nexusCards: nexus(), strongholdCards: createStrongholdCards() }),
profile(['atreides', 'atreides'], [], { nexusCards: nexus() }),
profile(undefined, ['ix', 'ix'], { discoveryEnabled: true }),
]) assert.equal(supported(game), false);
assert.equal(supported(profile(['atreides', 'emperor'], [], {
advanced: true, nexusCards: nexus(), strongholdCards: createStrongholdCards() })), true);
});
void test('unrelated previews and mismatched native, parent, reaction or Banker stamps remain outside occupation permission', () => {
const game = profile(undefined, undefined, { nexusCards: nexus() });
for (const field of ['semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview',
'richeseBetrayalPreview'] as const) assert.equal(supported({ ...game, [field]: true }), false, field);
for (const extra of [
{ ecazOccupyPreview: true }, { moritaniAssassinatePreview: true },
{ homeworldOccupationPreview: true, homeworldTupilePreview: true },
{ homeworldOccupationPreview: true, homeworldEcazVictoryPreview: true },
{ nexusIxianBetrayalPreview: true }, { nexusHarkonnenBetrayalPreview: true },
{ spiceBankerIncomePreview: true }, { ecazTreachery: 'true' },
]) assert.equal(supported({ ...game, ...extra }), false);
assert.equal(supported(profile(['atreides', 'emperor', 'harkonnen'], [], {
nexusCards: nexus(), guildBetrayalPreview: true })), false);
assert.equal(supported(profile(['ixians', 'emperor', 'guild'], ['ix'], {
nexusCards: nexus(), ecazTreachery: true, nexusIxianReplacementPreview: true })), false);
for (const field of ['homeworldOccupationPreview', 'homeworldTupilePreview', 'homeworldEcazVictoryPreview',
'ecazOccupyPreview', 'moritaniAssassinatePreview', 'spiceBankerIncomePreview', 'guildBetrayalPreview',
'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview'] as const)
assert.equal(supported({ ...game, [field]: 'forged' }), false, field);
});
void test('live native Discovery, active printed Sandtrout and occupied custody stay configuration-neutral without changing clean helpers', () => {
const custody = { visitors: { 'homeworld:ixians': { emperor: { normal: 1, elite: 1 } } } };
const discoveries = { turn: 2, tokens: [], encounters: [] };
const game = profile(['ixians', 'tleilaxu', 'emperor'], ['ix'], { nexusCards: nexus(), discoveryEnabled: true,
homeworlds: { custody, historyVersion: 1 }, homeworldOccupationPreview: true, sandtrout: true,
discoveries, discoveryStash: {}, greatMaker: { turn: 2 } });
assert.equal(pairedDiscoveryNexusProfile(game), true);
const before = structuredClone(game); Object.freeze(custody); Object.freeze(game.players); Object.freeze(game);
assert.equal(supported(game), true); assert.deepEqual(game, before);
assert.equal((game.homeworlds as { custody: unknown }).custody, custody); assert.equal(game.discoveries, discoveries);
const plain = profile(); const trained = { ...plain, leaderSkills: true };
assert.equal(clean(plain), true); assert.equal(cleanSkills(trained), true);
assert.equal(supported(plain), false); assert.equal(supported(trained), false);
assert.equal(clean({ ...plain, sandtrout: true }), false); assert.equal(cleanSkills({ ...trained, sandtrout: true }), false);
for (const value of [null, {}, { ...game, expansions: 'ix' }, { ...game, players: [{ faction: 'ixians' }, null] }])
assert.equal(supported(value as unknown as HomeworldPhaseModulesProfile), false);
});
+20 -2
View File
@@ -336,10 +336,28 @@ void test('actual CHOAM Special fuels matching Cunning once; original native pay
assert.equal(game.lastBattleContext!.winner, f.native);
assert.equal(player(game, f.native).tanks, player(beforePlans, f.native).tanks + 2);
assert.deepEqual(army(game, f.native, 'homeworld:guild'), { normal: 1, elite: 0 });
// Collection may already have opened; separate its stronghold/harvest income from support.
// Collection may already have opened. The actual victory also empties
// Junction, leaving CHOAM's one survivor as its qualified sole occupier.
// Its printed two-spice occupied award is separate from support and from
// the held Arrakeen card at another battle source.
const collection = game.phase === 7 && clean(game) ? quoteSpiceCollection(game).receipts.find(r => r.player === f.native) : undefined;
const occupied = game.homeworldOccupiedIncomeHistory!.slice(beforePlans.homeworldOccupiedIncomeHistory!.length);
assert.equal(occupied.length, 1, 'The real Collection phase records its occupied-World award once.');
const junction = occupied[0].queue.find(row => row.world === 'homeworld:guild'); assert.ok(junction);
assert.equal(junction.card, 'junction'); assert.equal(junction.native, f.guild);
assert.equal(junction.occupier, f.native); assert.equal(junction.spice, 2);
assert.deepEqual(army(game, f.guild, 'homeworld:guild'), { normal: 0, elite: 0 });
const qualification = game.homeworldOccupationHistory!.qualifications.find(row =>
row.event === junction.qualification && row.world === junction.world && row.player === f.native);
assert.ok(qualification); assert.equal(qualification.cause, 'sole');
assert.ok(!beforePlans.homeworldOccupationHistory!.qualifications.some(row => row.event === qualification.event),
'The actual World battle earns the qualification after the plans were sealed.');
const award = occupied[0].receipts.find(row => row.world === junction.world);
assert.ok(award?.status === 'settled');
assert.deepEqual(award.credits, [{ player: f.native, amount: 2 }]);
const occupiedIncome = award.credits.filter(credit => credit.player === f.native).reduce((sum, credit) => sum + credit.amount, 0);
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice - 2 +
(collection?.strongholds ?? 0) + (collection?.collected ?? 0));
(collection?.strongholds ?? 0) + (collection?.collected ?? 0) + occupiedIncome);
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
inventory(game);
});
@@ -293,6 +293,11 @@ void test('original Moritani private rescue → assassination → exact death Sk
g = advance(g, s => s.turn === turn + 1 && s.phase === 1 && clean(s), f.actions);
orderSpice(g);
g = advance(g, s => s.phase === 4 && clean(s), f.actions);
if (g.revivalOrder) {
assert.ok(g.revivalOrder.remaining.includes(f.opponent));
g = advance(g, s => s.phase === 4 && clean(s) &&
s.revivalOrder?.remaining[0] === f.opponent, f.actions);
}
if (index < discs.length - 1) reject(g, f.opponent, { type: 'reviveLeader', leader: target }, /All leaders must die/);
}
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, 5);
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame } from '../game/engine';
import { createMixedPhaseModulesFixture } from './fixture-mixed-phase-modules';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
// React SSR confirms the original controls. Recorded public actions exercise
// authoritative Start/training; no DOM callback execution is claimed here.
void test('original World Nexus and Discovery controls retain real public Start and native trained leaders', () => {
for (const advanced of [false, true]) {
const f = createMixedPhaseModulesFixture({ factions: ['atreides', 'emperor', 'fremen'], expansions: [],
advanced, homeworlds: true, nexus: true, discoveries: advanced, skills: advanced,
tech: true, strongholds: advanced,
...(advanced ? { training: { atreides: 'warmaster', emperor: 'mentat', fremen: 'swordmaster-of-ginaz' } as const } : {}) });
const render = (game: typeof f.game) => renderToStaticMarkup(createElement(GameTable, {
game: viewGame(game, game.host), busy: false, send: async () => {}, onExit() {},
}));
const html = render(f.lobby), labels = [...html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/g)];
for (const text of ['Homeworlds · preview', 'Nexus Cards', 'Tech tokens · optional',
...(advanced ? ['Discoveries', 'Leader Skills · all fourteen skill cards', 'Stronghold Cards'] : [])]) {
const label = labels.find(([, children]) => children.includes(text)); assert.ok(label, text);
assert.match(label[1], /<input\b[^>]*checked=""/);
}
const text = advanced ? 'Begin Advanced preview' : 'Begin game';
const button = [...html.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)]
.find(([, , children]) => children.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() === text);
assert.ok(button); assert.doesNotMatch(button[1], /(?:^|\s)disabled(?:=""|\s|$)/);
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === advanced));
assert.equal(f.setup.homeworldOccupationPreview, true);
if (advanced) {
const assignment = f.afterSetup.leaderSkills!.assignments.find(assignment => assignment.owner === f.game.host); assert.ok(assignment);
const native = f.afterSetup.players.find(player => player.id === assignment.owner)!.leaders.find(leader => leader.id === assignment.leader); assert.ok(native);
const playing = render(f.game);
assert.match(playing, /Public Leader Skill assignments/); assert.match(playing, /Warmaster/);
assert.ok(playing.includes(native.name)); assert.match(playing, /Inspect Stronghold Cards/);
}
}
});
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { TECH_TOKENS } from '../game/tech-tokens';
import {
completeWorldModuleOccupation, createWorldModuleOccupationFixture,
worldModuleOccupationComponents as components, worldModuleOccupationDeferred as deferred,
worldModuleOccupationPlayer as player,
} from './fixture-world-module-occupation';
const cases = [
{ label: 'Basic World Nexus Tech', advanced: false, nexus: true, discoveries: false, skills: false, tech: true, strongholds: false },
{ label: 'Basic World Discovery Skills', advanced: false, nexus: false, discoveries: true, skills: true, tech: false, strongholds: false },
{ label: 'Advanced World Nexus Discovery Skills Tech Strongholds', advanced: true, nexus: true, discoveries: true, skills: true, tech: true, strongholds: true },
] as const;
for (const options of cases) {
void test(`native ${options.label} earns Caladan occupation income beside original module continuations`, () => {
const f = createWorldModuleOccupationFixture(options);
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.game.players.map(seat => seat.faction), ['atreides', 'emperor', 'fremen']);
assert.deepEqual(f.game.expansions, []);
assert.ok(f.game.homeworlds && f.game.homeworldOccupationPreview);
assert.equal(!!f.game.nexusCards, options.nexus); assert.equal(!!f.game.discoveryEnabled, options.discoveries);
assert.equal(!!f.game.leaderSkills, options.skills); assert.equal(!!f.game.techTokens, options.tech);
assert.equal(!!f.game.strongholdCards, options.strongholds);
assert.equal(f.originalComponents.cards.length, 33);
assert.equal(f.originalComponents.skills.length, options.skills ? 14 : 0);
assert.equal(f.originalComponents.nexus.length, options.nexus ? 12 : 0);
assert.equal(f.originalComponents.spice.length, options.discoveries ? 28 : 21);
assert.equal(f.originalComponents.tokens.length, options.discoveries ? 8 : 0);
assert.deepEqual(components(f.game), f.originalComponents); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(f.evacuationSources, { 'homeworld:atreides': { normal: 10, elite: 0 } });
assert.equal(player(f.evacuation.after, f.native).reserves, 0);
assert.equal(player(f.evacuation.after, f.native).forces['arrakeen:10'], 20);
assert.equal(player(f.evacuation.before, f.native).spice - player(f.evacuation.after, f.native).spice, 10);
assert.deepEqual(f.incomingSources, { 'homeworld:emperor': { normal: 1, elite: 0 } });
assert.equal(player(f.invasion.before, f.owner).spice - player(f.invasion.after, f.owner).spice, 1);
assert.equal(player(f.invasion.before, f.owner).reserves - player(f.invasion.after, f.owner).reserves, 1);
assert.equal(player(f.invasion.after, f.owner).elites?.reserves, player(f.invasion.before, f.owner).elites?.reserves);
assert.deepEqual(f.invasion.after.homeworlds?.custody?.salusa, f.invasion.before.homeworlds?.custody?.salusa);
assert.deepEqual(f.invasion.after.homeworlds?.custody?.visitors[f.world]?.[f.owner], { normal: 1, elite: 0 });
assert.equal(f.entitlement.qualification, f.qualification.event); assert.equal(f.entitlement.spice, 2);
assert.equal(f.entitlement.occupier, f.owner); assert.equal(f.entitlement.ally, null);
if (options.skills) {
assert.equal(f.trainingActions.length, 3);
for (const [actor, skill] of Object.entries(f.training!)) {
const assignment = viewGame(f.game, actor).leaderSkills!.assignments.find(row => row.owner === actor);
assert.ok(assignment); assert.equal(assignment.skill, skill);
assert.equal(assignment.faceUp, true); assert.equal(assignment.captured, false);
assert.ok(f.trainingActions.some(step => step.actor === actor && step.action.skill === skill && step.action.leader === assignment.leader));
}
assert.deepEqual(f.bankerSource!.source.bankLegs, [{ payer: f.native, amount: 10 }]);
assert.equal(f.bankerSource!.grant!.owner, f.owner); assert.equal(deferred(f.game, f.owner), 1);
assert.equal(player(f.evacuation.after, f.owner).spice, player(f.evacuation.before, f.owner).spice);
} else assert.equal(f.bankerSource, null);
const before = structuredClone(f.game), ordinary = quoteSpiceCollection(f.game).receipts;
const techBefore = structuredClone(f.game.techTokens);
const result = completeWorldModuleOccupation(f);
assert.deepEqual(f.game, before, 'The native source remains reusable before its actual final callback');
assert.deepEqual(result.collection.steps[0], { actor: f.current, action: f.action });
assert.equal(result.collection.after.phase, 7);
assert.deepEqual(result.incomeReceipt, { world: f.world, status: 'settled', ownAmount: 2,
credits: [{ player: f.owner, amount: 2 }] });
for (const seat of before.players) {
const receipt = ordinary.find(receipt => receipt.player === seat.id);
const nativeIncome = receipt ? receipt.strongholds + receipt.collected : 0;
assert.equal(player(result.collection.after, seat.id).spice - seat.spice,
nativeIncome + (seat.id === f.owner ? 2 : 0) + (seat.id === f.techOwner ? f.techAmount : 0));
}
assert.equal(player(result.collection.after, f.native).spice - player(before, f.native).spice,
(options.advanced ? 2 : 0) + (f.techOwner === f.native ? f.techAmount : 0), 'Ordinary Arrakeen income remains separate');
assert.equal(deferred(result.collection.after, f.owner), options.skills ? 1 : 0);
if (options.tech) {
assert.ok(f.techOwner); assert.ok(f.techAmount > 0);
assert.equal(result.collection.after.techTokens!.heighliners.spice, 0);
for (const { id } of TECH_TOKENS) assert.equal(result.game.techTokens![id].owner, techBefore![id].owner);
}
assert.equal(player(result.mentat.after, f.owner).spice - player(result.mentat.before, f.owner).spice, options.skills ? 1 : 0);
if (options.skills) {
assert.deepEqual(result.bankerCollection!.credits, [{ owner: f.owner, amount: 1 }]);
assert.deepEqual(result.bankerCollection!.grants, [f.bankerSource!.cursor]);
assert.equal(result.bankerCollection!.phase, 8);
assert.equal(result.game.spiceBankerIncome!.sources.filter(receipt => receipt.grant !== null).length, 1);
}
assert.equal(deferred(result.game, f.owner), 0);
if (options.strongholds) {
assert.equal(result.game.strongholdCards!.claimedTurn, 1);
assert.equal(result.game.strongholdCards!.owners.arrakeen, f.native);
assert.equal(result.game.strongholdCards!.owners.sietch_tabr, 'fremen');
}
if (options.discoveries) {
assert.equal(f.moduleSpiceOrder.length, 1); assert.ok(f.discoveryPlacement && f.discoveryMovement && f.token);
assert.equal(f.token.face, 'cistern'); assert.equal(f.token.revealedTurn, null);
assert.deepEqual(f.discoveryMovement.steps[0], { actor: f.native, action: {
type: 'move', from: 'arrakeen:10', amount: 1, elite: 0, territory: 'gara_kulon', sector: 8 } });
assert.equal(player(f.game, f.native).forces['gara_kulon:8'], 1);
assert.equal(player(f.game, f.native).forces['arrakeen:10'], 19);
assert.ok(result.revelation && result.beforeEntry && result.entry && result.entryAction);
assert.deepEqual(result.revelation.steps.map(step => step.action), [
{ type: 'discovery', token: f.token.id, reveal: false }, { type: 'discovery', token: f.token.id, reveal: true },
]);
assert.equal(result.beforeEntry.decision?.kind, 'discoveryEntry');
assert.equal(result.beforeEntry.decision!.player, f.native);
assert.deepEqual(result.entry.steps[0], { actor: f.native, action: result.entryAction });
assert.equal(player(result.entry.after, f.native).forces['gara_kulon:8'], undefined);
assert.equal(player(result.entry.after, f.native).forces['cistern:0'], 1);
assert.equal(player(result.entry.after, f.native).spice, player(result.entry.before, f.native).spice);
assert.equal(player(result.entry.after, f.native).moved, player(result.entry.before, f.native).moved);
assert.equal(player(result.entry.after, f.native).reserves, player(result.entry.before, f.native).reserves);
} else assert.equal(result.entry, null);
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0);
assert.deepEqual(components(result.game), f.originalComponents);
});
}
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { leaders, spiceDeck, treacheryDeck } from '../game/cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { DISCOVERY_SPICE_CARDS, DISCOVERY_TOKENS } from '../game/discoveries';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { createOccupiedPercentageState } from '../game/homeworld-occupied-percentage';
import {
createMixedPhaseModulesFixture, mixedPhaseModulesInventory, type MixedPhaseModulesOptions,
} from './fixture-mixed-phase-modules';
function originalOccupation(game: Game) {
assert.equal(game.homeworldOccupationPreview, true);
assert.equal(game.pendingHomeworldOccupiedIncome, null);
assert.deepEqual(game.homeworldOccupiedIncomeHistory, []);
assert.ok(game.homeworldOccupiedPercentageLedger);
assert.deepEqual(game.homeworldOccupiedPercentageLedger.receipts, []);
if (game.players.some(player => player.faction === 'ecaz')) {
assert.equal(game.homeworldEcazVictoryPreview, true); assert.equal(game.ecazOccupyPreview, true);
}
}
function originalModules(game: Game, options: MixedPhaseModulesOptions) {
if (options.nexus) {
const cards = game.nexusCards?.cards; assert.ok(cards);
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(),
[...NEXUS_FACTIONS].sort());
} else assert.ok(!game.nexusCards);
if (options.discoveries) {
assert.equal(game.discoveryEnabled, true); assert.ok(game.discoveries);
assert.deepEqual(game.discoveries.tokens.map(token => token.face).sort(), DISCOVERY_TOKENS.map(token => token.id).sort());
assert.equal(new Set(game.discoveries.tokens.map(token => token.id)).size, 8);
const cards = [...game.spiceDeck, ...game.spiceDiscard.flat(),
...(game.spiceResolution?.skipped ?? []), ...(game.spiceSequence?.skipped ?? [])];
assert.equal(cards.length, spiceDeck(game.expansions.includes('ix')).length + 7);
assert.deepEqual(cards.flatMap(card => 'territory' in card && card.discovery ? [card.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(card => card.discovery).sort());
assert.equal(cards.filter(card => 'worm' in card && card.greatMaker).length, 1);
} else assert.ok(!game.discoveryEnabled && !game.discoveries);
if (options.skills) {
const skills = game.leaderSkills; assert.ok(skills);
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(offer => offer.cards),
...skills.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
} else assert.ok(!game.leaderSkills);
}
function entry(options: MixedPhaseModulesOptions) {
const f = createMixedPhaseModulesFixture({ ...options, homeworlds: true });
assert.equal(f.setup.status, 'setup'); originalOccupation(f.setup);
assert.equal(f.game.status, 'playing'); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 1);
originalModules(f.setup, options); originalModules(f.game, options);
assert.equal(f.nexus, !!options.nexus); assert.equal(f.discoveries, !!options.discoveries);
assert.equal(f.originalInventory.length, treacheryDeck(options.expansions).length);
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory);
const firstReady = f.actions.findIndex(step => step.action.type === 'ready');
for (const [enabled, module] of [[options.nexus, 'nexusCards'], [options.discoveries, 'discoveries']] as const) {
if (!enabled) continue;
const selected = f.actions.findIndex(step => step.action.type === 'moduleOption' && step.action.module === module && step.action.enabled === true);
assert.ok(selected >= 0 && selected < firstReady); assert.equal(f.actions[selected].actor, f.initial.host);
}
for (const player of f.afterSetup.players) {
assert.deepEqual(player.leaders.map(leader => leader.id).sort(), leaders(player.faction).map(leader => leader.id).sort());
assert.ok(viewGame(f.game, player.id).homeworlds?.worlds?.some(world => world.native === player.id));
const requested = options.training?.[player.faction];
if (requested) {
const assignment = f.afterSetup.leaderSkills!.assignments.find(assignment => assignment.owner === player.id);
assert.ok(assignment); assert.equal(assignment.skill, requested);
assert.ok(player.leaders.some(leader => leader.id === assignment.leader));
assert.ok(f.actions.some(step => step.actor === player.id && step.action.type === 'leaderSkill' &&
step.action.skill === requested && step.action.leader === assignment.leader));
}
}
assert.ok(!f.game.semutaPreview && !f.game.ecazTreachery);
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.actions.length);
return f;
}
// These recipes exercise previously registered original World envelopes. They
// do not assert new roster or overlay admission.
void test('public original classic World Nexus and Discovery starts retain ordinary occupation and physical components', () => {
entry({ factions: ['atreides', 'emperor', 'fremen'], expansions: [], nexus: true, tech: true });
entry({ factions: ['atreides', 'emperor'], expansions: [], discoveries: true });
});
void test('public original Advanced World Nexus plus Discovery keeps real fourteen-card training and phase modules', () => {
entry({ factions: ['atreides', 'emperor', 'fremen'], expansions: [], advanced: true,
nexus: true, discoveries: true, skills: true, tech: true, strongholds: true,
training: { atreides: 'warmaster', emperor: 'mentat', fremen: 'swordmaster-of-ginaz' } });
});
void test('public admitted paired E1 Nexus and standalone Ecaz Discovery retain original Worlds and occupation state', () => {
entry({ factions: ['ixians', 'tleilaxu', 'guild'], expansions: ['ix'], nexus: true, tech: true });
entry({ factions: ['ecaz', 'emperor', 'guild'], expansions: ['ecaz'], discoveries: true });
});
void test('an original World Nexus lobby rejects a pre-existing occupation ledger without overwriting it', () => {
const f = createMixedPhaseModulesFixture({ factions: ['atreides', 'emperor', 'fremen'], expansions: [],
homeworlds: true, nexus: true });
// Controlled negative input only: this otherwise valid original ready lobby
// has an existing typed ledger before fresh setup is allowed to create one.
const game = structuredClone(f.lobby);
game.homeworldOccupiedPercentageLedger = createOccupiedPercentageState();
const before = structuredClone(game);
assert.throws(() => applyAction(game, game.host, { type: 'start' }));
assert.deepEqual(game, before);
});