Initialize occupation benefits for registered World Skills starts
This commit is contained in:
1 parent
7f9daa10fa
commit
396f9e0ccb
21 files changed
+737
-26
No files matched your search
@@ -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
|
||||
|
||||
@@ -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); };
|
||||
|
||||
@@ -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); };
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user