Initialize occupation benefits in original World module setups
This commit is contained in:
1 parent
396f9e0ccb
commit
09eb857583
18 files changed
+916
-12
No files matched your search
@@ -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); };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user