Initialize occupation benefits for registered World Skills starts

This commit is contained in:
Quintine committed 2026-10-26 11:59:03 +11:00
1 parent 7f9daa10fa
commit 396f9e0ccb
21 files changed
+737 -26

No files matched your search

+6 -5
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeHomeworldOccupationGameForAudit, joinGame, newPlayer,
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
@@ -189,9 +189,10 @@ export function withHomeworldBankerEntropy<T>(run: () => T): T {
}
}
/** Fresh classic Advanced Homeworld admission through the real Banker
* initializer. Only the original all-fourteen skill shuffle is controlled; the
* decks, hands, offers, traitors and native custody stay original. */
/** Fresh classic Advanced Homeworld admission through the ordinary Skills
* initializer, which also creates mandatory original occupation state. Only the
* original all-fourteen skill shuffle is controlled; decks, hands, offers,
* traitors and native custody stay original, with no occupation retrofit. */
function admitHomeworldBankerGame(): Game {
let game = createGame('HOMEWORLDBANKER', newPlayer(SEATS[0], 'Banker', SEATS[0]), true);
for (const faction of SEATS.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
@@ -200,7 +201,7 @@ function admitHomeworldBankerGame(): Game {
const canonical = LEADER_SKILL_CARDS.map(card => card.id);
const rest = canonical.filter(id => !REQUESTED_SKILLS.includes(id));
const desired = REQUESTED_SKILLS.flatMap(id => [id, rest.shift()!]).concat(rest);
return originalLottery(permutationDraws(canonical, desired), () => initializeHomeworldOccupationGameForAudit(game, true));
return originalLottery(permutationDraws(canonical, desired), () => initializeLeaderSkillsGameForAudit(game));
}
/** Genuine setup selections: each requested skill is chosen from its real
+3 -3
View File
@@ -7,7 +7,7 @@ import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupat
import {
createMixedPhaseModulesFixture, actMixedPhaseModules, mixedPhaseModulesClean,
mixedPhaseModulesInventory, mixedPhaseModulesPlayer, nextMixedPhaseModules,
type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
type MixedPhaseModulesOptions, type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
} from './fixture-mixed-phase-modules';
export type HomeworldPhaseOccupationStep = MixedPhaseModulesStep;
@@ -48,12 +48,12 @@ export function finishHomeworldPhaseOccupation(game: Game, actions: HomeworldPha
* 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 } = {}) {
export function createHomeworldPhaseOccupationFixture(options: { advanced?: boolean } & Pick<MixedPhaseModulesOptions, 'skills' | 'training'> = {}) {
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 });
tech: true, strongholds: advanced, independent: true, skills: options.skills, training: options.training });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actHomeworldPhaseOccupation(game, actor, action, actions); };
+65 -5
View File
@@ -1,10 +1,12 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { applyAction, 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 { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { actAdvisorPreparation } from './fixture-advisor-preparation';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { skillOfferRolls } from './fixture-independent-card-modules';
export type MixedPhaseModulesStep = { actor: string; action: Action };
export type MixedPhaseModulesTransition = { before: Game; steps: MixedPhaseModulesStep[]; after: Game };
@@ -16,6 +18,8 @@ export type MixedPhaseModulesOptions = {
strongholds?: boolean;
independent?: boolean;
homeworlds?: boolean;
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
};
export const MIXED_PHASE_NATIVE_FACTIONS = ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const;
export const actMixedPhaseModules = actAdvisorPreparation;
@@ -68,6 +72,26 @@ export function finishMixedPhaseModules(game: Game, actions: MixedPhaseModulesSt
return advanceMixedPhaseModules(game, state => (state.phase !== phase || state.turn !== turn) && mixedPhaseModulesClean(state), actions);
}
/** Choose only the original undealt skill lottery. Native UUID/byte entropy
* stays native, and later scalar draws retain the existing source policy. */
function startWithSkillOffers(game: Game, actor: string, action: Action,
training: Partial<Record<FactionId, LeaderSkillId>>, actions: MixedPhaseModulesStep[]): Game {
const rolls = skillOfferRolls(game, training), descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(original, crypto, [array]);
return array;
};
try {
const next = applyAction(game, actor, action);
actions.push({ actor, action: structuredClone(action) }); return next;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Public module selections, ready/start and every original setup decision.
* Only scalar lotteries and unopened Spice positions are selected. No played
* card, force, wallet, Tanks, phase, token or Stronghold owner is installed. */
@@ -78,16 +102,49 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
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.skills) act(host, { type: 'moduleOption', module: 'leaderSkills', 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 });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(host, options.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
// Fill unspecified seats without consuming a role requested for a later
// seat. The resulting full map still selects only the original lottery.
let training: Partial<Record<FactionId, LeaderSkillId>> | undefined;
if (options.skills && options.training) {
training = {};
const requested = game.players.flatMap(player => options.training?.[player.faction] ?? []);
const chosen = new Set<LeaderSkillId>();
for (const player of game.players) {
const skill = options.training[player.faction] ?? LEADER_SKILL_CARDS.find(card =>
!requested.includes(card.id) && !chosen.has(card.id))?.id;
assert.ok(skill && !chosen.has(skill), 'Choose distinct original skill offers');
training[player.faction] = skill; chosen.add(skill);
}
}
const start: Action = options.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' };
if (training) game = startWithSkillOffers(game, host, start, training, actions);
else act(host, start);
const setup = structuredClone(game); assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => !player.hand.length));
if (!options.skills) assert.ok(game.players.every(player => !player.hand.length));
const originalInventory = mixedPhaseModulesInventory(game);
game = advanceMixedPhaseModules(game, state => state.status === 'playing', actions);
if (options.skills) {
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (!game.decision && !game.response && !game.phaseOpening && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0]; assert.ok(actor);
const own = viewGame(game, actor).leaderSkills!;
const player = mixedPhaseModulesPlayer(game, actor);
const skill = training?.[player.faction] ?? own.offer?.cards.find(card => !own.unavailableSkills?.[card]);
const leader = own.eligibleLeaders[0];
assert.ok(own.offer && skill && own.offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader,
'Choose one currently offered original skill and an eligible own leader');
act(actor, { type: 'leaderSkill', event: own.offer.event, skill, leader: leader.id });
} else {
const next = nextMixedPhaseModules(game); assert.ok(next); act(next.actor, next.action);
}
}
assert.equal(game.status, 'playing');
} else game = advanceMixedPhaseModules(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3].map(position => {
@@ -102,6 +159,7 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
assert.deepEqual(mixedPhaseModulesInventory(game), originalInventory);
return { game, sourceGame: structuredClone(game), independent: options.independent ?? false,
homeworlds: options.homeworlds ?? false,
skills: options.skills ?? false,
initial, lobby, setup, afterSetup, firstStorm,
originalInventory, originalSpice, spiceOrder, actions, sourceActions: structuredClone(actions),
sourceActionCount: actions.length, staging: [] as string[] };
@@ -113,11 +171,13 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
* entry plus original native choices, with no played-state installation. */
export function createMixedPhaseModulesProgramme(options: {
advanced?: boolean; strongholds?: boolean; independent?: boolean; homeworlds?: boolean;
skills?: boolean; training?: Partial<Record<FactionId, LeaderSkillId>>;
} = {}) {
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 });
independent: options.independent, homeworlds: options.homeworlds,
skills: options.skills, training: options.training });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
+37 -2
View File
@@ -12,7 +12,7 @@ import {quoteSpiceCollection} from '../game/board-resolution-quote';
import {leaderSkillStrongholdCount} from '../game/leader-skill-battle-board';
import type {Card} from '../game/cards';
export type SpiceBankerIncomeFixtureOptions = {initial?:Game;advanced?:boolean;kind?:BankerIncomeSourceKind|'cross-shipment'|'return-shipment';ownerIsPayer?:boolean;payerFaction?:FactionId;bankerFaction?:FactionId;stageKarama?:boolean;stageIncomeCounter?:boolean;amount?:number;allySplit?:number;emperor?:boolean;guild?:boolean;seatIds?:string[];trainerSelected?:boolean;trainerDies?:boolean;trainerHidden?:boolean};
export type SpiceBankerIncomeFixtureOptions = {initial?:Game;advanced?:boolean;kind?:BankerIncomeSourceKind|'cross-shipment'|'return-shipment';ownerIsPayer?:boolean;payerFaction?:FactionId;bankerFaction?:FactionId;revivalBeforeBanker?:FactionId;stageKarama?:boolean;stageIncomeCounter?:boolean;amount?:number;allySplit?:number;emperor?:boolean;guild?:boolean;seatIds?:string[];trainerSelected?:boolean;trainerDies?:boolean;trainerHidden?:boolean};
export type SpiceBankerIncomeNativeStep = {actor:string;action:Action};
export type SpiceBankerIncomeFixture = {game:Game;beforePayment:Game;actor:string;payer:string;paymentAction:Action;owner:string;leader:string;event:string;kind:NonNullable<SpiceBankerIncomeFixtureOptions['kind']>;amount:number;bankLegs:{payer:string;amount:number}[]};
@@ -37,6 +37,10 @@ export function nextSpiceBankerIncomeNativeStep(game:Game):SpiceBankerIncomeNati
const voter=viewGame(game,game.host).battle!.traitorVoters.find(id=>game.battle!.traitorCalls[id]===undefined);
if(voter) return {actor:voter,action:{type:'traitorCall',call:false}};
}
if(game.phase===4&&game.revivalOrder) {
const actor=game.revivalOrder.remaining[0];
if(actor) return {actor,action:{type:'ready'}};
}
if([1,2,4,7,8].includes(game.phase)||(game.phase===0&&game.stormPending!==null)) {
const seat=game.players.find(p=>!game.ready.includes(p.id));
if(seat) return {actor:seat.id,action:{type:'ready'}};
@@ -101,6 +105,21 @@ function phase(game:Game,target:number):Game {
assert.equal(game.phase,target);return game;
}
/** Finish preceding native opportunities through their real Ready actions.
* Basic and an already-open unstamped Revival retain concurrent timing. */
export function awaitSpiceBankerIncomeRevivalActor(state:Game,actor:string):Game {
let game=state;
assert.equal(game.phase,4);
for(let i=0;game.revivalOrder&&i<200;i++) {
assert.ok(game.revivalOrder.remaining.includes(actor),'The requested native Revival opportunity must still be pending.');
if(game.phaseOpening||game.response||game.decision) game=step(game);
else if(game.revivalOrder.remaining[0]===actor) return game;
else game=applyAction(game,game.revivalOrder.remaining[0],{type:'ready'});
}
assert.ok(!game.revivalOrder,'The native Revival actor must be reached before continuing.');
return game;
}
/** Reuse real custody first; any explicit staging transfers the canonical card
* out of the remaining deck, without duplicating or redealing starting hands. */
function stageHeldCard(game:Game,player:Game['players'][number],matches:(card:Card)=>boolean):Card {
@@ -170,6 +189,17 @@ export function createSpiceBankerIncomeFixture(options:SpiceBankerIncomeFixtureO
const opponentFaction=bankerFaction==='harkonnen'?'atreides':'harkonnen';
joinGame(game,newPlayer(ids[1],'Opponent',opponentFaction));
}
if(options.revivalBeforeBanker) {
assert.ok(game.advanced);
const preceding=game.players.find(p=>p.faction===options.revivalBeforeBanker)!;
const banker=game.players.find(p=>p.faction===bankerFaction)!;
assert.ok(preceding&&banker&&preceding.id!==banker.id);
// First Storm0 ends at1; the native next card1..6 ends at2..7.
// Original printed circles14/17 keep the future skilled payer before
// the original Banker without reopening an already completed turn.
game=applyAction(game,preceding.id,{type:'seatPosition',position:5});
game=applyAction(game,banker.id,{type:'seatPosition',position:6});
}
}
game=setup(game);
const assignment=game.leaderSkills!.assignments.find(a=>a.skill==='spice-banker')!;
@@ -220,7 +250,11 @@ export function createSpiceBankerIncomeFixture(options:SpiceBankerIncomeFixtureO
game=stageSpiceBankerIncomeTrainerBattle(game,{trainerOwner:actor,death:true,kwisatz:kind==='kh-revival'});
game=advanceSpiceBankerIncomeToMentat(game);
}
game=phase(game,4);const payer=game.players.find(p=>p.id===actor)!;
game=phase(game,4);
const beneficiary=kind==='emperor-extra-revival'
?game.players.find(p=>p.faction!=='emperor'&&p.id!==owner)!.id:actor;
game=awaitSpiceBankerIncomeRevivalActor(game,beneficiary);
const payer=game.players.find(p=>p.id===actor)!;
if(kind==='force-revival') {
payer.reserves-=3;payer.tanks+=3;paymentAction={type:'revive',amount:3};amount=forceRevivalQuote(game,payer,3).cost;
} else if(kind==='leader-revival') {
@@ -378,6 +412,7 @@ export function createSpiceBankerIncomeReassignmentFixture(state:Game):SpiceBank
let game=stageSpiceBankerIncomeTrainerBattle(state,{death:true,reassignToOpponent:true});
game=advanceSpiceBankerIncomeToMentat(game);
game=phase(game,4);
game=awaitSpiceBankerIncomeRevivalActor(game,actor);
const trainer=game.players.find(p=>p.id===actor)!.leaders.find(l=>l.id===leader)!;
assert.ok(trainer.dead&&game.players.find(p=>p.id===actor)!.spice>=trainer.strength);
assert.ok(game.leaderSkills!.deck.slice(0,2).includes('spice-banker'),'The actual singleton return shuffle must supply the native two-card offer.');
+8 -2
View File
@@ -83,13 +83,19 @@ export function advanceTleilaxuSkillsTechToPhase(state: Game, target: number): G
assert.ok(game.phase >= target && !game.phaseOpening && !game.response && !game.decision);
return game;
}
function setup(options: TleilaxuSkillsTechOptions) {
function setup(options: TleilaxuSkillsTechOptions & { revivalTleilaxuFirst?: boolean }) {
const faction = options.winner ?? 'guild';
let initial = options.initial ? structuredClone(options.initial)
: createGame('TLEISKILLTECH', newPlayer(faction, faction, faction), options.advanced ?? true, ['ix']);
if (!options.initial) {
joinGame(initial, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
joinGame(initial, newPlayer('emperor', 'Emperor', 'emperor'));
if (options.revivalTleilaxuFirst && initial.advanced) {
// Original lobby circles keep the native Tleilaxu opportunity before
// Guild, allowing both free returns without reopening a completed turn.
initial = applyAction(initial, 'tleilaxu', { type: 'seatPosition', position: 5 });
initial = applyAction(initial, faction, { type: 'seatPosition', position: 6 });
}
}
assert.equal(initial.status, 'lobby');
assert.ok(initial.players.length >= 3 && initial.players.length <= 6);
@@ -213,7 +219,7 @@ export type TleilaxuSkillsTechRevivalFixture = {
* untrained original discs' dead states are controlled, never funding. The
* native dead disc makes genuine room in Tleilaxu's five-leader active pool. */
export function createTleilaxuSkillsTechRevivalFixture(options: Pick<TleilaxuSkillsTechOptions, 'initial' | 'advanced'> = {}): TleilaxuSkillsTechRevivalFixture {
const { game: started } = setup({ ...options, winner: 'guild' });
const { game: started } = setup({ ...options, winner: 'guild', revivalTleilaxuFirst: true });
const game = advanceTleilaxuSkillsTechToPhase(started, 4);
const tleilaxu = advancedNativePlayer(game, 'tleilaxu').id;
const other = advancedNativePlayer(game, 'guild').id;
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import {
actHomeworldPhaseOccupation, advanceHomeworldPhaseOccupation, completeHomeworldPhaseOccupation,
createHomeworldPhaseOccupationFixture, finishHomeworldPhaseOccupation,
homeworldPhaseOccupationClean, homeworldPhaseOccupationInventory, homeworldPhaseOccupationPlayer,
} from './fixture-homeworld-phase-occupation';
/** Banker observes another payer: Ecaz earns from Atreides' actual Bank10.
* Atreides cannot earn its own shipment payment through its own Banker. */
export const WORLD_SKILLS_OCCUPATION_TRAINING = {
atreides: 'warmaster', ecaz: 'spice-banker', ixians: 'mentat',
} as const;
export const actWorldSkillsOccupation = actHomeworldPhaseOccupation;
export const advanceWorldSkillsOccupation = advanceHomeworldPhaseOccupation;
export const finishWorldSkillsOccupation = finishHomeworldPhaseOccupation;
export const worldSkillsOccupationClean = homeworldPhaseOccupationClean;
export const worldSkillsOccupationInventory = homeworldPhaseOccupationInventory;
export const worldSkillsOccupationPlayer = homeworldPhaseOccupationPlayer;
export function worldSkillsOccupationSkillInventory(game: Game) {
const skills = game.leaderSkills; assert.ok(skills);
const cards = [...skills.deck, ...skills.assignments.map(assignment => assignment.skill),
...Object.values(skills.offers).flatMap(offer => offer.cards)].sort();
assert.equal(new Set(cards).size, cards.length, 'Each original Skill Card retains one physical slot');
assert.deepEqual(cards, LEADER_SKILL_CARDS.map(card => card.id).sort()); return cards;
}
export function worldSkillsOccupationDeferred(game: Game, owner = 'ecaz') {
return viewGame(game, owner).spiceBankerIncome?.deferred.find(credit => credit.owner === owner)?.amount ?? 0;
}
/** Entirely native shared Caladan programme with original all14 offers. Only
* original unopened skill/deck/scalar lotteries are selected; actual public
* leaderSkill actions choose living native discs. There is no installed skill,
* played card, army, wallet, occupation event or income receipt. The source
* stops before the existing final Ix End Movement callback. */
export function createWorldSkillsOccupationFixture(options: { advanced?: boolean } = {}) {
const source = createHomeworldPhaseOccupationFixture({ advanced: options.advanced ?? false,
skills: true, training: WORLD_SKILLS_OCCUPATION_TRAINING });
const banker = source.owner;
const originalSkills = worldSkillsOccupationSkillInventory(source.setup);
worldSkillsOccupationSkillInventory(source.game);
const trainingActions = source.sourceActions.filter(step => step.action.type === 'leaderSkill');
assert.equal(trainingActions.length, 3);
for (const [actor, skill] of Object.entries(WORLD_SKILLS_OCCUPATION_TRAINING)) {
const assignment = source.afterSetup.leaderSkills!.assignments.find(assignment => assignment.owner === actor);
assert.ok(assignment); assert.equal(assignment.skill, skill);
assert.ok(worldSkillsOccupationPlayer(source.afterSetup, actor).leaders.some(leader => leader.id === assignment.leader && !leader.dead));
assert.ok(trainingActions.some(step => step.actor === actor && step.action.skill === skill && step.action.leader === assignment.leader));
}
const bankerSource = source.evacuation.after.spiceBankerIncome?.sources.find(receipt =>
receipt.source.kind === 'shipment' && receipt.source.bankLegs.some(leg => leg.payer === source.native && leg.amount === 10));
assert.ok(bankerSource?.grant);
assert.equal(bankerSource.grant.owner, banker); assert.equal(bankerSource.grant.amount, 1);
assert.equal(worldSkillsOccupationDeferred(source.game, banker), 1);
return { ...source, banker, training: WORLD_SKILLS_OCCUPATION_TRAINING, trainingActions,
originalSkills, bankerSource: structuredClone(bankerSource), staging: [] as string[],
sourceDisclosure: 'Original all14 offer lottery is selected before the public Start. Three actual leaderSkill choices train native A Warmaster, Ecaz Banker and Ix Mentat; the shared programme earns both paid shipments and Caladan sole occupation.' };
}
export type WorldSkillsOccupationFixture = ReturnType<typeof createWorldSkillsOccupationFixture>;
export function completeWorldSkillsOccupation(f: WorldSkillsOccupationFixture, state = f.game) {
const result = completeHomeworldPhaseOccupation(f, state);
const bankerCollection = result.game.spiceBankerIncome?.collections.find(collection =>
collection.turn === f.game.turn && collection.grants.includes(f.bankerSource.cursor));
assert.ok(bankerCollection, 'The original Mentat event collects the earned Banker receipt');
worldSkillsOccupationSkillInventory(result.game);
return { ...result, bankerCollection: structuredClone(bankerCollection) };
}
+5
View File
@@ -59,6 +59,11 @@ void test('fresh classic Homeworld Banker admission keeps the original board, fo
const f = createHomeworldBankerFixture();
assert.equal(f.initial.spiceBankerIncomePreview, true);
assert.equal(f.initial.homeworldOccupationPreview, true);
assert.equal(f.initial.pendingHomeworldOccupiedIncome, null);
assert.deepEqual(f.initial.homeworldOccupiedIncomeHistory, []);
assert.ok(f.initial.homeworldOccupiedPercentageLedger,
'Ordinary fresh World Skills admission supplies its original occupation ledger.');
assert.deepEqual(f.initial.homeworldOccupiedPercentageLedger.receipts, []);
assert.ok(f.initial.spiceBankerIncome, 'The fresh Homeworld Banker game owns the real income ledger.');
assert.equal(f.initial.status, 'setup');
assert.equal(f.setup.status, 'playing');
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
homeworldOccupationLeaderSkillsProfile as supported,
homeworldPhaseModulesProfile as untrained,
type HomeworldPhaseModulesProfile,
} from '../game/homeworld-module-profile';
import { homeworldLeaderSkillsProfile as admitted } from '../game/leader-skill-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 }, leaderSkills: true, ...extra };
}
void test('original classic World/all14 admission gains clean occupation benefits with its optional phase modules', () => {
for (const game of [profile(), profile(undefined, undefined, { advanced: true }),
profile(undefined, undefined, { techTokens: createTechTokens(), ecazTreachery: true }),
profile(undefined, undefined, { advanced: true, techTokens: createTechTokens(), strongholdCards: createStrongholdCards() }),
]) {
assert.equal(admitted(game), true); assert.equal(supported(game), true);
assert.equal(untrained(game), false, 'The original untrained World predicate still rejects Skills');
}
});
void test('the unchanged selected-family all14 predicate remains the source of native roster and role admission', () => {
for (const game of [
profile(['ecaz', 'ixians', 'emperor'], ['ecaz', 'ix']),
profile(['ecaz', 'moritani', 'choam'], ['ecaz', 'choam'], { advanced: true }),
profile(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz'],
{ advanced: true, ecazTreachery: true, techTokens: createTechTokens(), strongholdCards: createStrongholdCards() }),
profile(['atreides', 'emperor', 'guild'], ['ix', 'choam', 'ecaz']),
]) {
assert.equal(admitted(game), true); assert.equal(supported(game), true);
}
for (const game of [
profile(['ecaz', 'harkonnen', 'guild'], ['ecaz'], { advanced: true }),
profile(['moritani', 'harkonnen', 'guild'], ['ecaz'], { advanced: true }),
]) {
assert.equal(admitted(game), false); assert.equal(supported(game), false);
}
});
void test('required modules, physical seat bounds and existing phase limits do not change with benefit initialization', () => {
assert.equal(supported(profile(undefined, undefined, { homeworlds: null })), false);
assert.equal(supported(profile(undefined, undefined, { leaderSkills: null })), false);
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);
for (const game of [profile(['atreides']), profile(['atreides', 'atreides']),
profile(['ecaz', 'guild', 'emperor']), profile(undefined, ['ix', 'ix']),
profile(['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit', 'harkonnen', 'ecaz'], ['ecaz']),
]) assert.equal(supported(game), false);
});
void test('occupation benefit context excludes Nexus, Discovery, Sandtrout and unrelated reaction selectors without changing library admission', () => {
for (const field of ['nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
'sandtrout', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview',
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
const nexus = profile(undefined, undefined, { nexusCards: { cards: null, phase: null } });
assert.equal(admitted(nexus), true, 'Existing Nexus/World/all14 library entry stays independently admitted');
assert.equal(supported(nexus), false, 'This clean benefit slice does not claim that separate combination');
});
void test('canonical Banker and native occupation stamps require their actual parent factions and retain the Advanced assassination band', () => {
const native = profile(['ecaz', 'moritani', 'choam'], ['ecaz', 'choam'], { advanced: true,
homeworldOccupationPreview: true, homeworldTupilePreview: true, homeworldEcazVictoryPreview: true,
ecazOccupyPreview: true, moritaniAssassinatePreview: true, spiceBankerIncomePreview: true });
assert.equal(admitted(native), true); assert.equal(supported(native), true);
assert.equal(supported(profile(undefined, undefined, { spiceBankerIncomePreview: true })), true);
for (const game of [
{ ...native, homeworldOccupationPreview: undefined }, { ...native, advanced: false },
profile(undefined, undefined, { ecazOccupyPreview: true }),
profile(undefined, undefined, { advanced: true, moritaniAssassinatePreview: true }),
profile(undefined, undefined, { homeworldOccupationPreview: true, homeworldTupilePreview: true }),
profile(undefined, undefined, { homeworldOccupationPreview: true, homeworldEcazVictoryPreview: true }),
]) assert.equal(supported(game), false);
for (const field of ['spiceBankerIncomePreview', 'ecazOccupyPreview', 'moritaniAssassinatePreview',
'homeworldOccupationPreview', 'homeworldTupilePreview', 'homeworldEcazVictoryPreview'] as const)
assert.equal(supported({ ...native, [field]: 'forged' }), false, field);
});
void test('fresh offers and live occupied posture remain configuration-neutral rather than a new deal or ownership predicate', () => {
const skills = { deck: ['sandmaster'], offers: {}, assignments: [
{ owner: 'seat-a', leader: 'disc-a', skill: 'spice-banker', faceUp: false },
] };
const custody = { visitors: { 'homeworld:atreides': { 'seat-e': { normal: 1, elite: 1 } } },
salusa: { normal: 0, elite: 4 } };
const game = profile(undefined, undefined, { advanced: true, leaderSkills: skills,
homeworlds: { custody, historyVersion: 1 }, homeworldOccupationPreview: true,
spiceBankerIncomePreview: true, techTokens: createTechTokens(), strongholdCards: createStrongholdCards() });
const before = structuredClone(game); Object.freeze(game.players); Object.freeze(game);
assert.equal(admitted(game), true); assert.equal(supported(game), true); assert.deepEqual(game, before);
assert.equal(game.leaderSkills, skills); assert.equal((game.homeworlds as { custody: unknown }).custody, custody);
});
void test('the previous untrained helper retains its behavior and malformed configuration never widens the all14 route', () => {
const plain = profile(undefined, undefined, { leaderSkills: undefined });
assert.equal(untrained(plain), true); assert.equal(supported(plain), false);
assert.equal(untrained(profile()), false); assert.equal(supported(profile()), true);
assert.equal(untrained(profile(undefined, undefined, { leaderSkills: undefined, spiceBankerIncomePreview: true })), false);
for (const game of [profile(undefined, undefined, { ecazTreachery: 'true' }),
profile(undefined, undefined, { advanced: 'advanced' as unknown as boolean })]) assert.equal(supported(game), false);
for (const value of [null, {}, { ...profile(), expansions: 'ix' }, { ...profile(), players: [{ faction: 'atreides' }, null] }])
assert.equal(supported(value as unknown as HomeworldPhaseModulesProfile), false);
});
+11 -2
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction,viewGame,type Game} from '../game/engine';
import {advanceSpiceBankerIncomeToMentat,createSpiceBankerIncomeFixture,createSpiceBankerIncomeReassignmentFixture,nextSpiceBankerIncomeNativeStep,quoteSpiceBankerIncomeBattleEconomics,stageSpiceBankerIncomeTrainerBattle,type SpiceBankerIncomeFixture,type SpiceBankerIncomeFixtureOptions} from './fixture-spice-banker-income';
import {advanceSpiceBankerIncomeToMentat,awaitSpiceBankerIncomeRevivalActor,createSpiceBankerIncomeFixture,createSpiceBankerIncomeReassignmentFixture,nextSpiceBankerIncomeNativeStep,quoteSpiceBankerIncomeBattleEconomics,stageSpiceBankerIncomeTrainerBattle,type SpiceBankerIncomeFixture,type SpiceBankerIncomeFixtureOptions} from './fixture-spice-banker-income';
import {forceRevivalQuote} from '../game/revival';
function pay(fixture:SpiceBankerIncomeFixture,action=fixture.paymentAction):Game {
@@ -49,6 +49,10 @@ for(const kind of ['shipment','force-revival','leader-revival','emperor-extra-re
void test(`actual ${kind} original native cost and effect commit once with deferred normal Banker income`,()=>{
const fixture=createSpiceBankerIncomeFixture({kind,advanced:true,...(kind==='force-revival'?{payerFaction:'emperor' as const}:{})});
const payerBefore=fixture.game.players.find(p=>p.id===fixture.payer)!;
if(fixture.game.phase===4) {
assert.ok(fixture.game.revivalOrder);
assert.equal(fixture.game.revivalOrder.remaining[0],kind==='emperor-extra-revival'?payerBefore.ally:fixture.payer);
}
const balance=payerBefore.spice,forces=payerBefore.reserves,tanks=payerBefore.tanks;
if(kind==='leader-revival') {
assert.ok(payerBefore.leaders.every(l=>l.dead));
@@ -282,8 +286,10 @@ void test('deferred automatic spice cannot fund a bid before actual Mentat colle
assert.equal(JSON.stringify(game),before);
});
void test('a real singleton skill return and different-faction native revival retain old owner custody but use the new eligible owner',()=>{
const fixture=createSpiceBankerIncomeFixture({advanced:true});const paid=pay(fixture);
const fixture=createSpiceBankerIncomeFixture({advanced:true,revivalBeforeBanker:'harkonnen'});const paid=pay(fixture);
const lifecycle=createSpiceBankerIncomeReassignmentFixture(paid);
assert.equal(lifecycle.game.revivalOrder!.remaining[0],lifecycle.actor);
assert.ok(lifecycle.game.revivalOrder!.remaining.includes(fixture.owner));
assert.equal(lifecycle.game.spiceBankerIncome!.sources.find(r=>r.grant)!.grant!.owner,fixture.owner);
assert.equal(lifecycle.game.spiceBankerIncome!.collections[0].credits.find(c=>c.owner===fixture.owner)!.amount,1);
let game=applyAction(lifecycle.game,lifecycle.actor,lifecycle.revivalAction);
@@ -295,6 +301,9 @@ void test('a real singleton skill return and different-faction native revival re
game=applyAction(game,lifecycle.actor,{type:'leaderSkill',event:offer.event,skill:'spice-banker',leader:lifecycle.leader});
assert.equal(game.leaderSkills!.assignments.find(a=>a.skill==='spice-banker')!.owner,lifecycle.actor);
const balance=game.players.find(p=>p.id===lifecycle.actor)!.spice;
game=awaitSpiceBankerIncomeRevivalActor(game,fixture.owner);
assert.equal(game.revivalOrder!.remaining[0],fixture.owner);
assert.ok(game.revivalOrder!.completed.includes(lifecycle.actor));
game=applyAction(game,fixture.owner,{type:'reviveLeader',leader:fixture.leader});game=nativeCounters(game);
assert.equal(deferred(game,lifecycle.actor),1);
assert.equal(deferred(game,fixture.owner),0);
+11 -3
View File
@@ -6,7 +6,7 @@ import { DIFFICULTIES } from '../game/bot-profiles';
import { validateLeaderSkills } from '../game/leader-skills';
import { quoteSukRescue } from '../game/suk-graduate';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceBankerIncomeBattleEconomics } from './fixture-spice-banker-income';
import { awaitSpiceBankerIncomeRevivalActor, quoteSpiceBankerIncomeBattleEconomics } from './fixture-spice-banker-income';
import {
createTleilaxuSkillsTechFixture, createTleilaxuSkillsTechRevivalFixture,
advanceTleilaxuSkillsTechToPhase, settleTleilaxuSkillsTechRevival,
@@ -159,11 +159,17 @@ void test('four original native policies replace the actual winner using legal s
void test('only-Tleilaxu real free revival excludes Axlotl; another faction real free revival accrues and pays exactly its owned-token count', () => {
for (const anotherFaction of [false, true]) {
const f = createTleilaxuSkillsTechRevivalFixture();
let game = settleTleilaxuSkillsTechRevival(applyAction(f.game, f.tleilaxu, f.ownFreeAction));
let game = awaitSpiceBankerIncomeRevivalActor(f.game, f.tleilaxu);
assert.equal(game.revivalOrder!.remaining[0], f.tleilaxu);
assert.ok(game.revivalOrder!.remaining.includes(f.other));
game = settleTleilaxuSkillsTechRevival(applyAction(game, f.tleilaxu, f.ownFreeAction));
assert.equal(player(game, f.tleilaxu).tanks, 0);
assert.equal(player(game, f.tleilaxu).freeForcesRevived, 1);
assert.deepEqual(game.techTokens, f.game.techTokens, 'The native Tleilaxu-only free revival is not an Axlotl trigger.');
if (anotherFaction) {
game = awaitSpiceBankerIncomeRevivalActor(game, f.other);
assert.equal(game.revivalOrder!.remaining[0], f.other);
assert.ok(game.revivalOrder!.completed.includes(f.tleilaxu));
game = settleTleilaxuSkillsTechRevival(applyAction(game, f.other, f.otherFreeAction));
assert.equal(player(game, f.other).tanks, 0);
assert.equal(player(game, f.other).freeForcesRevived, 1);
@@ -181,7 +187,9 @@ void test('only-Tleilaxu real free revival excludes Axlotl; another faction real
void test('real native foreign Ghola revival grants no new Tleilaxu skill and preserves its living native trainer', () => {
const f = createTleilaxuSkillsTechRevivalFixture();
const assignments = structuredClone(f.game.leaderSkills!.assignments);
const after = settleTleilaxuSkillsTechRevival(applyAction(f.game, f.tleilaxu, f.foreignGholaAction));
const current = awaitSpiceBankerIncomeRevivalActor(f.game, f.tleilaxu);
assert.equal(current.revivalOrder!.remaining[0], f.tleilaxu);
const after = settleTleilaxuSkillsTechRevival(applyAction(current, f.tleilaxu, f.foreignGholaAction));
const foreign = after.players.flatMap(p => p.leaders).find(l => l.id === f.foreignLeader)!;
assert.equal(foreign.dead, false); assert.equal(foreign.gholaBy, f.tleilaxu);
assert.deepEqual(after.leaderSkills!.assignments, assignments);
@@ -0,0 +1,54 @@
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 actions select the modules, Start and eligible training. Stateful
// React SSR confirms visible controls without claiming DOM callback execution.
void test('original World Skills controls retain selected modules and Basic/Advanced preview Start with live trained leaders', () => {
for (const advanced of [false, true]) {
const f = createMixedPhaseModulesFixture({ factions: ['atreides', 'ecaz', 'ixians'], expansions: ['ix', 'choam', 'ecaz'],
advanced, homeworlds: true, skills: true, tech: true, strongholds: advanced, independent: advanced,
training: { atreides: 'warmaster', ecaz: 'spice-banker', ixians: 'mentat' } });
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', 'Leader Skills · all fourteen skill cards', '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 === 'moduleOption' &&
step.action.module === 'leaderSkills' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === advanced));
assert.equal(f.setup.homeworldOccupationPreview, true);
assert.equal(f.actions.filter(step => step.action.type === 'leaderSkill').length, 3);
const playing = render(f.game);
assert.match(playing, /Warmaster/); assert.match(playing, /Spice Banker/); assert.match(playing, /Mentat/);
if (advanced) assert.match(playing, /Inspect Stronghold Cards/);
}
});
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { TECH_TOKENS } from '../game/tech-tokens';
import {
completeWorldSkillsOccupation, createWorldSkillsOccupationFixture,
worldSkillsOccupationDeferred as deferred, worldSkillsOccupationInventory as inventory,
worldSkillsOccupationPlayer as player, worldSkillsOccupationSkillInventory as skills,
} from './fixture-world-skills-occupation';
for (const advanced of [false, true]) {
void test(`public trained ${advanced ? 'Advanced Worlds Tech Strongholds' : 'Basic Worlds Tech'} keeps Caladan income, other-payer Banker and phase rewards distinct`, () => {
const f = createWorldSkillsOccupationFixture({ advanced });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.game.players.map(seat => seat.faction), ['atreides', 'ecaz', 'ixians']);
assert.deepEqual(f.game.expansions, ['ix', 'choam', 'ecaz']);
assert.ok(f.game.homeworlds && f.game.ecazTreachery && f.game.techTokens && f.game.leaderSkills);
assert.equal(!!f.game.strongholdCards, advanced);
assert.ok(f.game.homeworldOccupationPreview && f.game.spiceBankerIncomePreview);
assert.ok(!f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
assert.equal(f.originalInventory.length, 50); assert.equal(f.originalSkills.length, 14);
assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(skills(f.game), f.originalSkills);
assert.equal(f.trainingActions.length, 3); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, f.sourceActions.length);
for (const [actor, skill] of Object.entries(f.training)) {
const assignment = viewGame(f.game, actor).leaderSkills!.assignments.find(assignment => assignment.owner === actor);
assert.ok(assignment); assert.equal(assignment.skill, skill);
assert.equal(assignment.faceUp, true); assert.equal(assignment.captured, false);
assert.equal(assignment.controller, actor);
}
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.bankerSource.source.bankLegs, [{ payer: f.native, amount: 10 }]);
assert.equal(f.bankerSource.source.phase, 5); assert.equal(f.bankerSource.source.turn, 1);
assert.equal(f.bankerSource.source.trainer?.assignment.owner, f.banker);
assert.equal(f.bankerSource.source.trainer?.faceUp, true);
assert.equal(f.bankerSource.grant?.owner, f.banker); assert.equal(f.bankerSource.grant?.amount, 1);
assert.notEqual(f.banker, f.native, 'The qualifying Bank10 belongs to another payer');
assert.equal(player(f.evacuation.after, f.banker).spice, player(f.evacuation.before, f.banker).spice,
'The mandatory normal grant waits in front of the shield');
assert.equal(deferred(f.evacuation.after, f.banker), 1); assert.equal(deferred(f.game, f.banker), 1);
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.game.techTokens?.heighliners.spice, 1);
assert.equal(f.game.spiceBankerIncome!.sources.filter(receipt => receipt.grant !== null).length, 1);
const before = structuredClone(f.game), originalTech = structuredClone(f.game.techTokens);
const result = completeWorldSkillsOccupation(f);
assert.deepEqual(f.game, before, 'The genuine trained source remains reusable before its final callback');
assert.deepEqual(result.collection.steps[0], { actor: f.current, action: f.action });
assert.equal(result.collection.after.phase, 7);
assert.equal(player(result.collection.after, f.owner).spice - player(result.collection.before, f.owner).spice, 2);
assert.equal(deferred(result.collection.after, f.banker), 1, 'Caladan Collection does not prematurely collect Banker');
assert.deepEqual(result.incomeReceipt, { world: f.world, status: 'settled', ownAmount: 2,
credits: [{ player: f.owner, amount: 2 }] });
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.native).spice - player(result.collection.before, f.native).spice, advanced ? 2 : 0,
'Ordinary Advanced Arrakeen Collection belongs to Atreides');
assert.equal(result.collection.after.spiceBankerIncome!.sources.length, before.spiceBankerIncome!.sources.length,
'Bank awards and Tech payouts are not another player Bank invoice');
assert.equal(player(result.mentat.after, f.banker).spice - player(result.mentat.before, f.banker).spice, 1);
assert.deepEqual(result.bankerCollection.credits, [{ owner: f.banker, amount: 1 }]);
assert.deepEqual(result.bankerCollection.grants, [f.bankerSource.cursor]);
assert.equal(result.bankerCollection.phase, 8); assert.equal(result.bankerCollection.turn, 1);
assert.equal(deferred(result.game, f.banker), 0);
assert.equal(result.game.spiceBankerIncome!.sources.filter(receipt => receipt.grant !== null).length, 1);
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0);
for (const { id } of TECH_TOKENS) assert.equal(result.game.techTokens![id].owner, originalTech![id].owner);
if (advanced) {
assert.equal(result.game.strongholdCards?.claimedTurn, 1);
assert.equal(result.game.strongholdCards?.owners.arrakeen, f.native);
assert.equal(result.game.strongholdCards?.owners.hidden_mobile_stronghold, f.techOwner);
}
assert.deepEqual(inventory(result.game), f.originalInventory); assert.deepEqual(skills(result.game), f.originalSkills);
assert.deepEqual(result.game.leaderSkills?.assignments, f.game.leaderSkills!.assignments);
homeworldGameIntegrity(result.game);
});
}
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_STRONGHOLD } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import type { FactionId } from '../game/catalog';
import { quotePhaseResources } from '../game/phase-resource-quote';
import { forceRevivalQuote } from '../game/revival';
import { nativeShipmentSources } from '../game/homeworld-options';
import {
createMixedPhaseModulesFixture, createMixedPhaseModulesProgramme, mixedPhaseModulesInventory,
mixedPhaseModulesPlayer as player, mixedPhaseModulesClean, type MixedPhaseModulesOptions,
} from './fixture-mixed-phase-modules';
const training: Partial<Record<FactionId, LeaderSkillId>> = {
ixians: 'prana-bindu-adept', tleilaxu: 'swordmaster-of-ginaz', choam: 'killer-medic',
richese: 'warmaster', ecaz: 'master-of-assassins', moritani: 'mentat',
};
function originalSkills(game: Game) {
const state = game.leaderSkills; assert.ok(state);
const identities = [...state.deck, ...Object.values(state.offers).flatMap(offer => offer.cards),
...state.assignments.map(assignment => assignment.skill)].sort();
assert.deepEqual(identities, LEADER_SKILL_CARDS.map(card => card.id).sort());
}
function originalOccupation(game: Game) {
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);
}
}
function actualTraining(f: ReturnType<typeof createMixedPhaseModulesFixture>, requested: typeof training) {
originalSkills(f.setup); originalSkills(f.afterSetup); originalOccupation(f.setup);
const assignments = f.afterSetup.leaderSkills!.assignments;
assert.equal(assignments.length, f.afterSetup.players.length);
for (const assignment of assignments) {
const owner = player(f.afterSetup, assignment.owner);
assert.ok(owner.leaders.some(leader => leader.id === assignment.leader));
if (requested[owner.faction]) assert.equal(assignment.skill, requested[owner.faction]);
assert.ok(f.actions.some(step => step.actor === assignment.owner && step.action.type === 'leaderSkill' &&
step.action.skill === assignment.skill && step.action.leader === assignment.leader && typeof step.action.event === 'string'));
}
assert.ok(!f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory);
assert.deepEqual(f.staging, []);
}
void test('public mixed and base World Skills starts retain mandatory original occupation state and original fourteen-card offers', () => {
const cases: MixedPhaseModulesOptions[] = [
{ factions: ['ecaz', 'ixians', 'tleilaxu'], expansions: ['ix', 'choam', 'ecaz'], training: { ecaz: 'spice-banker' } },
{ factions: ['atreides', 'emperor'], expansions: [], training: { emperor: 'bureaucrat' } },
];
for (const options of cases) {
const f = createMixedPhaseModulesFixture({ ...options, homeworlds: true, skills: true });
assert.equal(f.skills, true); assert.equal(f.homeworlds, true);
const option = f.actions.findIndex(step => step.action.type === 'moduleOption' && step.action.module === 'leaderSkills');
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.lobby.lobbyOptions?.leaderSkills, true);
assert.equal(f.game.phase, 1); assert.equal(f.game.turn, 1); assert.equal(f.game.advanced, false);
assert.ok(!f.game.techTokens && !f.game.strongholdCards && !f.game.ecazTreachery);
actualTraining(f, options.training ?? {});
}
});
void test('public Advanced mixed World Skills and phase selections preserve the original requested eligible training', () => {
const f = createMixedPhaseModulesFixture({ factions: ['ixians', 'ecaz', 'moritani'], expansions: ['ix', 'ecaz'],
advanced: true, homeworlds: true, skills: true, tech: true, strongholds: true, independent: true,
training: { ixians: 'mentat', ecaz: 'spice-banker', moritani: 'warmaster' } });
actualTraining(f, { ixians: 'mentat', ecaz: 'spice-banker', moritani: 'warmaster' });
assert.equal(f.setup.ecazTreachery, true); assert.ok(f.game.techTokens && f.game.strongholdCards);
assert.equal(f.originalInventory.length, 50);
});
for (const advanced of [false, true]) void test(`public all-six ${advanced ? 'Advanced World Skills, independent cards and Strongholds' : 'Basic World Skills'} keeps earned native phase income and a real free return`, () => {
const f = createMixedPhaseModulesProgramme({ advanced, homeworlds: true, skills: true, training,
independent: advanced });
actualTraining(f, training); originalSkills(f.game);
assert.equal(f.originalInventory.length, advanced ? 60 : 57);
for (const [actor, receipt, location] 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.deepEqual(action.homeworldSources, nativeShipmentSources(viewGame(receipt.before, actor), 1, 0));
assert.equal(player(receipt.after, actor).forces[location], 1);
assert.equal(player(receipt.after, actor).reserves, player(receipt.before, actor).reserves - 1);
assert.equal(receipt.after.techTokens?.heighliners.spice, 1);
}
// Compare the actual current phase invoice; role-bearing profiles need not
// retain an unrelated no-Skills shipment debit assumption.
const shipping = quotePhaseResources(f.shipmentPayout.before);
assert.ok(shipping.credits.some(credit => credit.kind === 'tech' && credit.token === 'heighliners' && credit.amount === 1));
assert.equal(player(f.shipmentPayout.after, 'ixians').spice, shipping.balances.ixians);
assert.equal(f.shipmentPayout.after.techTokens?.heighliners.spice, 0);
if (advanced) {
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(player(f.storm.after, 'moritani').tanks, 1);
assert.equal(player(f.storm.after, 'moritani').forces[f.casualtyLocation], undefined);
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 } });
assert.equal(viewGame(f.revival.before, 'moritani').revival.tleilaxuHomeworldIncomeBlocked, false);
assert.equal(player(f.revival.after, 'moritani').tanks, 0);
assert.equal(player(f.revival.after, 'moritani').reserves, player(f.revival.before, 'moritani').reserves + 1);
const revival = quotePhaseResources(f.revivalPayout.before);
assert.ok(revival.credits.some(credit => credit.kind === 'tech' && credit.token === 'axlotl' && credit.amount === 1));
assert.equal(player(f.revivalPayout.after, 'tleilaxu').spice, revival.balances.tleilaxu);
assert.equal(f.revivalPayout.after.techTokens?.axlotl.spice, 0);
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 2); assert.ok(mixedPhaseModulesClean(f.game));
if (advanced) {
const actor = f.game.active; assert.ok(actor);
const own = viewGame(f.game, actor); own.players.find(seat => seat.id === actor)!.bot = 'Easy';
const candidate = botActions(own).find(action => action.type === 'endMovement'); assert.ok(candidate);
assert.doesNotThrow(() => applyAction(f.game, actor, candidate));
}
});