Connect Homeworld occupation with original phase modules
This commit is contained in:
1 parent
0a98a92f4b
commit
7f9daa10fa
19 files changed
+796
-40
No files matched your search
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
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 {
|
||||
createMixedPhaseModulesFixture, actMixedPhaseModules, mixedPhaseModulesClean,
|
||||
mixedPhaseModulesInventory, mixedPhaseModulesPlayer, nextMixedPhaseModules,
|
||||
type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
|
||||
} from './fixture-mixed-phase-modules';
|
||||
|
||||
export type HomeworldPhaseOccupationStep = MixedPhaseModulesStep;
|
||||
export type HomeworldPhaseOccupationTransition = MixedPhaseModulesTransition;
|
||||
export const actHomeworldPhaseOccupation = actMixedPhaseModules;
|
||||
export const homeworldPhaseOccupationClean = mixedPhaseModulesClean;
|
||||
export const homeworldPhaseOccupationInventory = mixedPhaseModulesInventory;
|
||||
export const homeworldPhaseOccupationPlayer = mixedPhaseModulesPlayer;
|
||||
|
||||
/** Optional Ambassador placement is declined; all source/return/payment/phase
|
||||
* choices remain their actual native decisions. No income chooser is invented
|
||||
* for an unallied occupied-bank award, which settles automatically. */
|
||||
export function nextHomeworldPhaseOccupation(game: Game): HomeworldPhaseOccupationStep | null {
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision?.kind === 'ecazPlacement')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
return nextMixedPhaseModules(game);
|
||||
}
|
||||
export function advanceHomeworldPhaseOccupation(game: Game, until: (state: Game) => boolean,
|
||||
actions: HomeworldPhaseOccupationStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original occupied-income source ended before its next native control');
|
||||
const step = nextHomeworldPhaseOccupation(game);
|
||||
assert.ok(step, `Choose the actual native control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actHomeworldPhaseOccupation(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original occupied-income source did not reach its finite boundary');
|
||||
}
|
||||
export function finishHomeworldPhaseOccupation(game: Game, actions: HomeworldPhaseOccupationStep[] = []): Game {
|
||||
return advanceHomeworldPhaseOccupation(game, state => state.turn === 2 && state.phase === 0 && homeworldPhaseOccupationClean(state), actions);
|
||||
}
|
||||
|
||||
/** Public Atreides/Ecaz/Ixians +all3 selected decks, independent cards, Worlds,
|
||||
* Tech and optional Advanced Strongholds. Atreides actually spends its native10
|
||||
* to ship all10 Caladan normal counters into already-held Arrakeen; Ecaz then
|
||||
* pays1 for an original normal counter from its own World into empty Caladan.
|
||||
* The accepted arrival records sole occupation. Only the shared source's
|
||||
* original scalar and unopened Spice lotteries are controlled. No card, army,
|
||||
* wallet, World owner, qualification, income ledger or clock is installed.
|
||||
* Stop at the real final Movement actor before automatic Collection credit. */
|
||||
export function createHomeworldPhaseOccupationFixture(options: { advanced?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? false, native = 'atreides', owner = 'ecaz', techOwner = 'ixians';
|
||||
const world = 'homeworld:atreides', card = 'caladan' as const;
|
||||
const source = createMixedPhaseModulesFixture({ factions: [native, owner, techOwner],
|
||||
expansions: ['ix', 'choam', 'ecaz'], advanced, homeworlds: true,
|
||||
tech: true, strongholds: advanced, independent: true });
|
||||
let game = source.game;
|
||||
const actions = [...source.actions];
|
||||
const act = (actor: string, action: Action) => { game = actHomeworldPhaseOccupation(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceHomeworldPhaseOccupation(game, until, actions); };
|
||||
const transition = (run: () => void): HomeworldPhaseOccupationTransition => {
|
||||
const before = structuredClone(game), from = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
};
|
||||
assert.ok(game.homeworldOccupationPreview && game.homeworldOccupationHistory);
|
||||
assert.equal(game.homeworldOccupationHistory.qualifications.length, 0);
|
||||
assert.equal(game.techTokens?.heighliners.owner, techOwner);
|
||||
reach(state => state.phase === 5 && state.active === native && homeworldPhaseOccupationClean(state));
|
||||
assert.equal(homeworldPhaseOccupationPlayer(game, native).reserves, 10);
|
||||
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(homeworldPhaseOccupationClean); });
|
||||
assert.equal(homeworldPhaseOccupationPlayer(game, native).reserves, 0);
|
||||
assert.equal(homeworldPhaseOccupationPlayer(game, native).forces['arrakeen:10'], 20);
|
||||
assert.equal(homeworldPhaseOccupationPlayer(evacuation.before, native).spice - homeworldPhaseOccupationPlayer(game, native).spice, 10);
|
||||
assert.equal(quoteStableHomeworldOccupation(game, card).entitlement, null);
|
||||
reach(state => state.phase === 5 && state.active === owner && homeworldPhaseOccupationClean(state));
|
||||
const incomingSources = nativeShipmentSources(viewGame(game, owner), 1, 0); assert.ok(incomingSources);
|
||||
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(homeworldPhaseOccupationClean); });
|
||||
assert.equal(homeworldPhaseOccupationPlayer(invasion.before, owner).spice - homeworldPhaseOccupationPlayer(game, owner).spice, 1);
|
||||
assert.deepEqual(game.homeworlds?.custody?.visitors[world]?.[owner], { normal: 1, elite: 0 });
|
||||
const qualification = game.homeworldOccupationHistory!.qualifications.find(q => q.world === world && q.player === owner);
|
||||
assert.ok(qualification, 'The actual accepted sole arrival records 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);
|
||||
homeworldGameIntegrity(game);
|
||||
reach(state => state.phase === 5 && state.active === techOwner && homeworldPhaseOccupationClean(state));
|
||||
assert.equal(game.order.at(-1), techOwner);
|
||||
assert.equal(game.techTokens?.heighliners.spice, 1);
|
||||
assert.deepEqual(homeworldPhaseOccupationInventory(game), source.originalInventory);
|
||||
return { ...source, game, sourceGame: structuredClone(game), advanced, native, owner, techOwner,
|
||||
world, card, current: techOwner, action: { type: 'endMovement' } as Action,
|
||||
evacuationSources, evacuationAction, evacuation, incomingSources, invasionQuote, invasionAction, invasion,
|
||||
qualification: structuredClone(qualification), entitlement: structuredClone(stable.entitlement),
|
||||
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
|
||||
staging: [] as string[] };
|
||||
}
|
||||
export type HomeworldPhaseOccupationFixture = ReturnType<typeof createHomeworldPhaseOccupationFixture>;
|
||||
|
||||
/** The original final Movement action pays its pending Tech first; original
|
||||
* Collection then automatically credits the unallied occupier's printed2.
|
||||
* Actual END Mentat later claims the six separate Stronghold cards. */
|
||||
export function completeHomeworldPhaseOccupation(f: HomeworldPhaseOccupationFixture, state = f.game) {
|
||||
let game = state;
|
||||
const actions: HomeworldPhaseOccupationStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actHomeworldPhaseOccupation(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceHomeworldPhaseOccupation(game, until, actions); };
|
||||
const transition = (run: () => void): HomeworldPhaseOccupationTransition => {
|
||||
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 && homeworldPhaseOccupationClean(state)); });
|
||||
const history = game.homeworldOccupiedIncomeHistory?.find(receipt => receipt.turn === game.turn &&
|
||||
receipt.queue.some(row => row.world === f.world)); assert.ok(history);
|
||||
const incomeReceipt = history.receipts.find(receipt => receipt.world === f.world); assert.ok(incomeReceipt);
|
||||
const mentat = transition(() => { game = finishHomeworldPhaseOccupation(game, actions); });
|
||||
homeworldGameIntegrity(game);
|
||||
assert.deepEqual(homeworldPhaseOccupationInventory(game), f.originalInventory);
|
||||
return { game, collection, incomeHistory: structuredClone(history), incomeReceipt: structuredClone(incomeReceipt),
|
||||
mentat, actions, actionCount: actions.length };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES, MOBILE_STRONGHOLD, location, territory } from '../game/board';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { withNativeShipmentSources } from '../game/homeworld-options';
|
||||
import { actAdvisorPreparation } from './fixture-advisor-preparation';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
@@ -14,6 +15,7 @@ export type MixedPhaseModulesOptions = {
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
independent?: boolean;
|
||||
homeworlds?: boolean;
|
||||
};
|
||||
export const MIXED_PHASE_NATIVE_FACTIONS = ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const;
|
||||
export const actMixedPhaseModules = actAdvisorPreparation;
|
||||
@@ -75,6 +77,7 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
|
||||
for (const faction of others) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: MixedPhaseModulesStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
|
||||
if (options.homeworlds) act(host, { type: 'homeworlds', 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 });
|
||||
@@ -98,6 +101,7 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
|
||||
const firstStorm = structuredClone(game);
|
||||
assert.deepEqual(mixedPhaseModulesInventory(game), originalInventory);
|
||||
return { game, sourceGame: structuredClone(game), independent: options.independent ?? false,
|
||||
homeworlds: options.homeworlds ?? false,
|
||||
initial, lobby, setup, afterSetup, firstStorm,
|
||||
originalInventory, originalSpice, spiceOrder, actions, sourceActions: structuredClone(actions),
|
||||
sourceActionCount: actions.length, staging: [] as string[] };
|
||||
@@ -107,13 +111,21 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
|
||||
* accrue Heighliners once; native first Mentat claims precede a real later
|
||||
* Storm casualty (Basic dials2 / Advanced card1) and that owner's free return. Sources remain ordinary public
|
||||
* entry plus original native choices, with no played-state installation. */
|
||||
export function createMixedPhaseModulesProgramme(options: { advanced?: boolean; strongholds?: boolean; independent?: boolean } = {}) {
|
||||
export function createMixedPhaseModulesProgramme(options: {
|
||||
advanced?: boolean; strongholds?: boolean; independent?: boolean; homeworlds?: 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 });
|
||||
expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds,
|
||||
independent: options.independent, homeworlds: options.homeworlds });
|
||||
let game = source.game;
|
||||
const actions = [...source.actions];
|
||||
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
|
||||
const ship = (actor: string, action: Action) => {
|
||||
const selected = game.homeworlds ? withNativeShipmentSources(viewGame(game, actor), action) : action;
|
||||
assert.ok(selected, 'The current native World pools must fund the exact typed shipment');
|
||||
act(actor, selected);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceMixedPhaseModules(game, until, actions); };
|
||||
const transition = (run: () => void): MixedPhaseModulesTransition => {
|
||||
const before = structuredClone(game), from = actions.length; run();
|
||||
@@ -123,7 +135,7 @@ export function createMixedPhaseModulesProgramme(options: { advanced?: boolean;
|
||||
const tabr = territory('sietch_tabr').sectors[0], choamLocation = location('sietch_tabr', tabr);
|
||||
assert.ok(game.players.every(player => !player.forces[choamLocation]));
|
||||
const choamShipment = transition(() => {
|
||||
act('choam', { type: 'ship', territory: 'sietch_tabr', sector: tabr, amount: 1, elite: 0, allyPayment: 0 });
|
||||
ship('choam', { type: 'ship', territory: 'sietch_tabr', sector: tabr, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(mixedPhaseModulesClean);
|
||||
});
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === 'moritani' && mixedPhaseModulesClean(state));
|
||||
@@ -133,7 +145,7 @@ export function createMixedPhaseModulesProgramme(options: { advanced?: boolean;
|
||||
assert.ok(target, 'The next original Storm sector has an empty desert shipment destination');
|
||||
const casualtyLocation = location(target.id, sector);
|
||||
const moritaniShipment = transition(() => {
|
||||
act('moritani', { type: 'ship', territory: target.id, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
ship('moritani', { type: 'ship', territory: target.id, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(mixedPhaseModulesClean);
|
||||
});
|
||||
// Capture the actual phase-closing action, rather than folding later income
|
||||
|
||||
@@ -7,6 +7,7 @@ import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import { ixHomeworldRevivalEnabled, quoteIxSuboidBonus } from '../game/ix-homeworld-revival';
|
||||
import type { IndependentCardModulesOptions } from './fixture-independent-card-modules';
|
||||
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
|
||||
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
|
||||
@@ -47,6 +48,8 @@ export const nativeWorldRecruitsClean = (game: Game) =>
|
||||
* An unrequested Face Dancer is declined; every other window keeps its original
|
||||
* continuation and the whole remaining programme is driven by it. */
|
||||
export function nextNativeWorldRecruitsStep(game: Game): NativeWorldRecruitsStep | null {
|
||||
if (game.phase === 4 && nativeWorldRecruitsClean(game) && !game.pendingRevival && game.revivalOrder?.remaining[0])
|
||||
return { actor: game.revivalOrder.remaining[0], action: { type: 'ready' } };
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'faceDance')
|
||||
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
|
||||
return nextHarassHomeworldStep(game);
|
||||
@@ -182,6 +185,7 @@ export function createNativeWorldRecruitsFixture(options: {
|
||||
assert.equal(nativeWorldRecruitsPlayer(game, native).tanks, native === 'ixians' ? 14 : 20);
|
||||
game = advanceNativeWorldRecruits(game,
|
||||
g => g.turn === 2 && g.phase === 4 && nativeWorldRecruitsClean(g), steps);
|
||||
game = awaitNativeWorldRecruitsActor(game, options.imperialCasualties ? visitor : native, steps);
|
||||
takeHarassCard(game, owner, RECRUITS_CARD_ID);
|
||||
staging.push(`Conserve the existing physical Recruits to ${owner} at the real second Revival; this is not a purchase.`);
|
||||
assertNativeWorldRecruitsInventory(game);
|
||||
@@ -190,13 +194,34 @@ export function createNativeWorldRecruitsFixture(options: {
|
||||
location, steps, staging };
|
||||
}
|
||||
|
||||
/** Complete only preceding original owners. A finished opportunity cannot be
|
||||
* reopened; Basic and an unstamped legacy phase retain concurrent timing. */
|
||||
export function awaitNativeWorldRecruitsActor(game: Game, actor: string,
|
||||
preparation: NativeWorldRecruitsStep[] = []): Game {
|
||||
while (game.revivalOrder && game.revivalOrder.remaining[0] !== actor) {
|
||||
assert.ok(game.revivalOrder.remaining.includes(actor), 'The requested native Revival opportunity must still be pending.');
|
||||
const preceding = game.revivalOrder.remaining[0]; assert.ok(preceding);
|
||||
const step: NativeWorldRecruitsStep = { actor: preceding, action: { type: 'ready' } };
|
||||
preparation.push(step); game = applyAction(game, step.actor, step.action);
|
||||
game = advanceNativeWorldRecruits(game, state => state.phase === 4 && nativeWorldRecruitsClean(state), preparation);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Capture the quote, accepted request, response/deployment choices and physical
|
||||
* deposit. Keep invoice price separate from Tleilaxu's own free-return income. */
|
||||
export function requestNativeWorldRecruits(game: Game, actor: string, amount: number, elite = 0,
|
||||
freeElite?: number) {
|
||||
const preparation: NativeWorldRecruitsStep[] = [];
|
||||
game = awaitNativeWorldRecruitsActor(game, actor, preparation);
|
||||
const before = structuredClone(game);
|
||||
const player = nativeWorldRecruitsPlayer(before, actor);
|
||||
const quote = forceRevivalQuote(before, player, amount, elite, freeElite);
|
||||
const ixSuboidBonus = player.faction === 'ixians' ? quoteIxSuboidBonus({
|
||||
enabled: ixHomeworldRevivalEnabled(before, actor) && !before.revivalRules?.freeBlocked?.includes(actor),
|
||||
amount, elite, free: quote.free, freeElite: freeElite ?? Math.min(quote.free, elite),
|
||||
normalTanks: player.tanks - (player.elites?.tanks ?? 0),
|
||||
}) : 0;
|
||||
const step: NativeWorldRecruitsStep = { actor,
|
||||
action: { type: 'revive', amount, elite, ...(freeElite === undefined ? {} : { freeElite }) } };
|
||||
const nativeBefore = combatArmy(before, actor, `homeworld:${player.faction}`);
|
||||
@@ -204,7 +229,7 @@ export function requestNativeWorldRecruits(game: Game, actor: string, amount: nu
|
||||
const continuations: NativeWorldRecruitsStep[] = [];
|
||||
game = advanceNativeWorldRecruits(requested, nativeWorldRecruitsClean, continuations);
|
||||
const after = nativeWorldRecruitsPlayer(game, actor);
|
||||
return { before, requested, game, step, continuations, quote,
|
||||
return { before, requested, game, step, preparation, continuations, quote, ixSuboidBonus,
|
||||
rate: viewGame(before, actor).revival,
|
||||
price: quote.cost, walletDelta: player.spice - after.spice,
|
||||
free: (after.freeForcesRevived ?? 0) - (player.freeForcesRevived ?? 0),
|
||||
|
||||
@@ -60,6 +60,16 @@ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptio
|
||||
let lobby = createGame('WORLDPHASERECRUITS', newPlayer(emperor, 'Emperor', 'emperor'), advanced);
|
||||
for (const [id, faction] of [[fremen, 'fremen'], [atreides, 'atreides'], [guild, 'guild']] as const)
|
||||
joinGame(lobby, newPlayer(id, faction, faction));
|
||||
const lobbySteps: RecruitsHomeworldStep[] = [];
|
||||
if (advanced) {
|
||||
// First Storm0 ends at1; the native later card1..6 ends at2..7.
|
||||
// Actual printed circles14/17 therefore keep Atreides before Fremen,
|
||||
// without completing the latter before its later typed requests.
|
||||
for (const [actor, position] of [[atreides, 5], [fremen, 6]] as const) {
|
||||
const action: Action = { type: 'seatPosition', position };
|
||||
lobby = applyAction(lobby, actor, action); lobbySteps.push({ actor, action });
|
||||
}
|
||||
}
|
||||
lobby = applyAction(lobby, emperor, { type: 'homeworlds', enabled: true });
|
||||
lobby.techTokens = createTechTokens();
|
||||
if (strongholds) lobby.strongholdCards = createStrongholdCards();
|
||||
@@ -96,6 +106,13 @@ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptio
|
||||
}
|
||||
throw Error('Original World revival decisions did not settle');
|
||||
};
|
||||
const awaitOwner = (actor: string) => {
|
||||
while (game.revivalOrder && game.revivalOrder.remaining[0] !== actor) {
|
||||
assert.ok(game.revivalOrder.remaining.includes(actor), 'The later native request must retain its pending opportunity.');
|
||||
const preceding = game.revivalOrder.remaining[0]; assert.ok(preceding);
|
||||
act(preceding, { type: 'ready' }); settle();
|
||||
}
|
||||
};
|
||||
const actions = {
|
||||
paidReturn: { type: 'revive', amount: 3, elite: 0 },
|
||||
readyAtreides: { type: 'ready' },
|
||||
@@ -112,13 +129,16 @@ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptio
|
||||
atreides: structuredClone(viewGame(game, atreides).revival),
|
||||
};
|
||||
const wallets = Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
awaitOwner(atreides);
|
||||
act(atreides, actions.paidReturn); settle();
|
||||
const afterPaidReturn = structuredClone(game);
|
||||
act(atreides, actions.readyAtreides);
|
||||
if (!advanced) act(atreides, actions.readyAtreides);
|
||||
const beforePlay = structuredClone(game);
|
||||
act(emperor, actions.play);
|
||||
const afterPlay = structuredClone(game);
|
||||
act(atreides, actions.reopenedReturn); settle();
|
||||
if (advanced) act(atreides, actions.readyAtreides);
|
||||
awaitOwner(fremen);
|
||||
const beforeFremen = structuredClone(game);
|
||||
act(fremen, actions.typedReturn); settle();
|
||||
const afterTypedReturn = structuredClone(game);
|
||||
@@ -149,11 +169,12 @@ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptio
|
||||
doubled: { fremenFree: 7, atreidesFree: 4, limit: 7 },
|
||||
afterThreshold: { freeRemaining: 5, forcesRemaining: 5, homeworldBonus: 0 },
|
||||
},
|
||||
nativeSteps: source.nativeSteps, steps,
|
||||
nativeSteps: [...lobbySteps, ...source.nativeSteps], steps,
|
||||
staging: [...source.staging,
|
||||
'Select unused original Tech Tokens and optional Advanced Strongholds in the undealt lobby, before readiness and original setup.',
|
||||
'Fix only the original first-Storm unused-token shuffle; restore native later Storm entropy. Custody and held-token count are read, never assigned.',
|
||||
'Atreides pays for three original native returns before Recruits; its recorded paid counter remains paid after reopening. Fremen then returns one typed Fedaykin and five normal counters on separate current-rate requests.',
|
||||
...(advanced ? ['Select original lobby circles14 for Atreides and17 for Fremen. Finish preceding native turns; keep Atreides current through both requests before advancing to still-pending Fremen. No played queue or phase is changed.'] : []),
|
||||
'Atreides pays for three original native returns before Recruits; its recorded paid counter remains paid after activation. Basic retains ready/reopening; Advanced keeps that opportunity open. Fremen then returns one typed Fedaykin and five normal counters on separate current-rate requests.',
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
homeworldPhaseModulesProfile as supported, type HomeworldPhaseModulesProfile,
|
||||
} from '../game/homeworld-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 };
|
||||
}
|
||||
|
||||
void test('classic empty-deck Homeworld starts permit both modes and independent cards without requiring phase modules', () => {
|
||||
for (const advanced of [false, true]) for (const ecazTreachery of [undefined, false, true])
|
||||
assert.equal(supported(profile(undefined, undefined, { advanced, ecazTreachery })), true);
|
||||
assert.equal(supported(profile(undefined, undefined, { homeworldOccupationPreview: true })), true);
|
||||
assert.equal(supported(profile(undefined, undefined, { homeworlds: null })), false);
|
||||
assert.equal(supported(profile(undefined, undefined, { advanced: 'advanced' as unknown as boolean })), false);
|
||||
});
|
||||
|
||||
void test('selected native, paired, mixed and unused family decks retain their actual Homeworld roster', () => {
|
||||
for (const game of [
|
||||
profile(['ecaz', 'moritani', 'guild'], ['ecaz']),
|
||||
profile(['ecaz', 'ixians', 'emperor'], ['ix', 'ecaz']),
|
||||
profile(['moritani', 'harkonnen', 'guild'], ['ecaz'], { advanced: true }),
|
||||
profile(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz'], { advanced: true }),
|
||||
profile(['atreides', 'emperor', 'guild'], ['ix', 'choam', 'ecaz']),
|
||||
profile(['ixians', 'guild', 'emperor'], ['ix', 'choam', 'ecaz']),
|
||||
]) assert.equal(supported(game), true);
|
||||
});
|
||||
|
||||
void test('Homeworld Tech retains three-six seats and Strongholds retain Advanced two-six with both requiring three', () => {
|
||||
const two = profile(['atreides', 'emperor']);
|
||||
assert.equal(supported(two), true);
|
||||
assert.equal(supported({ ...two, techTokens: createTechTokens() }), false);
|
||||
assert.equal(supported({ ...two, strongholdCards: createStrongholdCards() }), false);
|
||||
assert.equal(supported({ ...two, advanced: true, strongholdCards: createStrongholdCards() }), true);
|
||||
assert.equal(supported({ ...two, advanced: true, strongholdCards: createStrongholdCards(), techTokens: createTechTokens() }), false);
|
||||
assert.equal(supported(profile(undefined, undefined, { ecazTreachery: true, techTokens: createTechTokens() })), true);
|
||||
assert.equal(supported(profile(undefined, undefined, { advanced: true, ecazTreachery: true,
|
||||
techTokens: createTechTokens(), strongholdCards: createStrongholdCards() })), true);
|
||||
});
|
||||
|
||||
void test('unknown, duplicate, missing-family and out-of-bounds rosters do not create a fictitious selected deck', () => {
|
||||
for (const game of [
|
||||
profile(['ecaz', 'guild', 'emperor']), profile(['ixians', 'guild', 'emperor'], ['choam']),
|
||||
profile(undefined, ['ix', 'ix']), profile(undefined, ['unknown']),
|
||||
profile(undefined, ['ix', 'choam', 'ecaz', 'ix']), profile(['unknown', 'guild']),
|
||||
profile(['atreides', 'atreides']), profile(['atreides']),
|
||||
profile(['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit', 'harkonnen', 'ecaz'], ['ecaz']),
|
||||
]) assert.equal(supported(game), false);
|
||||
for (const value of [null, {}, { ...profile(), expansions: 'ix' },
|
||||
{ ...profile(), players: 'atreides,emperor' }, { ...profile(), players: [{ faction: 'atreides' }, null] }])
|
||||
assert.equal(supported(value as unknown as HomeworldPhaseModulesProfile), false);
|
||||
});
|
||||
|
||||
void test('independent-card selection is an exact optional boolean rather than a truthy capability', () => {
|
||||
for (const ecazTreachery of [undefined, false, true]) assert.equal(supported(profile(undefined, undefined, { ecazTreachery })), true);
|
||||
for (const ecazTreachery of [null, 0, 1, 'true', {}]) assert.equal(supported(profile(undefined, undefined, { ecazTreachery })), false);
|
||||
});
|
||||
|
||||
void test('Skills, Nexus, Discovery, active Sandtrout and unrelated reaction selectors remain outside the clean World scope', () => {
|
||||
for (const field of [
|
||||
'leaderSkills', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'sandtrout',
|
||||
'spiceBankerIncomePreview', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview',
|
||||
'guildBetrayalPreview', 'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview',
|
||||
'nexusHarkonnenBetrayalPreview',
|
||||
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
|
||||
});
|
||||
|
||||
void test('canonical occupation and native benefit stamps retain their actual seated source and required parent', () => {
|
||||
const native = profile(['ecaz', 'moritani', 'choam'], ['choam', 'ecaz'], { advanced: true,
|
||||
ecazOccupyPreview: true, moritaniAssassinatePreview: true, homeworldOccupationPreview: true,
|
||||
homeworldTupilePreview: true, homeworldEcazVictoryPreview: true });
|
||||
assert.equal(supported(native), true);
|
||||
assert.equal(supported(profile(['ecaz', 'guild'], ['ecaz'], { ecazOccupyPreview: true })), true);
|
||||
for (const game of [
|
||||
profile(undefined, undefined, { ecazOccupyPreview: true }),
|
||||
profile(undefined, undefined, { advanced: true, moritaniAssassinatePreview: true }),
|
||||
{ ...native, advanced: false },
|
||||
profile(undefined, undefined, { homeworldOccupationPreview: true, homeworldTupilePreview: true }),
|
||||
profile(undefined, undefined, { homeworldOccupationPreview: true, homeworldEcazVictoryPreview: true }),
|
||||
{ ...native, homeworldOccupationPreview: undefined },
|
||||
]) assert.equal(supported(game), false);
|
||||
for (const field of ['ecazOccupyPreview', 'moritaniAssassinatePreview', 'homeworldOccupationPreview',
|
||||
'homeworldTupilePreview', 'homeworldEcazVictoryPreview'] as const)
|
||||
assert.equal(supported({ ...native, [field]: 'forged' }), false, field);
|
||||
});
|
||||
|
||||
void test('live occupied custody is configuration-neutral and the predicate never clones, resets or repairs physical state', () => {
|
||||
const custody = { visitors: { 'homeworld:atreides': { emperor: { normal: 1, elite: 1 } } },
|
||||
salusa: { normal: 0, elite: 4 } };
|
||||
const game = profile(undefined, undefined, { advanced: true, homeworlds: { custody, historyVersion: 1 },
|
||||
homeworldOccupationPreview: true, ecazTreachery: true, techTokens: createTechTokens(), strongholdCards: createStrongholdCards() });
|
||||
const before = structuredClone(game);
|
||||
Object.freeze(custody); Object.freeze(game.homeworlds); 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);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
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();
|
||||
|
||||
// Original public actions drive setup. React SSR supplies visible-control
|
||||
// evidence without claiming that these stateful components received DOM clicks.
|
||||
void test('World and phase module controls retain their checked state and actual Basic/Advanced Start paths', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createMixedPhaseModulesFixture({ factions: ['ixians', 'ecaz', 'moritani'], expansions: ['ix', 'ecaz'],
|
||||
advanced, homeworlds: true, tech: true, strongholds: advanced, independent: advanced });
|
||||
const render = (game: typeof f.game) => renderToStaticMarkup(createElement(GameTable, {
|
||||
game: viewGame(game, game.host), busy: false, send: async () => {}, onExit() {},
|
||||
}));
|
||||
const html = render(f.lobby);
|
||||
const labels = [...html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/g)];
|
||||
for (const text of ['Homeworlds · preview', 'Tech tokens · optional', ...(advanced ? ['Stronghold Cards', 'Ecaz Treachery 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.actor === f.initial.host && step.action.type === 'homeworlds' && step.action.enabled === true));
|
||||
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === advanced));
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.homeworldOccupationPreview, true);
|
||||
const playing = render(f.game);
|
||||
assert.match(playing, /Heighliners/); assert.match(playing, /Homeworld|Grumman|Tleilax/);
|
||||
if (advanced) assert.match(playing, /Inspect Stronghold Cards/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { initializeHomeworldOccupationGameForAudit, viewGame, type Game } from '../game/engine';
|
||||
import { MOBILE_STRONGHOLD } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import {
|
||||
createMixedPhaseModulesFixture, createMixedPhaseModulesProgramme,
|
||||
mixedPhaseModulesClean, mixedPhaseModulesInventory, mixedPhaseModulesPlayer as player,
|
||||
type MixedPhaseModulesOptions,
|
||||
} from './fixture-mixed-phase-modules';
|
||||
|
||||
function freshWorlds(game: Game) {
|
||||
assert.ok(game.homeworlds); assert.equal(game.homeworlds.custody, null);
|
||||
assert.equal(game.homeworldOccupationPreview, true);
|
||||
assert.equal(game.pendingHomeworldOccupiedIncome, null);
|
||||
assert.deepEqual(game.homeworldOccupiedIncomeHistory, []);
|
||||
assert.ok(game.homeworldOccupiedPercentageLedger);
|
||||
if (game.players.some(seat => seat.faction === 'choam')) {
|
||||
assert.equal(game.homeworldTupilePreview, true); assert.ok(game.homeworldTupileState);
|
||||
assert.equal(game.pendingHomeworldTupileCleanup, null);
|
||||
}
|
||||
if (game.players.some(seat => seat.faction === 'ecaz')) {
|
||||
assert.equal(game.homeworldEcazVictoryPreview, true); assert.equal(game.ecazOccupyPreview, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Source/final component facts only, without a save/custody campaign. Native
|
||||
* World pools are the physical reserve locations, rather than extra counters. */
|
||||
function components(game: Game) {
|
||||
const worlds = viewGame(game, game.host).homeworlds?.worlds; assert.ok(worlds?.length);
|
||||
for (const seat of game.players) {
|
||||
const native: number = worlds.filter(world => world.native === seat.id)
|
||||
.reduce((total, world) => total + world.forces[seat.id].normal + world.forces[seat.id].elite, 0);
|
||||
const visitors: number = worlds.filter(world => world.native !== seat.id)
|
||||
.reduce((total, world) => total + (world.forces[seat.id]?.normal ?? 0) + (world.forces[seat.id]?.elite ?? 0), 0);
|
||||
assert.equal(native, seat.reserves);
|
||||
assert.equal(seat.reserves + seat.tanks + visitors + Object.values(seat.forces).reduce((a, b) => a + b, 0), 20);
|
||||
}
|
||||
}
|
||||
|
||||
void test('the explicit legacy occupation Skills entry does not inherit clean World phase-module admission', () => {
|
||||
const f = createMixedPhaseModulesFixture({ factions: ['atreides', 'emperor', 'guild'], expansions: [],
|
||||
advanced: true, homeworlds: true, tech: true });
|
||||
const before = structuredClone(f.lobby);
|
||||
assert.throws(() => initializeHomeworldOccupationGameForAudit(f.lobby, true), /Occupation requires/);
|
||||
assert.deepEqual(f.lobby, before);
|
||||
});
|
||||
|
||||
void test('public bare Basic Worlds start mixed selected families and classic no-deck seats with original occupation state', () => {
|
||||
const entries: MixedPhaseModulesOptions[] = [
|
||||
{ factions: ['ecaz', 'ixians', 'tleilaxu'], expansions: ['ix', 'choam', 'ecaz'] },
|
||||
{ factions: ['atreides', 'emperor'], expansions: [] },
|
||||
];
|
||||
for (const selected of entries) {
|
||||
const f = createMixedPhaseModulesFixture({ ...selected, homeworlds: true });
|
||||
assert.equal(f.initial.homeworlds, undefined); assert.deepEqual(f.lobby.homeworlds, { custody: null });
|
||||
const option = f.actions.findIndex(step => step.action.type === 'homeworlds' && step.action.enabled === true);
|
||||
const ready = f.actions.findIndex(step => step.action.type === 'ready');
|
||||
assert.ok(option >= 0 && option < ready); assert.equal(f.actions[option].actor, f.initial.host);
|
||||
assert.equal(f.setup.status, 'setup'); freshWorlds(f.setup);
|
||||
assert.equal(f.game.advanced, false); assert.equal(f.game.phase, 1); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.homeworlds, true); assert.equal(f.independent, false);
|
||||
assert.ok(!f.game.techTokens && !f.game.strongholdCards && !f.game.ecazTreachery &&
|
||||
!f.game.leaderSkills && !f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
|
||||
assert.deepEqual(f.game.expansions, selected.expansions);
|
||||
assert.equal(f.originalInventory.length, treacheryDeck(selected.expansions).length);
|
||||
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory);
|
||||
components(f.afterSetup); assert.deepEqual(f.staging, []);
|
||||
}
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`public all-six ${advanced ? 'Advanced World independent cards, Tech and Strongholds' : 'Basic World Tech'} earns native shipments, revival and delayed payouts`, () => {
|
||||
const f = createMixedPhaseModulesProgramme({ advanced, homeworlds: true, independent: advanced });
|
||||
freshWorlds(f.setup); components(f.afterSetup);
|
||||
assert.equal(f.setup.deck.length, advanced ? 50 : 47); assert.equal(f.setup.richeseCache?.length, 10);
|
||||
assert.equal(f.originalInventory.length, advanced ? 60 : 57);
|
||||
assert.equal(f.setup.ecazTreachery === true, advanced);
|
||||
assert.ok(!f.game.leaderSkills && !f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
|
||||
for (const [actor, receipt, key] of [
|
||||
['choam', f.choamShipment, f.choamLocation], ['moritani', f.moritaniShipment, f.casualtyLocation],
|
||||
] as const) {
|
||||
const action = receipt.steps[0].action;
|
||||
assert.equal(action.type, 'ship'); assert.equal(action.amount, 1); assert.equal(action.elite, 0);
|
||||
assert.deepEqual(action.homeworldSources, nativeShipmentSources(viewGame(receipt.before, actor), 1, 0));
|
||||
assert.equal(player(receipt.after, actor).reserves, player(receipt.before, actor).reserves - 1);
|
||||
assert.equal(player(receipt.after, actor).forces[key], 1);
|
||||
assert.equal(player(receipt.before, actor).spice - player(receipt.after, actor).spice, actor === 'choam' ? 1 : 2);
|
||||
const earned = ownedTech(receipt.after.techTokens, 'ixians').length;
|
||||
assert.equal(receipt.after.techTokens?.heighliners.spice, earned);
|
||||
assert.equal(receipt.after.techTokens?.heighliners.triggeredTurn, 1);
|
||||
assert.equal(player(receipt.after, 'ixians').spice, player(receipt.before, 'ixians').spice);
|
||||
}
|
||||
const shipmentIncome = f.shipmentPayout.before.techTokens!.heighliners.spice;
|
||||
assert.ok(shipmentIncome > 0); assert.equal(f.shipmentPayout.after.techTokens?.heighliners.spice, 0);
|
||||
assert.equal(player(f.shipmentPayout.after, 'ixians').spice - player(f.shipmentPayout.before, 'ixians').spice, shipmentIncome);
|
||||
if (advanced) {
|
||||
assert.equal(f.mentat.before.strongholdCards?.claimedTurn, 0);
|
||||
assert.equal(f.mentat.after.strongholdCards?.claimedTurn, 1);
|
||||
assert.equal(f.mentat.after.strongholdCards?.owners.carthag, 'moritani');
|
||||
assert.equal(f.mentat.after.strongholdCards?.owners.sietch_tabr, 'choam');
|
||||
assert.equal(f.mentat.after.strongholdCards?.owners[MOBILE_STRONGHOLD], 'ixians');
|
||||
}
|
||||
assert.equal(f.storm.before.stormMovementSource?.kind, advanced ? 'card' : 'dials');
|
||||
assert.equal(f.storm.before.stormPending, advanced ? 1 : 2);
|
||||
assert.equal(player(f.storm.after, 'moritani').tanks, 1);
|
||||
assert.equal(player(f.storm.after, 'moritani').forces[f.casualtyLocation], undefined);
|
||||
const view = viewGame(f.revival.before, 'moritani');
|
||||
assert.ok(view.revival.freeRemaining >= 1); assert.equal(view.revival.tleilaxuHomeworldIncomeBlocked, false);
|
||||
const quote = forceRevivalQuote(f.revival.before, player(f.revival.before, 'moritani'), 1, 0);
|
||||
assert.equal(quote.free, 1); assert.equal(quote.cost, 0);
|
||||
assert.deepEqual(f.revival.steps[0], { actor: 'moritani', action: { type: 'revive', amount: 1, elite: 0 } });
|
||||
if (advanced) assert.equal(f.revival.before.revivalOrder?.remaining[0], 'moritani');
|
||||
assert.equal(player(f.revival.after, 'moritani').tanks, 0);
|
||||
assert.equal(player(f.revival.after, 'moritani').reserves - player(f.revival.before, 'moritani').reserves, 1);
|
||||
assert.equal(player(f.revival.after, 'moritani').spice, player(f.revival.before, 'moritani').spice - quote.cost);
|
||||
assert.equal(player(f.revival.after, 'tleilaxu').spice - player(f.revival.before, 'tleilaxu').spice, 1);
|
||||
assert.equal(f.revival.after.techTokens?.axlotl.spice, ownedTech(f.revival.after.techTokens, 'tleilaxu').length);
|
||||
const revivalIncome = f.revivalPayout.before.techTokens!.axlotl.spice;
|
||||
assert.ok(revivalIncome > 0); assert.equal(f.revivalPayout.after.techTokens?.axlotl.spice, 0);
|
||||
assert.equal(player(f.revivalPayout.after, 'tleilaxu').spice - player(f.revivalPayout.before, 'tleilaxu').spice, revivalIncome);
|
||||
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 2); assert.ok(mixedPhaseModulesClean(f.game));
|
||||
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory); components(f.game);
|
||||
assert.deepEqual(f.staging, []);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { TECH_TOKENS } from '../game/tech-tokens';
|
||||
import {
|
||||
completeHomeworldPhaseOccupation, createHomeworldPhaseOccupationFixture,
|
||||
homeworldPhaseOccupationInventory as inventory, homeworldPhaseOccupationPlayer as player,
|
||||
} from './fixture-homeworld-phase-occupation';
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`public selected-family ${advanced ? 'Advanced Worlds Tech Strongholds' : 'Basic Worlds Tech'} pays actual sole Caladan occupation separately from phase rewards`, () => {
|
||||
const f = createHomeworldPhaseOccupationFixture({ advanced });
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.deepEqual(f.setup.players.map(seat => seat.faction), ['atreides', 'ecaz', 'ixians']);
|
||||
assert.deepEqual(f.setup.expansions, ['ix', 'choam', 'ecaz']);
|
||||
assert.ok(f.setup.homeworlds && f.setup.ecazTreachery && f.setup.techTokens);
|
||||
assert.equal(!!f.setup.strongholdCards, advanced);
|
||||
assert.ok(f.game.homeworldOccupationPreview);
|
||||
assert.ok(!f.game.nexusCards && !f.game.leaderSkills && !f.game.discoveryEnabled && !f.game.semutaPreview);
|
||||
assert.equal(f.originalInventory.length, 50); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
|
||||
assert.equal(f.evacuation.steps[0].action.type, 'ship');
|
||||
assert.equal(f.evacuation.steps[0].action.amount, 10);
|
||||
assert.deepEqual(f.evacuationSources, { 'homeworld:atreides': { normal: 10, elite: 0 } });
|
||||
assert.equal(player(f.evacuation.before, f.native).reserves, 10);
|
||||
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.equal(f.invasionAction.type, 'homeworldShip'); assert.equal(f.invasionQuote.cost, 1);
|
||||
assert.deepEqual(f.incomingSources, { 'homeworld:ecaz': { normal: 1, elite: 0 } });
|
||||
assert.deepEqual(f.invasion.after.homeworlds?.custody?.visitors[f.world]?.[f.owner], { normal: 1, elite: 0 });
|
||||
assert.equal(player(f.invasion.before, f.owner).reserves - player(f.invasion.after, f.owner).reserves, 1);
|
||||
assert.equal(f.invasion.before.homeworldOccupationHistory?.qualifications.some(q => q.world === f.world), false);
|
||||
assert.equal(f.qualification.world, f.world); assert.equal(f.qualification.player, f.owner);
|
||||
assert.ok(f.invasion.after.homeworldOccupationHistory?.sources.some(source => source.event === f.qualification.event));
|
||||
assert.equal(f.entitlement.qualification, f.qualification.event); assert.equal(f.entitlement.ally, null);
|
||||
assert.equal(f.entitlement.spice, 2);
|
||||
assert.equal(f.evacuation.after.techTokens?.heighliners.spice, 1);
|
||||
assert.equal(f.invasion.after.techTokens?.heighliners.spice, 1, 'The second paid shipment does not duplicate the per-phase token accrual');
|
||||
assert.equal(f.game.techTokens?.heighliners.owner, f.techOwner);
|
||||
assert.equal(f.game.homeworldOccupiedIncomeHistory?.length, 0);
|
||||
homeworldGameIntegrity(f.game);
|
||||
|
||||
const before = structuredClone(f.game), techBefore = structuredClone(f.game.techTokens);
|
||||
const result = completeHomeworldPhaseOccupation(f);
|
||||
assert.deepEqual(f.game, before, 'The qualified pre-Collection source remains reusable for the actual callback');
|
||||
assert.deepEqual(result.collection.steps[0], { actor: f.current, action: f.action });
|
||||
assert.equal(result.collection.before.phase, 5); assert.equal(result.collection.after.phase, 7);
|
||||
assert.equal(player(result.collection.after, f.techOwner).spice - player(result.collection.before, f.techOwner).spice, 1);
|
||||
assert.equal(result.collection.after.techTokens?.heighliners.spice, 0);
|
||||
assert.equal(player(result.collection.after, f.owner).spice - player(result.collection.before, f.owner).spice, 2);
|
||||
assert.equal(player(result.collection.after, f.native).spice,
|
||||
player(result.collection.before, f.native).spice + (advanced ? 2 : 0),
|
||||
'Advanced native Arrakeen Collection remains separate from the Ecaz occupier bank income');
|
||||
assert.equal(result.collection.after.pendingHomeworldOccupiedIncome, null);
|
||||
assert.equal(result.incomeHistory.queue.length, 1);
|
||||
assert.equal(result.incomeHistory.queue[0].qualification, f.qualification.event);
|
||||
assert.deepEqual(result.incomeReceipt, { world: f.world, status: 'settled', ownAmount: 2,
|
||||
credits: [{ player: f.owner, amount: 2 }] });
|
||||
for (const { id } of TECH_TOKENS)
|
||||
assert.equal(result.game.techTokens![id].owner, techBefore![id].owner,
|
||||
'Occupied-bank Collection transfers no original Tech custody');
|
||||
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0);
|
||||
if (advanced) {
|
||||
assert.equal(f.game.strongholdCards?.claimedTurn, 0);
|
||||
assert.ok(Object.values(f.game.strongholdCards!.owners).every(owner => owner === null));
|
||||
assert.equal(result.mentat.after.strongholdCards?.claimedTurn, 1);
|
||||
assert.equal(result.mentat.after.strongholdCards?.owners.arrakeen, f.native);
|
||||
assert.equal(result.mentat.after.strongholdCards?.owners.hidden_mobile_stronghold, f.techOwner);
|
||||
}
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory); homeworldGameIntegrity(result.game);
|
||||
assert.deepEqual(result.game.homeworlds?.custody?.visitors[f.world]?.[f.owner], { normal: 1, elite: 0 });
|
||||
assert.equal(player(result.game, f.native).reserves, 0);
|
||||
});
|
||||
}
|
||||
@@ -97,11 +97,17 @@ for (const options of [
|
||||
assert.equal(paid.free, 2);
|
||||
assert.deepEqual(paid.nativeAfter, { normal: 2, elite: 1 });
|
||||
programme.accrued(paid.game);
|
||||
let game = applyAction(paid.game, native, { type: 'ready' });
|
||||
assert.ok(game.ready.includes(native));
|
||||
let game = paid.game;
|
||||
if (!options.advanced) {
|
||||
game = applyAction(game, native, { type: 'ready' });
|
||||
assert.ok(game.ready.includes(native));
|
||||
} else {
|
||||
assert.equal(game.revivalOrder?.remaining[0], native);
|
||||
assert.equal(game.revivalOrder?.started, true, 'Keep the original Advanced opportunity open for later requests');
|
||||
}
|
||||
const beforePlayWallets = wallets(game);
|
||||
game = applyAction(game, f.owner, play);
|
||||
assert.deepEqual(wallets(game), beforePlayWallets, 'Reopening never refunds the prior paid cyborg');
|
||||
assert.deepEqual(wallets(game), beforePlayWallets, 'Recruits never refunds the prior paid cyborg');
|
||||
assert.equal(player(game, native).revived, 3);
|
||||
assert.equal(player(game, native).freeForcesRevived, 2);
|
||||
assert.equal(viewGame(game, native).revival.freeRemaining, 2);
|
||||
@@ -120,7 +126,9 @@ for (const options of [
|
||||
assert.equal(last.price, 5);
|
||||
assert.equal(last.walletDelta, 5);
|
||||
assert.equal(last.free, 0);
|
||||
assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 });
|
||||
assert.equal(last.ixSuboidBonus, 2, 'Current High Ix grants two additional physical Suboids for the paid Cyborg');
|
||||
assert.deepEqual(last.nativeAfter, { normal: 5, elite: 4 });
|
||||
assert.equal(player(last.game, native).tanks, 5);
|
||||
assert.equal(player(last.game, native).spice, player(f.game, native).spice - 8);
|
||||
assert.equal(player(last.game, native).revived, 7);
|
||||
assert.equal(player(last.game, native).freeForcesRevived, 4);
|
||||
@@ -202,10 +210,11 @@ void test('Basic all14 native Ix: actual paid cyborgs accrue Banker separately f
|
||||
const last = requestNativeWorldRecruits(crossing.game, native, 2, 1, 0);
|
||||
assert.deepEqual({ cost: last.price, wallet: last.walletDelta, free: last.free },
|
||||
{ cost: 5, wallet: 5, free: 0 });
|
||||
assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 });
|
||||
assert.equal(last.ixSuboidBonus, 2, 'The High bonus is physical stock, not another paid invoice or ordinary allowance');
|
||||
assert.deepEqual(last.nativeAfter, { normal: 5, elite: 4 });
|
||||
assert.equal(player(last.game, native).revived, 7);
|
||||
assert.equal(player(last.game, native).freeForcesRevived, 4);
|
||||
assert.equal(player(last.game, native).tanks, 7);
|
||||
assert.equal(player(last.game, native).tanks, 5);
|
||||
assert.equal(player(last.game, native).elites!.tanks, 0);
|
||||
assert.equal(deferred(last.game), 1);
|
||||
const receipt = last.game.spiceBankerIncome!.sources.at(-1)!;
|
||||
|
||||
@@ -25,7 +25,7 @@ function continueToShipment(game: Game): Game {
|
||||
for (const advanced of [false, true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
|
||||
void test(`${mode} native Ix deposits paid cyborgs and free suboids, reopens with Recruits, then reprices after population five`, () => {
|
||||
void test(`${mode} native Ix deposits paid cyborgs and free suboids, ${advanced ? 'keeps its opportunity open' : 'reopens'} with Recruits, then reprices after population five`, () => {
|
||||
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians' });
|
||||
const native = fixture.native;
|
||||
assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).spice, 10);
|
||||
@@ -39,7 +39,9 @@ for (const advanced of [false, true]) {
|
||||
assert.equal(paid.walletDelta, 3);
|
||||
assert.equal(paid.free, 2);
|
||||
assert.deepEqual(paid.nativeAfter, { normal: 2, elite: 1 });
|
||||
let game = applyAction(paid.game, native, { type: 'ready' });
|
||||
let game = paid.game;
|
||||
if (!advanced) game = applyAction(game, native, { type: 'ready' });
|
||||
else assert.equal(game.revivalOrder?.remaining[0], native);
|
||||
game = applyAction(game, fixture.owner, play);
|
||||
assert.equal(nativeWorldRecruitsPlayer(game, native).spice, 7, 'Activation never refunds the paid cyborg');
|
||||
assert.equal(viewGame(game, native).revival.freeRemaining, 2);
|
||||
@@ -56,11 +58,12 @@ for (const advanced of [false, true]) {
|
||||
const last = requestNativeWorldRecruits(game, native, 2, 1, 0);
|
||||
assert.equal(last.price, 5, 'One paid cyborg plus one paid suboid still costs five');
|
||||
assert.equal(last.walletDelta, 5);
|
||||
assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 });
|
||||
assert.equal(last.ixSuboidBonus, 2, 'A paid Cyborg at current High returns two extra physical Suboids');
|
||||
assert.deepEqual(last.nativeAfter, { normal: 5, elite: 4 });
|
||||
assert.equal(nativeWorldRecruitsPlayer(last.game, native).freeForcesRevived, 4);
|
||||
assert.equal(nativeWorldRecruitsPlayer(last.game, native).revived, 7);
|
||||
assert.equal(nativeWorldRecruitsPlayer(last.game, native).spice, 2);
|
||||
assert.equal(nativeWorldRecruitsPlayer(last.game, native).tanks, 7);
|
||||
assert.equal(nativeWorldRecruitsPlayer(last.game, native).tanks, 5);
|
||||
assert.equal(nativeWorldRecruitsPlayer(last.game, native).elites!.tanks, 0);
|
||||
rejectsUnchanged(last.game, native, { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(last.game.discard.filter(c => c.id === RECRUITS_CARD_ID).length, 1);
|
||||
@@ -180,12 +183,14 @@ for (const advanced of [false, true]) {
|
||||
continueToShipment(returned.game);
|
||||
});
|
||||
|
||||
void test(`${mode} ordinary native Richese reopens a ready revival, receives six free plus one paid, and still stops at seven`, () => {
|
||||
void test(`${mode} ordinary native Richese ${advanced ? 'keeps its opportunity open' : 'reopens a ready revival'}, receives six free plus one paid, and still stops at seven`, () => {
|
||||
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'richese' });
|
||||
const spice = nativeWorldRecruitsPlayer(fixture.game, fixture.native).spice;
|
||||
const first = requestNativeWorldRecruits(fixture.game, fixture.native, 3);
|
||||
assert.equal(first.price, 0);
|
||||
let game = applyAction(first.game, fixture.native, { type: 'ready' });
|
||||
let game = first.game;
|
||||
if (!advanced) game = applyAction(game, fixture.native, { type: 'ready' });
|
||||
else assert.equal(game.revivalOrder?.remaining[0], fixture.native);
|
||||
game = applyAction(game, fixture.owner, play);
|
||||
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 3);
|
||||
rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 5, elite: 0 });
|
||||
|
||||
@@ -43,7 +43,13 @@ for (const options of cases) {
|
||||
assert.deepEqual({ revived: paid.revived, free: paid.freeForcesRevived, reserves: paid.reserves,
|
||||
tanks: paid.tanks, spice: paid.spice },
|
||||
{ revived: 3, free: 2, reserves: 13, tanks: 7, spice: player(start, atreides).spice - 2 });
|
||||
assert.ok(f.beforePlay.ready.includes(atreides));
|
||||
if (!options.advanced) assert.ok(f.beforePlay.ready.includes(atreides));
|
||||
else {
|
||||
assert.equal(f.beforePlay.revivalOrder?.remaining[0], atreides);
|
||||
assert.equal(f.beforePlay.revivalOrder?.started, true);
|
||||
assert.equal(f.beforePlay.ready.includes(atreides), false);
|
||||
assert.equal(f.beforeFremen.revivalOrder?.remaining[0], fremen);
|
||||
}
|
||||
assert.equal(viewGame(f.afterPlay, fremen).revival.freeRemaining, 7);
|
||||
assert.equal(viewGame(f.afterPlay, fremen).revival.limit, 7);
|
||||
assert.equal(viewGame(f.afterPlay, atreides).revival.freeRemaining, 2,
|
||||
|
||||
Reference in new issue
Block a user