Compose World Skills cards with selected ordinary decks

This commit is contained in:
Quintine committed 2026-10-17 23:07:39 +11:00
1 parent b9330d489e
commit 01985af36b
36 files changed
+1201 -283

No files matched your search

-35
View File
@@ -5,13 +5,8 @@ import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { basicChoamLeaderSkillsProfile } from '../game/leader-skill-profile';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
@@ -83,7 +78,6 @@ void test('all fourteen production skill faces deal through genuine Basic CHOAM
player(game, 'c').leaders.some((leader) => leader.id === 'choam-auditor'),
false,
);
assert.equal(basicChoamLeaderSkillsProfile(game), true);
for (const participant of game.players) {
const view = viewGame(game, participant.id);
assert.equal(view.leaderSkills!.offer, null);
@@ -95,35 +89,6 @@ void test('all fourteen production skill faces deal through genuine Basic CHOAM
});
void test('Advanced CHOAM and Basic Richese retain audit admission without entering the exact normal profile', () => {
for (const variant of ['advanced', 'richese'] as const) {
let game = createGame(
variant === 'advanced' ? 'CHOADVAN' : 'CHORICHE',
newPlayer('c', 'CHOAM', 'choam'),
variant === 'advanced',
['choam'],
);
joinGame(
game,
variant === 'richese'
? newPlayer('r', 'Richese', 'richese')
: newPlayer('a', 'Atreides', 'atreides'),
);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const participant of game.players)
game = applyAction(game, participant.id, { type: 'ready' });
game = initializeLeaderSkillsGameForAudit(game);
assert.equal(game.setupStage, 'leaderSkills');
assert.equal(basicChoamLeaderSkillsProfile(game), false);
if (variant === 'advanced')
assert.equal(
player(game, 'c').leaders.some(
(leader) => leader.id === 'choam-auditor',
),
true,
);
}
});
void test('CHOAM Planetologist and Sandmaster controls and bots submit legal shared movement actions', () => {
let game = movementPosition(
+38 -5
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
applyAction, createGame, initializeEcazTreacheryGameForAudit, initializeLeaderSkillsGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { treacheryDeck } from '../game/cards';
@@ -13,11 +13,18 @@ import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { skillOfferRolls } from './fixture-independent-card-modules';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
export const HARASS_HOMEWORLD_CARD = 'ecaz-harass-withdraw';
export type HarassHomeworldStep = { actor: string; action: Action };
export type HarassHomeworldOptions = {
advanced?: boolean;
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
tech?: boolean;
strongholds?: boolean;
slot?: 'weapon' | 'defense';
role?: 'visitor' | 'native';
/** Salusa is an Advanced own-World rejection case, not a second faction. */
@@ -84,7 +91,18 @@ export function actHarassHomeworld(game: Game, step: HarassHomeworldStep, action
/** Never invent a plan, native vote or late-defense permission. */
export function nextHarassHomeworldStep(game: Game): HarassHomeworldStep | null {
if (!game.response && !game.phaseOpening) {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!;
const skill = offer.cards.find(card => !own.unavailableSkills?.[card]), leader = own.eligibleLeaders[0];
assert.ok(skill && leader, 'Choose an original offered skill and eligible native disc');
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
}
const d = game.decision;
if (d?.kind === 'mentatQuestion' && d.stage === 'name')
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'leaderSkillVisibility')
return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: true } };
if (d?.kind === 'harassWithdraw') return null;
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
if (d?.kind === 'homeworldExplosion') return { actor: d.player, action: { type: 'decision', event: d.event, choice: 0 } };
@@ -105,12 +123,23 @@ export function advanceHarassHomeworld(game: Game, until: (game: Game) => boolea
}
throw Error('Original Harass Homeworld programme did not reach its boundary');
}
function initialize(game: Game): Game {
function initialize(game: Game, options: Pick<HarassHomeworldOptions, 'skills' | 'training'>): Game {
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
let cursor = 0;
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
assert.ok(array);
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
else new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeEcazTreacheryGameForAudit(game); }
try {
if (options.skills) {
game.ecazTreachery = true;
return initializeLeaderSkillsGameForAudit(game);
}
return initializeEcazTreacheryGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
@@ -134,11 +163,15 @@ export function createHarassHomeworldSetup(options: HarassHomeworldOptions = {})
!game.discoveryEnabled && !game.discoveries && !game.greatMaker);
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
assert.deepEqual(game.homeworlds, { custody: null });
if (options.tech !== undefined && !!game.techTokens !== options.tech)
game = applyAction(game, game.host, { type: 'techTokens', enabled: options.tech });
if (options.strongholds !== undefined && !!game.strongholdCards !== options.strongholds)
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: options.strongholds });
const nativeSteps: HarassHomeworldStep[] = [];
for (const p of game.players) if (!p.ready)
game = actHarassHomeworld(game, { actor: p.id, action: { type: 'ready' } }, nativeSteps);
const initial = structuredClone(game);
game = initialize(game);
game = initialize(game, options);
const initialized = structuredClone(game);
game = advanceHarassHomeworld(game, g => g.status === 'playing', nativeSteps);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
+1 -1
View File
@@ -25,7 +25,7 @@ export type IndependentCardModulesOptions = {
};
/** Select only the original undealt fourteen-card lottery, never assignments. */
function skillOfferRolls(game: Game, training: IndependentCardModulesOptions['training']): number[] {
export function skillOfferRolls(game: Game, training: IndependentCardModulesOptions['training']): number[] {
const source = LEADER_SKILL_CARDS.map(card => card.id), selected: LeaderSkillId[] = [];
for (const player of game.players) {
const skill = training?.[player.faction] ?? source.find(card => !selected.includes(card))!;
+18 -6
View File
@@ -7,7 +7,7 @@ import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions } from './fixture-independent-card-modules';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import {
advanceHarassHomeworld, harassHomeworldClean, nextHarassHomeworldStep,
@@ -21,6 +21,10 @@ export type NativeWorldBattleOptions = {
/** Original unused phase modules selected before native setup. */
tech?: boolean;
strongholds?: boolean;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
/** Conceal and actually select the owner's originally trained native disc. */
skilled?: boolean;
route?: 'ix-visitor' | 'ix-native' | 'choam-native';
/** Actual first-turn export, not a reserve assignment. */
exportNormal?: number;
@@ -101,6 +105,7 @@ export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions
const advanced = options.advanced ?? false;
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true,
tech: options.tech ?? false, strongholds: options.strongholds ?? false,
skills: options.skills, training: options.training,
factions: route === 'choam-native' ? ['choam', 'guild', 'tleilaxu', 'ixians'] : ['ixians', 'tleilaxu', 'guild'],
expansions: route === 'choam-native' ? ['ix', 'choam'] : ['ix'] });
let game = setup.game;
@@ -147,11 +152,15 @@ export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions
const unused = game.deck.find(c => c.id !== REINFORCEMENTS_CARD && c.id !== HARASS_WITHDRAW_CARD);
assert.ok(unused); conserveCard(game, opponent, unused.id, staging);
}
const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt && !isAuditorLeader(l))
const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt && !isAuditorLeader(l) &&
(!game.leaderSkills?.assignments.some(a => a.owner === actor && a.leader === l.id) || actor === owner && options.skilled))
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const win = options.outcome === 'win';
const ownLeader = route === 'choam-native' && advanced ? nativeWorldBattlePlayer(game, owner).leaders.find(isAuditorLeader)!
: win ? living(owner).at(-1)! : living(owner)[0];
const trainer = options.skilled ? game.leaderSkills?.assignments.find(a => a.owner === owner) : null;
assert.ok(!options.skilled || trainer, 'Select Skills before native setup to use its real trainer');
const ownLeader = trainer ? living(owner).find(l => l.id === trainer.leader)!
: route === 'choam-native' && advanced ? nativeWorldBattlePlayer(game, owner).leaders.find(isAuditorLeader)!
: win ? living(owner).at(-1)! : living(owner)[0];
const otherLeader = win ? living(opponent)[0] : living(opponent).at(-1)!;
assert.ok(ownLeader && otherLeader);
if (options.faceDancer) {
@@ -162,7 +171,9 @@ export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions
const battleStep: NativeWorldBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep);
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
game = advanceNativeWorldBattle(game, g => !!g.battle && harassHomeworldClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, nativeSteps,
{ skilled: options.skilled ? [owner] : [] });
const preview = viewGame(game, owner).battle!.reinforcements; assert.ok(preview && !preview.blocked);
const positiveSources = preview.homeworldSources?.[0]; assert.ok(positiveSources);
const dial = options.ownerDial ?? (route === 'choam-native' ? 0 : route === 'ix-native' ? 2 : 1);
@@ -185,7 +196,7 @@ export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions
* silently decline the native Face Dance, Auditor or Cyborg exchange. */
export function advanceNativeWorldBattle(game: Game, until: (g: Game) => boolean,
steps: NativeWorldBattleStep[] = [], options: { returns?: Record<string, HomeworldForces>; callers?: string[];
casualties?: HomeworldForces } = {}): Game {
casualties?: HomeworldForces; skilled?: readonly string[] } = {}): Game {
for (let n = 0; n < 300; n++) {
if (until(game)) return game;
assert.ok(game.battle || game.homeworldBattleLoss || game.decision || game.response || game.phaseOpening,
@@ -201,6 +212,7 @@ export function advanceNativeWorldBattle(game: Game, until: (g: Game) => boolean
step = { actor: d.player, action: { type: 'decision', choice } };
} else step = nextHarassHomeworldStep(game);
assert.ok(step, 'Seal actual human plans before continuing');
if (step.action.type === 'leaderSkillVisibility') step.action.hide = options.skilled?.includes(step.actor) ?? false;
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
steps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
+14 -7
View File
@@ -11,7 +11,10 @@ import {
type RicheseReinforcementsStep,
} from './fixture-richese-reinforcements';
export type NativeWorldMarkerOptions = Omit<RicheseReinforcementsOptions, 'initial' | 'seatIds'>;
export type NativeWorldMarkerOptions = Omit<RicheseReinforcementsOptions, 'initial' | 'seatIds'> & {
tech?: boolean;
strongholds?: boolean;
};
export type NativeWorldMarkerFixture = RicheseReinforcementsFixture & {
/** Ready but undealt: the original initializer seam for controls/policies. */
readyLobby: Game;
@@ -26,10 +29,12 @@ export type NativeWorldMarkerFixture = RicheseReinforcementsFixture & {
};
};
/** Genuine selected-family World programme. Only the original initializer's
* randomness is scoped. Basic low budgets come from two reveals, real Spice
* Blow collection and an ordinary paid shipment over four turns. Advanced uses
* its funded turn-one route, never a staged low budget or frozen later Storm.
/** Genuine selected-family World programme, with optional all14 training and
* original Tech/Advanced Strongholds selected before the original initializer.
* Only that initializer's randomness is scoped. Basic low budgets come from
* two reveals, real Spice Blow collection and an ordinary paid shipment over
* four turns. Advanced uses its funded turn-one route, never a staged low budget
* or frozen later Storm.
* The inherited staging log explicitly labels conserved physical card moves;
* none of those moves is represented as a natural purchase. */
export function createNativeWorldMarkerFixture(
@@ -39,8 +44,10 @@ export function createNativeWorldMarkerFixture(
const selected = createIndependentCardModulesFixture({
advanced: options.advanced ?? false,
homeworlds: true,
tech: false,
strongholds: false,
tech: options.tech ?? false,
strongholds: options.strongholds ?? false,
skills: options.skills,
training: options.training,
expansions: family === 'choam' ? ['choam'] : ['ix', 'choam'],
factions: family === 'choam' ? ['richese', 'guild', 'atreides'] : ['richese', 'ixians', 'atreides'],
});
+25 -10
View File
@@ -4,7 +4,7 @@ import { combatArmy } from '../game/combat-location';
import { nativeShipmentSources } from '../game/homeworld-options';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import type { HomeworldForces } from '../game/homeworld-custody';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions } from './fixture-independent-card-modules';
import { actWorldPhaseBattle, advanceWorldPhaseBattle } from './fixture-world-phase-battle';
import { harassHomeworldClean, nextHarassHomeworldStep } from './fixture-harass-homeworld';
import { takeHarassCard } from './fixture-harass-withdraw';
@@ -18,6 +18,10 @@ export type NativeWorldPhaseBattleOptions = {
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
skilled?: boolean;
harass?: boolean;
/** Actual Cyborg support, never support for Reinforcements' score-only +2. */
support?: 0 | 1 | 3;
};
@@ -57,6 +61,7 @@ export function createNativeWorldPhaseBattleFixture(
const advanced = options.advanced ?? true;
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true,
tech: options.tech ?? true, strongholds: options.strongholds ?? advanced,
skills: options.skills, training: options.training,
factions: ['ixians', 'tleilaxu', 'guild'], expansions: ['ix'] });
let game = setup.game;
const afterSetup = structuredClone(game), nativeSteps = [...setup.steps];
@@ -108,17 +113,23 @@ export function createNativeWorldPhaseBattleFixture(
reach(g => g.phase === 6 && !!g.active && harassHomeworldClean(g));
for (const player of game.players) game.deck.push(...player.hand.splice(0));
takeHarassCard(game, owner, REINFORCEMENTS_CARD);
takeHarassCard(game, owner, HARASS_WITHDRAW_CARD);
staging.push('Return all original hands, including Ix setup stock, to undealt stock; conserve original R/H into Ix’s two real slots. This is not a purchase.');
if (options.harass ?? true) takeHarassCard(game, owner, HARASS_WITHDRAW_CARD);
staging.push('Return all original hands, including Ix setup stock, to undealt stock; conserve original R and optional H into Ix’s real slots. This is not a purchase.');
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === 'arrakeen'); assert.ok(choice);
const battleStep: NativeWorldBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep);
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
game = advanceNativeWorldBattle(game, g => !!g.battle && harassHomeworldClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, nativeSteps,
{ skilled: options.skilled ? [owner] : [] });
const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt &&
(!game.leaderSkills?.assignments.some(a => a.owner === actor && a.leader === l.id) || actor === owner && options.skilled))
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const ownLeader = living(owner).at(-1)!, otherLeader = living(opponent)[0];
const trainer = options.skilled ? game.leaderSkills?.assignments.find(a => a.owner === owner) : null;
assert.ok(!options.skilled || trainer, 'Select Skills before native setup to use its real trainer');
const ownLeader = trainer ? living(owner).find(l => l.id === trainer.leader)! : living(owner).at(-1)!;
const otherLeader = living(opponent)[0];
assert.ok(ownLeader && otherLeader);
conserveDancer(game, ownLeader.id, staging);
const support = advanced ? options.support ?? 3 : 0;
@@ -127,7 +138,8 @@ export function createNativeWorldPhaseBattleFixture(
// they dial three; each actually supported Cyborg adds one, not another token.
const planActions: NativeWorldPhaseBattleFixture['planActions'] = [
{ actor: owner, action: { type: 'battlePlan', dial: advanced ? 3 + support : 6, support,
leader: ownLeader.id, weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD, reinforcementsSources: sources } },
leader: ownLeader.id, weapon: REINFORCEMENTS_CARD, defense: (options.harass ?? true) ? HARASS_WITHDRAW_CARD : null,
reinforcementsSources: sources } },
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: otherLeader.id } },
];
const returns = { [location]: { normal: 3, elite: 0 } };
@@ -165,13 +177,16 @@ export function finishNativeWorldPhaseBattle(game: Game, fixture: NativeWorldPha
* Cyborg/Face Dance aftercare; own Ix retains its original H prohibition.
*/
export function createNativeWorldPhaseForeignBattleFixture(options: {
advanced?: boolean; typedAftercare?: boolean;
advanced?: boolean; typedAftercare?: boolean; skills?: boolean;
training?: IndependentCardModulesOptions['training']; skilled?: boolean;
} = {}): NativeWorldBattleFixture {
const advanced = options.advanced ?? true;
return createNativeWorldBattleFixture({ advanced, tech: true, strongholds: advanced,
skills: options.skills, training: options.training, skilled: options.skilled,
...(options.typedAftercare ? { exportNormal: 9, exportElite: 1, ownerDial: advanced ? 1 : 2,
harass: false, outcome: 'win' as const, faceDancer: true } : {}) });
}
export function createNativeWorldPhaseOwnBattleFixture(advanced = true): NativeWorldBattleFixture {
return createNativeWorldBattleFixture({ advanced, tech: true, strongholds: advanced, route: 'ix-native' });
export function createNativeWorldPhaseOwnBattleFixture(advanced = true,
options: Pick<NativeWorldPhaseBattleOptions, 'skills' | 'training' | 'skilled'> = {}): NativeWorldBattleFixture {
return createNativeWorldBattleFixture({ advanced, tech: true, strongholds: advanced, route: 'ix-native', ...options });
}
+10 -2
View File
@@ -7,6 +7,7 @@ import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { forceRevivalQuote } from '../game/revival';
import { RECRUITS_CARD_ID } from '../game/recruits';
import type { IndependentCardModulesOptions } from './fixture-independent-card-modules';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
import { takeHarassCard } from './fixture-harass-withdraw';
@@ -90,13 +91,17 @@ export function createNativeWorldRecruitsFixture(options: {
imperialCasualties?: boolean;
tech?: boolean;
strongholds?: boolean;
/** Default off; preferred cards are genuine original offers, not assignments. */
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
} = {}): NativeWorldRecruitsFixture {
const native = options.faction ?? 'ixians';
const advanced = options.advanced ?? false;
const tech = options.tech ?? false, strongholds = options.strongholds ?? false;
assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced');
const setup = createIndependentCardModulesFixture({ advanced,
homeworlds: true, tech, strongholds, factions: ['emperor', native, 'guild'],
homeworlds: true, tech, strongholds, skills: options.skills, training: options.training,
factions: ['emperor', native, 'guild'],
expansions: native === 'ixians' || native === 'tleilaxu' ? ['ix'] : ['choam'] });
let game = setup.game;
const afterSetup = structuredClone(game), steps = [...setup.steps];
@@ -109,7 +114,8 @@ export function createNativeWorldRecruitsFixture(options: {
assert.ok(Object.values(game.strongholdCards.owners).every(owner => owner === null),
'Only original end-Mentat may earn Stronghold Cards');
}
for (const [name, value] of [['Nexus Cards', game.nexusCards], ['Leader Skills', game.leaderSkills],
assert.equal(!!game.leaderSkills, options.skills ?? false);
for (const [name, value] of [['Nexus Cards', game.nexusCards],
['Discovery', game.discoveryEnabled]] as const)
assert.equal(value, undefined, `${name} must stay outside a native World Recruits programme`);
const visitor = 'emperor', owner = visitor;
@@ -121,6 +127,8 @@ export function createNativeWorldRecruitsFixture(options: {
const staging = ['Return original setup hands to the unopened Treachery deck; no weapon or defense is used.'];
if (tech || strongholds)
staging.push('Select requested unused original Tech/Advanced Strongholds before readiness; native first Storm assigns tokens and actual end-Mentat earns cards. No owner or income receipt is assigned.');
if (options.skills)
staging.push('Select the original all14 Skills lottery before initialization and assign actual offered cards; no live Skill or World state is replaced.');
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
const at = game.spiceDeck.findIndex((card, index) => index >= i && 'territory' in card && !card.discovery);
+40 -8
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
applyAction, createGame, initializeEcazTreacheryGameForAudit, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
@@ -9,7 +9,9 @@ import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nativeShipmentSources } from '../game/homeworld-options';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
import { takeHarassCard } from './fixture-harass-withdraw';
import { skillOfferRolls,
type IndependentCardModulesOptions } from './fixture-independent-card-modules';
export type RecruitsHomeworldStep = { actor: string; action: Action };
export type RecruitsHomeworldFixture = {
@@ -57,6 +59,8 @@ export function createRecruitsHomeworldFixture(options: {
advanced?: boolean;
seatIds?: readonly [string, string, string, string];
initial?: Game;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
} = {}): RecruitsHomeworldFixture {
const ids = options.seatIds ?? ['emperor', 'fremen', 'atreides', 'guild'];
let game = options.initial ? structuredClone(options.initial) :
@@ -72,18 +76,44 @@ export function createRecruitsHomeworldFixture(options: {
};
for (const p of game.players) if (!p.ready) act(p.id, { type: 'ready' });
const initial = structuredClone(game);
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
let cursor = 0;
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
assert.ok(array);
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length)
array[0] = rolls[cursor++];
else new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { game = initializeEcazTreacheryGameForAudit(game); }
try {
if (options.skills) {
game.ecazTreachery = true;
game = initializeLeaderSkillsGameForAudit(game);
} else game = initializeEcazTreacheryGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
game = advanceRecruitsHomeworld(game, g => g.status === 'playing', nativeSteps);
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!, player = recruitsHomeworldPlayer(game, actor);
const skill = options.training?.[player.faction] ?? offer.cards.find(card => !own.unavailableSkills?.[card]);
const leader = own.eligibleLeaders[0];
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader);
act(actor, { type: 'leaderSkill', event: offer.event, skill, leader: leader.id });
} else {
const step = nextHarassHomeworldStep(game); assert.ok(step);
act(step.actor, step.action);
}
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const staging = ['Return initial real hands to the unopened deck; no battle weapons, fake counters or wallet assignments.'];
if (options.skills)
staging.push('Select original all14 Skills before initialization of the supplied undealt lobby; assign actual offers without replacing seat identities, Worlds or original phase modules.');
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
// Original non-worm cards keep the first Blow away from an unrelated riding
// decision. This is not a fabricated Spice Card or phase assignment.
@@ -119,7 +149,8 @@ export function createRecruitsHomeworldFixture(options: {
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
}
if (game.battle && !game.battle.revealed) {
game = finishHarassPreparation(game);
game = advanceRecruitsHomeworld(game, g => recruitsHomeworldClean(g) &&
!g.battle?.preparation && g.battle?.preLeader?.closed !== false, nativeSteps);
const battle = game.battle!;
for (const actor of [battle.attacker, battle.defender]) {
if (game.battle!.plans[actor]) continue;
@@ -127,7 +158,8 @@ export function createRecruitsHomeworldFixture(options: {
const leaders = p.leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength);
const leader = actor === ids[0] || actor === ids[3] ? leaders.at(-1) : leaders[0]; assert.ok(leader);
act(actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
game = finishHarassPreparation(game);
game = advanceRecruitsHomeworld(game, g => recruitsHomeworldClean(g) &&
!g.battle?.preparation && g.battle?.preLeader?.closed !== false, nativeSteps);
}
} else {
const step = nextHarassHomeworldStep(game); assert.ok(step); act(step.actor, step.action);
+20 -3
View File
@@ -7,6 +7,7 @@ import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { takeHarassCard } from './fixture-harass-withdraw';
import type { IndependentCardModulesOptions } from './fixture-independent-card-modules';
import {
actHarassHomeworld, advanceHarassHomeworld, assertHarassHomeworldCustody,
createHarassHomeworldSetup, harassHomeworldClean, harassHomeworldPlayer,
@@ -21,6 +22,12 @@ export type ReinforcementsHomeworldStep = HarassHomeworldStep;
export type ReinforcementsHomeworldOptions = {
advanced?: boolean;
initial?: Game;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
tech?: boolean;
strongholds?: boolean;
/** Use the genuinely assigned trainer after the original visibility choice. */
skilled?: boolean;
seatIds?: [string, string, string];
role?: 'visitor' | 'native';
secondary?: boolean;
@@ -148,9 +155,13 @@ export function createReinforcementsHomeworldFixture(
takeHarassCard(game, setup.owner, laser); takeHarassCard(game, setup.opponent, shield);
}
staging.push('Return original hands to undealt stock, then conserve the physical Reinforcements and (explosion only) Lasgun/Shield into their owners’ hands; no card is manufactured.');
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt &&
(!game.leaderSkills?.assignments.some(a => a.owner === actor && a.leader === l.id) || actor === setup.owner && options.skilled))
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const ownLeader = outcome === 'win' ? living(setup.owner).at(-1)! : living(setup.owner)[0];
const trainer = options.skilled ? game.leaderSkills?.assignments.find(a => a.owner === setup.owner) : null;
assert.ok(!options.skilled || trainer, 'Select Skills before native setup to use its real trainer');
const ownLeader = trainer ? living(setup.owner).find(l => l.id === trainer.leader)!
: outcome === 'win' ? living(setup.owner).at(-1)! : living(setup.owner)[0];
const otherLeader = outcome === 'win' ? living(setup.opponent)[0] : living(setup.opponent).at(-1)!;
if (outcome === 'traitor') conserveTraitor(game, native,
native === setup.owner ? otherLeader.id : ownLeader.id, staging);
@@ -161,7 +172,13 @@ export function createReinforcementsHomeworldFixture(
action: { type: 'chooseBattle', territory: location,
target: battleChoice.attacker === battleChoice.chooser ? battleChoice.defender : battleChoice.attacker } };
act(battleStep);
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
for (let n = 0; n < 300; n++) {
if (game.battle && harassHomeworldClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) break;
const step = nextHarassHomeworldStep(game); assert.ok(step, 'Complete the original battle preparation');
if (step.action.type === 'leaderSkillVisibility') step.action.hide = step.actor === setup.owner && !!options.skilled;
act(step);
}
assert.ok(game.battle && harassHomeworldClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false);
assert.equal(Object.keys(game.battle!.plans).length, 0);
const sources = structuredClone(options.sources ?? reinforcementsHomeworldSources());
const dial = options.ownerDial ?? (role === 'visitor' || outcome === 'win' ? 2 : 0);
+59 -14
View File
@@ -1,12 +1,15 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame, newPlayer,
applyAction, createGame, initializeEcazTreacheryGameForAudit, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import type { Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import type { NoFieldValue } from '../game/richese-no-field';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { skillOfferRolls } from './fixture-independent-card-modules';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export const RICHESE_REINFORCEMENTS = 'ecaz-reinforcements';
@@ -40,6 +43,13 @@ export const RICHESE_ARMIES = [
export type RicheseReinforcementsOptions = {
advanced?: boolean;
family?: RicheseFamily;
/** Original all-fourteen setup, off unless selected before initialization. */
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
/** The Richese owner actually moves its trained disc behind the shield. */
skilled?: boolean;
/** Separate own-spice commitment, never reserve-fee or support funding. */
bankerSpice?: number;
/**
* Natural multi-turn route. 'tuning' is the deterministic Basic route: two
* real No-Field reveals plus one real tuning shipment reach a low reserve
@@ -103,7 +113,9 @@ export type RicheseReinforcementsFixture = {
plain: RicheseReinforcementsStep;
opponent: RicheseReinforcementsStep;
};
/** Every real native action the programme took, in order. */
/** Original dealt setup choices, including ordinary-disc training when selected. */
setupSteps: RicheseReinforcementsStep[];
/** Every real native action after setup, in order. */
nativeSteps: RicheseReinforcementsStep[];
/** Every conserved card exchange the programme staged, in order. */
staging: string[];
@@ -130,6 +142,9 @@ export function nextRicheseReinforcementsStep(
game: Game,
stoneMode: 'kill' | 'ignore' = 'ignore',
): RicheseReinforcementsStep | null {
const d = !game.response && !game.phaseOpening ? game.decision : null;
if (d?.kind === 'mentatQuestion' && d.stage === 'name')
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
const step = nextRicheseStrongholdsNativeStep(game);
if (step && game.decision?.kind === 'stoneBurner' &&
step.action.type === 'decision' && step.action.mode !== undefined)
@@ -138,14 +153,23 @@ export function nextRicheseReinforcementsStep(
}
/** Determinism is scoped to the original native initializer only. */
function initialize(game: Game): Game {
function initialize(game: Game, options: RicheseReinforcementsOptions): Game {
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
let cursor = 0;
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
else new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeEcazTreacheryGameForAudit(game); }
try {
if (options.skills) {
game.ecazTreachery = true;
return initializeLeaderSkillsGameForAudit(game);
}
return initializeEcazTreacheryGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
@@ -204,8 +228,10 @@ function transferCard(game: Game, actor: string, cardId: string, staging: string
}
function availableLeaders(game: Game, actor: string, battleTerritory: string) {
const assignment = game.leaderSkills?.assignments.find(skill => skill.owner === actor);
return richeseReinforcementsPlayer(game, actor).leaders
.filter(leader => !leader.dead && (!leader.usedAt || leader.usedAt === battleTerritory))
.filter(leader => !leader.dead && (!leader.usedAt || leader.usedAt === battleTerritory) &&
(leader.id !== assignment?.leader || game.battle?.leaderSkillHidden?.[actor] === true))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id));
}
@@ -232,6 +258,8 @@ export function createRicheseReinforcementsFixture(
assert.deepEqual([...game.expansions].sort(), family === 'choam' ? ['choam'] : ['choam', 'ix']);
assert.ok(game.players.some(player => player.faction === 'richese'));
if (game.status === 'setup') assert.equal(game.ecazTreachery, true);
if (game.status === 'setup' && options.skills)
assert.ok(game.leaderSkills, 'Skills must be selected in the original initializer, never retrofitted into setup');
} else {
const roster: FactionId[] = family === 'choam'
? ['richese', 'guild', 'atreides'] : ['richese', 'ixians', 'atreides'];
@@ -243,18 +271,30 @@ export function createRicheseReinforcementsFixture(
}
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game = initialize(game);
game = initialize(game, options);
}
const initial = structuredClone(game);
const setupSteps: RicheseReinforcementsStep[] = [];
for (let count = 0; game.status === 'setup' && count < 300; count++) {
let next: RicheseReinforcementsStep | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = { actor: player.id, action }; break; }
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!, player = richeseReinforcementsPlayer(game, actor);
const skill = options.training?.[player.faction] ?? offer.cards.find(card => !view.unavailableSkills?.[card]);
const leader = player.leaders.filter(disc => view.eligibleLeaders.some(eligible => eligible.id === disc.id))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(skill && offer.cards.includes(skill) && !view.unavailableSkills?.[skill] && leader);
next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else {
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = { actor: player.id, action }; break; }
}
}
assert.ok(next, `Original independent-card setup stalled at ${game.setupStage}/${game.decision?.kind}`);
setupSteps.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
@@ -387,7 +427,11 @@ export function createRicheseReinforcementsFixture(
assert.ok(beforeShipment && afterShipment && shipmentStep && beforeBattle && battleStep &&
reservesBefore && army, 'The timeline must record its final shipment, army and battle admission');
for (let count = 0; count < 120; count++) {
const step = nextRicheseReinforcementsStep(game);
const visibility = game.decision?.kind === 'leaderSkillVisibility' && !game.response && !game.phaseOpening
? game.decision : null;
const step = visibility ? { actor: visibility.player, action: { type: 'leaderSkillVisibility',
event: visibility.event, hide: visibility.player === richese && !!options.skilled } } satisfies RicheseReinforcementsStep
: nextRicheseReinforcementsStep(game);
if (!step) break;
act(step);
}
@@ -429,6 +473,7 @@ export function createRicheseReinforcementsFixture(
RicheseReinforcementsStep => ({
actor: richese,
action: { type: 'battlePlan', dial: ownDial, support: advanced ? ownDial : 0,
...(options.bankerSpice ? { bankerSpice: options.bankerSpice } : {}),
leader: ownLeader.id, weapon: machineSlot(weapon), defense: machineSlot(defense) },
});
const plans = {
@@ -443,7 +488,7 @@ export function createRicheseReinforcementsFixture(
initial, afterSetup, beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep,
game, richese, opponent, observer, family, advanced, route, location, city,
token: structuredClone(tokenOf(marker)), materializes, tuning, reservesBefore,
machines, hand, plans, nativeSteps, staging,
machines, hand, plans, setupSteps, nativeSteps, staging,
};
}
+218
View File
@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, nativeHandLimit, newPlayer, viewGame,
type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { treacheryDeck, type Card } from '../game/cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nextChoamIndependentSkillsStep } from './fixture-choam-independent-skills';
import { singleNexusE1Clean, singleNexusE1Player, shipSingleNexusE1,
openSingleNexusE1Battle, revealSingleNexusE1Plans, quoteSingleNexusE1Battle } from './fixture-single-nexus-e1';
import { skillOfferRolls } from './fixture-independent-card-modules';
export type SelectedDeckSkillsStep = { actor: string; action: Action };
export type SelectedDeckSkillsOptions = {
advanced?: boolean;
homeworlds?: boolean;
factions?: readonly FactionId[];
expansions?: Game['expansions'];
/** The first seat's confirmed module technique; later seats share the rest. */
skill?: LeaderSkillId;
/** Play conserved original poison weapons: only the attacker's, or both. */
poison?: 'attacker' | 'both';
};
export const selectedDeckSkillsPlayer = singleNexusE1Player;
export const selectedDeckSkillsClean = singleNexusE1Clean;
export const selectedDeckSkillsQuote = quoteSingleNexusE1Battle;
/** Ordinary techniques that need no card of their own to change a battle. */
const OTHER_SKILLS: readonly LeaderSkillId[] = ['killer-medic', 'swordmaster-of-ginaz',
'prana-bindu-adept', 'sandmaster', 'warmaster'];
/** Entropy selection is confined to the original setup lottery. */
function originalLottery<T>(draws: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = draws[cursor++] ?? 0xffffffff;
else if (array) new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Move one conserved physical face out of the real remaining deck. No face is
* ever minted, purchased or replaced, and the printed hand limit still binds. */
function conserveCard(game: Game, actor: string, matches: (card: Card) => boolean,
staging: string[]): Card {
const player = selectedDeckSkillsPlayer(game, actor);
const index = game.deck.findIndex(matches);
assert.ok(index >= 0, 'The conserved original face must still wait in the real deck');
while (player.hand.length >= nativeHandLimit(player)) {
const at = player.hand.findIndex(card => !card.id.startsWith('richese-'));
assert.ok(at >= 0, `${actor}'s conserved cache faces leave no deckable hand room`);
const [card] = player.hand.splice(at, 1);
game.deck.push(card);
staging.push(`Conserved ${card.id}: ${actor}'s hand → remaining deck to keep the printed hand limit`);
}
const [card] = game.deck.splice(index, 1);
player.hand.push(card);
staging.push(`Conserved ${card.id}: remaining deck → ${actor}'s hand (no purchase, payment or replacement)`);
return card;
}
/** The ordinary lobby controls a real host uses: select the module, ready every
* seat, start, then confirm the two private offers. No development initializer,
* no expansion seat beyond the selected decks and no independent card module. */
export function createSelectedDeckSkillsSetup(options: SelectedDeckSkillsOptions = {}) {
const factions = options.factions ?? ['ixians', 'richese', 'guild'];
assert.ok(factions.length >= 2 && new Set(factions).size === factions.length);
let game = createGame('SELECTEDDECKSKILLS', newPlayer(factions[0], factions[0], factions[0]),
options.advanced ?? false, options.expansions ?? ['ix', 'choam']);
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
const actions: SelectedDeckSkillsStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action }); game = applyAction(game, actor, action);
};
const initial = structuredClone(game);
if (options.homeworlds) act(game.host, { type: 'homeworlds', enabled: true });
act(game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const owner = game.players[0].id;
const chosen: LeaderSkillId[] = [options.skill ?? 'mentat'];
while (chosen.length < game.players.length)
chosen.push(OTHER_SKILLS.find(skill => !chosen.includes(skill))!);
const training = Object.fromEntries(game.players.map((player, index) => [player.faction, chosen[index]]));
game = originalLottery(skillOfferRolls(game, training), () => {
const action: Action = game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' };
actions.push({ actor: game.host, action }); return applyAction(game, game.host, action);
});
const started = structuredClone(game);
let ixOffer: { before: Game; step: SelectedDeckSkillsStep; after: Game } | null = null;
if (game.decision?.kind === 'ixSetup') {
const actor = game.decision.player, before = structuredClone(game);
const card = viewGame(game, actor).ixTechnology!.setup![0]; assert.ok(card);
const step: SelectedDeckSkillsStep = { actor, action: { type: 'decision', card: card.id } };
act(actor, step.action); ixOffer = { before, step, after: structuredClone(game) };
staging.push('The Ix seat drafted its ordinary Treachery Card before any module technique was confirmed.');
}
const offered = structuredClone(game), trainers: Record<string, string> = {};
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const skill = chosen[game.players.findIndex(player => player.id === actor)];
assert.ok(own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
const eligible = new Set(own.eligibleLeaders.map(leader => leader.id));
const leader = selectedDeckSkillsPlayer(game, actor).leaders.filter(disc => eligible.has(disc.id))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader, `The ${actor} seat still has an eligible original disc`);
trainers[actor] = leader.id;
act(actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id });
} else {
const step = nextChoamIndependentSkillsStep(game);
assert.ok(step, `Consume the original ${game.setupStage ?? game.decision?.kind} setup window`);
act(step.actor, step.action);
}
}
assert.equal(game.status, 'playing');
const seats: Partial<Record<FactionId, string>> = {};
for (const player of game.players) seats[player.faction] = player.id;
return { initial, started, offered, ixOffer, afterSetup: structuredClone(game),
owner, seats, trainers, skills: chosen, actions, staging };
}
/** Pauses at unsealed plans and typed physical choices; only real native
* controls advance the chronology. */
export function advanceSelectedDeckSkills(game: Game, until: (g: Game) => boolean,
actions: SelectedDeckSkillsStep[] = []): Game {
for (let count = 0; count < 3000; count++) {
if (until(game)) return game;
assert.equal(game.status, 'playing');
const step = nextChoamIndependentSkillsStep(game);
assert.ok(step, `Supply the real ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
actions.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
throw Error('The bare selected-deck chronology did not reach its requested boundary');
}
/** All cards come from the selected ordinary decks plus a seated Richese cache;
* the Auditor is a leader disc and no Traitor face is minted or dropped. */
export function assertSelectedDeckSkillsInventory(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
if (game.homeworlds) homeworldGameIntegrity(game);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
const original = [...treacheryDeck(game.expansions),
...(game.players.some(player => player.faction === 'richese') ? richeseCards() : [])];
assert.deepEqual(cards.map(card => card.id).sort(), original.map(card => card.id).sort());
for (const player of game.players) {
assert.ok(player.hand.length <= nativeHandLimit(player));
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0),
player.faction === 'ixians' ? 7 : player.faction === 'emperor' ? 5 : 3);
}
if (game.status !== 'lobby' && game.status !== 'setup') assert.deepEqual([
...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors),
...game.players.flatMap(player => (player.faceDancers ?? []).map(card => card.leader)),
].sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
}
/** A paid shipment into the untouched Guild army, real posture choices and both
* sealed plans. No hand staging beyond conserved faces and no invented spice. */
export function createSelectedDeckSkillsProgramme(options: SelectedDeckSkillsOptions = {}) {
const setup = createSelectedDeckSkillsSetup(options), actions = setup.actions;
const guild = setup.seats.guild;
assert.ok(guild, 'The programme needs a real Guild seat to attack');
let game = advanceSelectedDeckSkills(setup.afterSetup,
g => g.phase === 5 && g.active === setup.owner && selectedDeckSkillsClean(g), actions);
const attacked = options.poison ? conserveCard(game, setup.owner, card => card.kind === 'poison',
setup.staging) : undefined;
const defended = options.poison === 'both'
? conserveCard(game, guild, card => card.kind === 'poison' && card.id !== attacked!.id, setup.staging)
: undefined;
const shipment = shipSingleNexusE1(game, setup.owner, 'tueks_sietch', 2, 0, actions);
game = openSingleNexusE1Battle(shipment.after, setup.owner, guild, 'tueks_sietch', actions);
const beforePlans = structuredClone(game);
const defender = selectedDeckSkillsPlayer(game, guild).leaders
.filter(disc => !disc.dead && disc.id !== setup.trainers[guild])
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(defender, 'The Guild seat still holds an untrained original disc');
game = revealSingleNexusE1Plans(game, [
{ actor: setup.owner, action: { type: 'battlePlan', leader: setup.trainers[setup.owner], dial: 0,
...(attacked ? { weapon: attacked.id } : {}) } },
{ actor: guild, action: { type: 'battlePlan', leader: defender.id, dial: 0,
...(defended ? { weapon: defended.id } : {}) } },
], actions);
return { ...setup, guild, shipment, beforePlans, game, defender: defender.id,
weapons: [attacked?.id, defended?.id].filter((id): id is string => !!id) };
}
/** Original poison weapons kill both committed discs for real, then the same
* bare table continues into the actual Revival phase of the next turn. */
export function createSelectedDeckSkillsRevivalCase(options: { advanced?: boolean } = {}) {
const programme = createSelectedDeckSkillsProgramme({ advanced: options.advanced,
factions: ['tleilaxu', 'guild', 'ixians'], skill: 'mentat', poison: 'both' });
const tleilaxu = programme.seats.tleilaxu;
assert.ok(tleilaxu, 'The revival case needs a real Tleilaxu seat');
const actions = programme.actions;
let game = advanceSelectedDeckSkills(programme.game,
g => !g.battle && selectedDeckSkillsClean(g), actions);
const guild = selectedDeckSkillsPlayer(game, programme.guild);
const dead = guild.leaders.find(disc => disc.id === programme.defender)!;
const trainer = selectedDeckSkillsPlayer(game, tleilaxu).leaders
.find(disc => disc.id === programme.trainers[tleilaxu])!;
assert.equal(dead.dead, true, 'The attacker’s conserved poison kills the committed Guild disc');
assert.equal(trainer.dead, true, 'The defender’s conserved poison kills the committed Tleilaxu disc');
assert.ok(guild.leaders.filter(disc => !disc.dead).length > 0);
game = advanceSelectedDeckSkills(game,
g => g.turn === 2 && g.phase === 4 && g.status === 'playing' && selectedDeckSkillsClean(g), actions);
return { ...programme, tleilaxu, actions, game, dead: dead.id, trainer: trainer.id,
advanced: programme.afterSetup.advanced };
}
+22 -8
View File
@@ -11,7 +11,7 @@ import {
harassHomeworldClean, harassHomeworldPlayer, nextHarassHomeworldStep,
type HarassHomeworldStep,
} from './fixture-harass-homeworld';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions } from './fixture-independent-card-modules';
import { takeHarassCard } from './fixture-harass-withdraw';
import {
createReinforcementsHomeworldFixture, type ReinforcementsHomeworldFixture,
@@ -23,6 +23,9 @@ export type WorldPhaseBattleOptions = {
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
skilled?: boolean;
support?: number;
};
export type WorldPhaseBattleFixture = {
@@ -68,12 +71,13 @@ export function actWorldPhaseBattle(game: Game, step: WorldPhaseBattleStep,
actions?.push(structuredClone(step)); return firstStormAction(game, step);
}
export function advanceWorldPhaseBattle(game: Game, until: (state: Game) => boolean,
actions?: WorldPhaseBattleStep[]): Game {
actions?: WorldPhaseBattleStep[], skilled: readonly string[] = []): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextHarassHomeworldStep(game);
assert.ok(step, 'Supply the genuine human plan or physical return allocation');
if (step.action.type === 'leaderSkillVisibility') step.action.hide = skilled.includes(step.actor);
game = actWorldPhaseBattle(game, step, actions);
}
throw Error('Original World/phase-module programme did not reach its requested boundary');
@@ -87,10 +91,11 @@ export function advanceWorldPhaseBattle(game: Game, until: (state: Game) => bool
* Existing provisional utility/Stronghold timing is inherited, not a new ruling. */
export function createWorldPhaseBattleFixture(options: WorldPhaseBattleOptions = {}): WorldPhaseBattleFixture {
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const base = options.initial ? createHarassHomeworldSetup({ advanced, initial: options.initial }) : null;
const base = options.initial ? createHarassHomeworldSetup({ ...options, advanced, initial: options.initial }) : null;
const original = base ? { initial: base.initial, game: base.afterSetup, steps: base.nativeSteps }
: createIndependentCardModulesFixture({ advanced, homeworlds: true,
tech: options.tech ?? true, strongholds: options.strongholds ?? advanced,
skills: options.skills, training: options.training,
factions: ['emperor', 'guild', 'atreides'] });
let game = structuredClone(original.game);
const actions = [...original.steps], initial = structuredClone(original.initial);
@@ -153,10 +158,15 @@ export function createWorldPhaseBattleFixture(options: WorldPhaseBattleOptions =
const battleStep: WorldPhaseBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep);
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
game = advanceWorldPhaseBattle(game, g => !!g.battle && harassHomeworldClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, actions, options.skilled ? [owner] : []);
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt &&
(!game.leaderSkills?.assignments.some(a => a.owner === actor && a.leader === l.id) || actor === owner && options.skilled))
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const leader = living(owner).at(-1)!, opponentLeader = living(opponent)[0];
const trainer = options.skilled ? game.leaderSkills?.assignments.find(a => a.owner === owner) : null;
assert.ok(!options.skilled || trainer, 'Select Skills before native setup to use its real trainer');
const leader = trainer ? living(owner).find(l => l.id === trainer.leader)! : living(owner).at(-1)!;
const opponentLeader = living(opponent)[0];
const support = advanced ? options.support ?? 3 : 0;
const sources = { 'homeworld:atreides': { normal: 3, elite: 0 } };
const plans: WorldPhaseBattleFixture['plans'] = [
@@ -176,10 +186,12 @@ export function createWorldPhaseBattleFixture(options: WorldPhaseBattleOptions =
/** Exposes exactly the original source-key controls for a genuine own-World
* battle: ten normal Kaitain reserves move to Salusa; Guild pays to invade
* Kaitain. The three fee counters may come from either real normal pool. */
export function createWorldPhaseOwnBattleFixture(options: { initial?: Game; tech?: boolean } = {}): ReinforcementsHomeworldFixture {
export function createWorldPhaseOwnBattleFixture(options: Pick<WorldPhaseBattleOptions,
'initial' | 'tech' | 'skills' | 'training' | 'skilled'> = {}): ReinforcementsHomeworldFixture {
const initial = options.initial ?? createIndependentCardModulesFixture({ advanced: true, homeworlds: true,
tech: options.tech ?? true, strongholds: true, factions: ['emperor', 'guild', 'atreides'] }).initial;
const fixture = createReinforcementsHomeworldFixture({ advanced: true, initial, role: 'native', outcome: 'win',
skills: options.skills, training: options.training, skilled: options.skilled,
nativeTransfer: 10, sources: { 'homeworld:emperor:salusa': { normal: 3, elite: 0 } }, ownerDial: 2, ownerSupport: 2 });
takeHarassCard(fixture.game, fixture.owner, HARASS_WITHDRAW_CARD);
fixture.staging.push('Conserve original Harass & Withdraw from undealt stock into Emperor’s hand solely to exercise the native own-World ban; never seal it in the legal plan.');
@@ -190,10 +202,12 @@ export function createWorldPhaseOwnBattleFixture(options: { initial?: Game; tech
/** Seven original Emperor visitors invade Junction (3N4E), then two normal
* reserves move Kaitain → Salusa. R pays 1N Kaitain + 2N Salusa before H returns
* 2N to Kaitain and 3E to Salusa. Both modules start original and unused. */
export function createWorldPhaseVisitorBattleFixture(options: { initial?: Game } = {}): ReinforcementsHomeworldFixture {
export function createWorldPhaseVisitorBattleFixture(options: Pick<WorldPhaseBattleOptions,
'initial' | 'skills' | 'training' | 'skilled'> = {}): ReinforcementsHomeworldFixture {
const initial = options.initial ?? createIndependentCardModulesFixture({ advanced: true, homeworlds: true,
tech: true, strongholds: true, factions: ['emperor', 'guild', 'atreides'] }).initial;
const fixture = createReinforcementsHomeworldFixture({ advanced: true, initial, role: 'visitor', outcome: 'win',
skills: options.skills, training: options.training, skilled: options.skilled,
slot: 'weapon', nativeTransfer: 2, ownerDial: 2, ownerSupport: 1,
sources: { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 2, elite: 0 } } });
takeHarassCard(fixture.game, fixture.owner, HARASS_WITHDRAW_CARD);
+5 -1
View File
@@ -10,11 +10,14 @@ import {
recruitsHomeworldClean, recruitsHomeworldPlayer, type RecruitsHomeworldFixture, type RecruitsHomeworldStep,
} from './fixture-recruits-homeworld';
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
import type { IndependentCardModulesOptions } from './fixture-independent-card-modules';
export type WorldPhaseRecruitsOptions = {
advanced?: boolean;
strongholds?: boolean;
seatIds?: readonly [string, string, string, string];
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
};
export type WorldPhaseRecruitsFixture = {
@@ -72,7 +75,8 @@ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptio
return array;
};
let source: RecruitsHomeworldFixture;
try { source = createRecruitsHomeworldFixture({ advanced, initial: lobby, seatIds: ids }); }
try { source = createRecruitsHomeworldFixture({ advanced, initial: lobby, seatIds: ids,
skills: options.skills, training: options.training }); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
+44
View File
@@ -158,3 +158,47 @@ void test('Advanced: native CHOAM Reinforcements preserves the original Auditor
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished);
assertNativeWorldBattleInventory(game, f.afterSetup);
});
void test('all14 native Ix Suk saves only the post-fee committed Cyborgs, never the reserve fee or exported garrison', () => {
const f = createNativeWorldBattleFixture({ advanced: true, route: 'ix-native', skills: true, skilled: true,
tech: true, strongholds: true,
training: { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'sandmaster' } });
const original = structuredClone(f.game);
assert.deepEqual(f.sources, { 'homeworld:ixians': { normal: 1, elite: 2 } });
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null,
'Original unused Strongholds never funds support on its owner’s World');
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, dial: 4.5 }), RuleError);
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, defense: HARASS_WITHDRAW_CARD }), RuleError);
assert.deepEqual(f.game, original);
const revealed = revealNativeWorldBattle(f), forces = viewGame(revealed, f.owner).battle!.ownForces!;
assert.equal(forces.normal, 0); assert.equal(forces.elite, 2);
assert.equal(forces.normalFixedHalf, true); assert.equal(forces.eliteStrength, 2);
assert.equal(forces.freeSupport, false);
assert.deepEqual(viewGame(revealed, f.owner).battle!.traitorVoters, [f.owner]);
let game = advanceNativeWorldBattle(revealed, g => g.decision?.kind === 'sukRescue');
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: f.location, normal: 0, elite: 2 }]);
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 0, elite: 2, paidNormal: 0, paidElite: 0 });
assert.equal(nativeWorldBattlePlayer(game, f.owner).tanks, 3);
assert.equal(nativeWorldBattlePlayer(game, f.owner).elites!.tanks, 2);
assert.ok(game.decision.options.every(o => o.normal === 0 && o.elite <= 2),
'The normal fee and two fee Cyborgs are not Suk casualties');
const choice = game.decision.options.findIndex(o => o.elite === 2 && o.kept?.kind === 'elite');
assert.ok(choice >= 0);
const event = game.decision.event;
game = applyAction(game, f.owner, { type: 'decision', event, choice });
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical, {
player: f.owner, territory: f.location, source: 'native-homeworld', saved: { normal: 0, elite: 2 },
kept: { key: f.location, kind: 'elite' }, destinations: {}, tanks: { normal: 0, elite: 0 },
});
assert.deepEqual(nativeWorldBattlePool(game, f.owner, f.location), { normal: 0, elite: 2 });
assert.deepEqual(combatArmy(game, f.owner, 'homeworld:tleilaxu'), { normal: 9, elite: 0 });
assert.equal(nativeWorldBattlePlayer(game, f.owner).tanks, 3);
assert.equal(nativeWorldBattlePlayer(game, f.owner).elites!.tanks, 2);
assert.equal(nativeWorldBattlePlayer(game, f.owner).battleLosses, 5);
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(nativeWorldBattlePlayer(game, f.owner).hand.some(c => c.id === HARASS_WITHDRAW_CARD));
assertNativeWorldBattleInventory(game, f.afterSetup);
});
+68 -1
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, RuleError, viewGame, type Game } from '../game/engine';
import { spiceBankerControlState } from '../components/spice-banker';
import {
createNativeWorldMarkerFixture, nativeWorldMarkerInventory, nativeWorldMarkerPlan,
type NativeWorldMarkerFixture,
@@ -13,7 +14,7 @@ import {
function physicalCards(game: Game) {
return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
...(game.ixSetupCards ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
}
function primaryPool(game: Game, fixture: NativeWorldMarkerFixture) {
@@ -216,3 +217,69 @@ for (const family of ['choam', 'ix+choam'] as const) {
});
}
void test('all14 mixed native World marker keeps Banker funding private and separate from R fee and H return', () => {
const f = createNativeWorldMarkerFixture({ family: 'ix+choam', advanced: true, marker: 5,
skills: true, skilled: true, training: { richese: 'spice-banker' }, bankerSpice: 1, opponentDial: 8 });
const view = viewGame(f.game, f.richese), owner = richeseReinforcementsPlayer(f.game, f.richese);
const leader = String(f.plans.plain.action.leader);
assert.deepEqual(spiceBankerControlState(view, leader, 1), { eligible: true, maximum: 3 });
assert.deepEqual(spiceBankerControlState(view, leader, 2), { eligible: true, maximum: 2 });
assert.deepEqual(spiceBankerControlState(viewGame(f.game, f.opponent), leader, 1),
{ eligible: false, maximum: 0 }, 'Another viewer cannot commit the native owner’s skill or wallet.');
assert.deepEqual(view.battle!.reinforcements!.noField, { materializes: 5, reservesAfterReveal: 15 });
const plan = nativeWorldMarkerPlan(f, 'both', 'defense');
assert.deepEqual(plan.action.reinforcementsSources,
{ [f.source.homeworld]: { normal: 3, elite: 0 } });
const before = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.richese,
{ ...plan.action, dial: 2, support: 2, bankerSpice: 3 }), RuleError);
assert.deepEqual(f.game, before, 'Reserve funding and later H returns do not pay an overcommitted own-spice budget.');
const first = prepareRicheseReinforcementsPlan(f.game, plan);
assertStillConcealed(f, first);
assert.equal(richeseReinforcementsPlayer(first, f.richese).spice, owner.spice);
assert.equal(viewGame(first, f.richese).battle!.plans[f.richese].bankerSpice, 1);
assert.deepEqual(viewGame(first, f.observer).battle!.plans, {});
const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent);
assert.deepEqual(primaryPool(revealed, f), { normal: 15, elite: 0 });
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, f.richese);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.deepEqual(primaryPool(done, f), { normal: 16, elite: 0 },
'Five materialize, three pay R, then four actual undialed H survivors return to the same primary World.');
assert.equal(after.reserves, 16);
assert.equal(after.tanks, 4);
assert.equal(after.spice, owner.spice - 2, 'One own support plus one Banker commitment, not a reserve-fee subsidy.');
assert.equal(done.phase, 7, 'The original battle continues into collection with no synthetic history.');
assertSpent(done, RICHESE_REINFORCEMENTS);
assertSpent(done, RICHESE_HARASS);
assertConserved(f, done);
});
void test('all14 capped World marker refuses R before a skilled Mentat or future H return, then uses legal H', () => {
const f = createNativeWorldMarkerFixture({ marker: 5, reserves: 3, skills: true, skilled: true,
training: { richese: 'mentat' }, opponentDial: 8 });
const before = structuredClone(f.game), preview = viewGame(f.game, f.richese).battle!;
assert.equal(f.game.battle!.leaderSkillHidden![f.richese], true);
assert.deepEqual(preview.reinforcements!.noField, { materializes: 3, reservesAfterReveal: 0 });
assert.ok(preview.reinforcements!.blocked);
const raw = nativeWorldMarkerPlan(f);
delete raw.action.reinforcementsSources;
for (const plan of [raw, nativeWorldMarkerPlan(f, 'both')]) {
assert.throws(() => applyAction(f.game, f.richese, plan.action), RuleError);
assert.deepEqual(f.game, before, 'The refused first seal spends no skill, cards, token, wallet or native stock.');
}
const first = prepareRicheseReinforcementsPlan(f.game, nativeWorldMarkerPlan(f, 'harass'));
assertStillConcealed(f, first);
const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent);
assert.deepEqual(primaryPool(revealed, f), { normal: 0, elite: 0 });
assert.equal(richeseReinforcementsPlayer(revealed, f.richese).forces[f.location], 3,
'The actual five-token reveal is capped by its three original native counters.');
const done = finishRicheseReinforcementsBattle(revealed);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.deepEqual(primaryPool(done, f), { normal: 1, elite: 0 });
assert.equal(richeseReinforcementsPlayer(done, f.richese).tanks, 2);
assert.ok(richeseReinforcementsPlayer(done, f.richese).hand.some(card => card.id === RICHESE_REINFORCEMENTS));
assertSpent(done, RICHESE_HARASS);
assertConserved(f, done);
});
+69
View File
@@ -148,3 +148,72 @@ void test('Advanced original phase modules: native board-only Cyborg substitutio
'The genuine native typed casualty decision precedes the separate substitution and Face Dance queues');
assertNativeWorldBattleInventory(game, f.afterSetup);
});
void test('all14 earned Arrakeen funds actual Cyborg support while selected Suk rescues board losses before original Face Dance', () => {
const f = createNativeWorldPhaseBattleFixture({ skills: true, skilled: true, harass: false, support: 3,
training: { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'sandmaster' } });
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.owner);
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen');
let game = advanceNativeWorldBattle(revealNativeWorldPhaseBattle(f), g => g.decision?.kind === 'sukRescue', [],
{ casualties: { normal: 0, elite: 3 } });
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: f.location, normal: 3, elite: 3 }]);
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 0, elite: 3, paidNormal: 0, paidElite: 3 });
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - 1,
'Only the real earned Arrakeen benefit covers two of the three selected support');
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 0);
assert.ok(game.decision.options.every(o => o.normal === 0), 'The three original native fee normals are not rescuable');
const choice = game.decision.options.findIndex(o => o.elite === 3 && o.kept?.key === f.location && o.kept.kind === 'elite');
assert.ok(choice >= 0);
game = applyAction(game, f.owner, { type: 'decision', event: game.decision.event, choice });
game = settleNativeWorldPhaseBattle(game, f);
assert.equal(game.decision?.kind, 'faceDance', 'The actual native spectator continuation resumes after Suk and card disposal');
assert.deepEqual(pool(game, f.owner, 'homeworld:ixians'), { normal: 4, elite: 3 });
assert.deepEqual(combatArmy(game, f.owner, 'arrakeen'), { normal: 3, elite: 1 });
assert.equal(game.lastBattleContext!.sukRescue!.physical!.source, 'arrakis');
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical!.destinations,
{ 'homeworld:ixians': { normal: 0, elite: 2 } });
const done = finishNativeWorldPhaseBattle(game, f);
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(done.lastBattleContext!.sukRescue!.completed, true);
assert.equal(player(done, f.owner).tanks, 3);
assert.equal(player(done, f.owner).elites!.tanks, 0);
assert.equal(player(done, f.owner).battleLosses, 6);
assert.equal(done.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(done.strongholdCards!.owners.arrakeen, f.owner);
assert.ok(done.techTokens);
assertNativeWorldBattleInventory(done, f.afterSetup);
});
void test('Basic all14 visitor Suk keeps the only committed Cyborg before native Face Dance returns it and kills the trained disc', () => {
const f = createNativeWorldPhaseForeignBattleFixture({ advanced: false, skills: true, skilled: true, typedAftercare: true,
training: { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'sandmaster' } });
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null);
let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'sukRescue', [],
{ casualties: { normal: 0, elite: 1 } });
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 0, elite: 1, paidNormal: 0, paidElite: 0 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 2);
const choice = game.decision.options.findIndex(o => o.elite === 1 && o.kept?.key === f.location && o.kept.kind === 'elite');
assert.ok(choice >= 0);
game = applyAction(game, f.owner, { type: 'decision', event: game.decision.event, choice });
game = advanceNativeWorldBattle(game, g => g.decision?.kind === 'faceDance');
assert.equal(game.pendingIxSubstitution, undefined, 'The single board Cyborg was saved, while the two fee Cyborgs cannot supply substitution');
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 9, elite: 1 });
assert.equal(game.decision!.player, f.opponent);
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
game = applyAction(game, f.opponent, { type: 'decision', reveal: true, sources: {} });
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished);
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 0, elite: 0 });
assert.deepEqual(pool(game, f.owner, 'homeworld:ixians'), { normal: 9, elite: 2 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 2);
assert.equal(player(game, f.owner).leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
assert.ok(!game.leaderSkills!.assignments.some(a => a.leader === f.planActions[0].action.leader));
assert.equal(game.leaderSkills!.deck.filter(skill => skill === 'suk-graduate').length, 1,
'Original native Face Dance death returns the singleton trained card instead of retaining a dead assignment');
assertNativeWorldBattleInventory(game, f.afterSetup);
});
+14 -10
View File
@@ -1,21 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, RuleError, viewGame } from '../game/engine';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { createNativeWorldMarkerFixture, nativeWorldMarkerPlan } from './fixture-native-world-marker';
import {
createRicheseReinforcementsFixture, finishRicheseReinforcementsBattle,
finishRicheseReinforcementsBattle,
prepareRicheseReinforcementsPlan, RICHESE_REINFORCEMENTS,
richeseReinforcementsPlayer, sealRicheseReinforcementsPlan,
} from './fixture-richese-reinforcements';
function markerFixture(advanced: boolean) {
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true,
tech: true, strongholds: advanced, expansions: ['choam'], factions: ['richese', 'guild', 'atreides'] });
return createRicheseReinforcementsFixture({ advanced, family: 'choam', marker: 3,
...(advanced ? {} : { reserves: 5 }), initial: setup.initial });
return createNativeWorldMarkerFixture({ advanced, tech: true, strongholds: advanced,
family: 'choam', marker: 3, skills: true, skilled: true, opponentDial: 8,
training: { richese: advanced ? 'spice-banker' : 'mentat' },
...(advanced ? { bankerSpice: 1 } : { reserves: 5 }) });
}
void test('Basic native World Tech income cannot fund a marker fee from future withdrawal counters', () => {
void test('Basic all14 native World Tech income and Mentat strength cannot fund a marker fee from future withdrawal counters', () => {
const f = markerFixture(false);
const before = structuredClone(f.game);
const preview = viewGame(f.game, f.richese).battle!.reinforcements!;
@@ -25,7 +25,7 @@ void test('Basic native World Tech income cannot fund a marker fee from future w
delete raw.reinforcementsSources;
assert.throws(() => applyAction(f.game, f.richese, raw), RuleError);
assert.deepEqual(f.game, before);
const first = prepareRicheseReinforcementsPlan(f.game, f.plans.harass);
const first = prepareRicheseReinforcementsPlan(f.game, nativeWorldMarkerPlan(f, 'harass'));
const done = finishRicheseReinforcementsBattle(sealRicheseReinforcementsPlan(first, f.plans.opponent));
const owner = richeseReinforcementsPlayer(done, f.richese);
assert.equal(owner.reserves, 3);
@@ -33,9 +33,12 @@ void test('Basic native World Tech income cannot fund a marker fee from future w
assert.ok(owner.hand.some(card => card.id === RICHESE_REINFORCEMENTS));
});
void test('Advanced native World Tech and Strongholds keep marker materialization and the later reserve fee disjoint', () => {
void test('Advanced all14 native World Tech and Strongholds keep Banker support, marker materialization and reserve fee disjoint', () => {
const f = markerFixture(true);
const first = prepareRicheseReinforcementsPlan(f.game, f.plans.reinforcements);
const beforeSpice = richeseReinforcementsPlayer(f.game, f.richese).spice;
const plan = nativeWorldMarkerPlan(f);
assert.deepEqual(plan.action.reinforcementsSources, { [f.source.homeworld]: { normal: 3, elite: 0 } });
const first = prepareRicheseReinforcementsPlan(f.game, plan);
assert.equal(richeseReinforcementsPlayer(first, f.richese).reserves, 20);
assert.ok(richeseReinforcementsPlayer(first, f.richese).noField!.deployed);
const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent);
@@ -45,6 +48,7 @@ void test('Advanced native World Tech and Strongholds keep marker materializatio
const owner = richeseReinforcementsPlayer(done, f.richese);
assert.equal(owner.reserves, 14);
assert.equal(owner.tanks, 6);
assert.equal(owner.spice, beforeSpice - 2, 'One actual force support and one own Banker commitment settle separately.');
const home = viewGame(done, f.richese).homeworlds?.worlds?.find(world => world.native === f.richese);
assert.deepEqual(home?.forces[f.richese], { normal: 14, elite: 0 });
assert.equal(done.discard.filter(card => card.id === RICHESE_REINFORCEMENTS).length, 1);
+76
View File
@@ -174,3 +174,79 @@ void test('Advanced native CHOAM + Tech: unlimited paid/free returns retain thei
const completed = programme.finish(game);
assert.equal(player(completed.afterPhaseEnd, native).spice, player(f.game, native).spice - 10);
});
void test('Basic all14 native Ix: actual paid cyborgs accrue Banker separately from once-only phase-end Axlotl', () => {
const f = createNativeWorldRecruitsFixture({ skills: true, tech: true, faction: 'ixians',
training: { emperor: 'mentat', ixians: 'swordmaster-of-ginaz', guild: 'spice-banker' } });
const native = f.native, banker = 'guild';
const programme = axlotlProgramme(f.game, native);
const deferred = (game: Game) => viewGame(game, banker).spiceBankerIncome!.deferred
.find(row => row.owner === banker)?.amount ?? 0;
const originalBankerWallet = player(f.game, banker).spice;
const paid = requestNativeWorldRecruits(f.game, native, 3, 1, 0);
assert.deepEqual({ cost: paid.price, wallet: paid.walletDelta, free: paid.free },
{ cost: 3, wallet: 3, free: 2 });
assert.equal(deferred(paid.game), 0, 'The actual three-spice bank payment is below Banker threshold');
let game = applyAction(paid.game, native, { type: 'ready' });
game = applyAction(game, f.owner, play);
assert.equal(player(game, native).spice, player(f.game, native).spice - 3);
assert.equal(player(game, native).revived, 3);
assert.equal(player(game, native).freeForcesRevived, 2);
const crossing = requestNativeWorldRecruits(game, native, 2, 2, 2);
assert.deepEqual(crossing.nativeAfter, { normal: 2, elite: 3 });
assert.equal(crossing.price, 0);
assert.equal(deferred(crossing.game), 0, 'Typed free deposits are not qualifying bank payments');
assert.equal(viewGame(crossing.game, native).revival.homeworldBonus, 0);
assert.equal(viewGame(crossing.game, native).revival.freeRemaining, 0);
rejectsUnchanged(crossing.game, native, { type: 'revive', amount: 2, elite: 2 });
const last = requestNativeWorldRecruits(crossing.game, native, 2, 1, 0);
assert.deepEqual({ cost: last.price, wallet: last.walletDelta, free: last.free },
{ cost: 5, wallet: 5, free: 0 });
assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 });
assert.equal(player(last.game, native).revived, 7);
assert.equal(player(last.game, native).freeForcesRevived, 4);
assert.equal(player(last.game, native).tanks, 7);
assert.equal(player(last.game, native).elites!.tanks, 0);
assert.equal(deferred(last.game), 1);
const receipt = last.game.spiceBankerIncome!.sources.at(-1)!;
assert.equal(receipt.source.kind, 'force-revival');
assert.deepEqual(receipt.source.bankLegs, [{ payer: native, amount: 5 }]);
assert.equal(receipt.grant?.owner, banker);
assert.equal(player(last.game, banker).spice, originalBankerWallet,
'Normal Banker income stays in front of the shield until Mentat');
const completed = programme.finish(last.game);
assert.equal(deferred(completed.afterPhaseEnd), 1, 'Revival end pays Axlotl, not Banker');
assert.equal(deferred(completed.afterShipment), 1);
assert.equal(player(completed.afterPhaseEnd, native).spice, player(f.game, native).spice - 8);
assert.equal(player(completed.afterPhaseEnd, banker).spice,
originalBankerWallet + (programme.owner === banker ? programme.income : 0));
game = completed.afterShipment;
for (let count = 0; count < 300 && !(game.phase === 7 && nativeWorldRecruitsClean(game)); count++) {
if (!game.battle && game.phase === 6 && nativeWorldRecruitsClean(game)) {
const choice = viewGame(game, game.active!).battleChoices[0]; assert.ok(choice);
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
} else if (game.battle && !game.battle.revealed && nativeWorldRecruitsClean(game) &&
!game.battle.preparation && game.battle.preLeader?.closed !== false) {
const actor = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.plans[id]);
assert.ok(actor);
const leader = player(game, actor).leaders.filter(disc => !disc.dead && !disc.usedAt)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader);
game = applyAction(game, actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
} else {
const step = nextNativeWorldRecruitsStep(game); assert.ok(step);
game = applyAction(game, step.actor, step.action);
}
}
assert.equal(game.phase, 7);
assert.equal(deferred(game), 1);
const beforeMentatWallet = player(game, banker).spice;
const mentat = advanceNativeWorldRecruits(game, state => state.phase === 8 && nativeWorldRecruitsClean(state));
assert.equal(deferred(mentat), 0);
assert.equal(player(mentat, banker).spice, beforeMentatWallet + 1,
'The one actual qualifying bank invoice is collected only at original Mentat');
assert.equal(mentat.techTokens!.axlotl.spice, 0);
assert.equal(mentat.techTokens!.axlotl.triggeredTurn, 2);
assertNativeWorldRecruitsInventory(mentat);
});
+29
View File
@@ -227,3 +227,32 @@ for (const advanced of [false, true]) {
continueToShipment(game);
});
}
void test('Advanced all14 native Tleilaxu keeps its unlimited return, accepted low-World invoice and separate own free income', () => {
const f = createNativeWorldRecruitsFixture({ advanced: true, skills: true, faction: 'tleilaxu',
training: { emperor: 'mentat', tleilaxu: 'swordmaster-of-ginaz', guild: 'spice-banker' } });
const active = applyAction(f.game, f.owner, play);
const returned = requestNativeWorldRecruits(active, f.native, 11);
assert.deepEqual({ price: returned.price, free: returned.free, wallet: returned.walletDelta },
{ price: 5, free: 6, wallet: 4 });
assert.deepEqual(returned.nativeAfter, { normal: 11, elite: 0 });
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).spice, 1);
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).revived, 11);
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).freeForcesRevived, 6);
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).tanks, 9);
const current = viewGame(returned.game, f.native).revival;
assert.deepEqual({ limit: current.limit, remaining: current.forcesRemaining,
free: current.freeRemaining, bonus: current.homeworldBonus },
{ limit: 20, remaining: 9, free: 0, bonus: 0 });
const receipt = returned.game.spiceBankerIncome!.sources.at(-1)!;
assert.equal(receipt.source.kind, 'force-revival');
assert.deepEqual(receipt.source.bankLegs, [{ payer: f.native, amount: 5 }],
'The actual paid invoice, not its net four-spice wallet delta, is the bank leg');
assert.equal(receipt.grant?.owner, 'guild');
const next = continueToShipment(returned.game);
assert.equal(nativeWorldRecruitsPlayer(next, f.native).spice, 1);
assert.equal(nativeWorldRecruitsPlayer(next, f.native).revived, 11);
assert.equal(nativeWorldRecruitsPlayer(next, f.native).freeForcesRevived, 6);
assert.equal(viewGame(next, 'guild').spiceBankerIncome!.deferred
.find(row => row.owner === 'guild')?.amount, 1, 'Banker is not paid at Revival end');
});
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, type Action, type Game } from '../game/engine';
import { nativeHomeworldArmy } from './fixture-homeworld-native-e1e2-modules';
import {
advanceSelectedDeckSkills, assertSelectedDeckSkillsInventory, createSelectedDeckSkillsProgramme,
createSelectedDeckSkillsRevivalCase, selectedDeckSkillsClean, selectedDeckSkillsPlayer,
selectedDeckSkillsQuote,
} from './fixture-selected-deck-skills';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
void test('bare Basic Ix/CHOAM table deals native setup and continues a paid shipment without independent cards', () => {
const f = createSelectedDeckSkillsProgramme();
const owner = f.owner, guild = f.guild;
assert.equal(f.afterSetup.richeseCache!.length, 10, 'A seated Richese keeps its separate ten-card cache.');
// The real Ix card draft happens before any module technique.
assert.equal(f.started.setupStage, 'skillTreachery');
assert.ok(f.ixOffer && f.ixOffer.after.setupStage === 'leaderSkills');
assert.equal(f.ixOffer.after.leaderSkills!.assignments.length, 0);
// Real spice pays for a real shipment into the untouched Guild army.
const committed = selectedDeckSkillsPlayer(f.shipment.before, guild).forces['tueks_sietch:5'];
assert.equal(selectedDeckSkillsPlayer(f.shipment.before, owner).spice -
selectedDeckSkillsPlayer(f.shipment.after, owner).spice, 2);
assert.equal(selectedDeckSkillsPlayer(f.shipment.after, owner).forces['tueks_sietch:5'], 2);
assert.equal(selectedDeckSkillsPlayer(f.shipment.after, guild).forces['tueks_sietch:5'], committed);
// The trained Mentat really changes the battle total.
const quote = selectedDeckSkillsQuote(f.game);
assert.equal(quote.winner, owner);
assert.deepEqual(quote.leaderSkillBonuses.attacker.applied,
[{ skill: 'mentat', amount: 2, mode: 'skilled' }]);
assert.equal(quote.leaderSkillBonuses.defender.bonus, 0);
// The same position continues into the next real phase with real losses.
const done = advanceSelectedDeckSkills(f.game, g => !g.battle && selectedDeckSkillsClean(g), f.actions);
assert.equal(done.phase, 7);
assert.equal(selectedDeckSkillsPlayer(done, owner).forces['tueks_sietch:5'], 2, 'The winner keeps the territory.');
assert.equal(selectedDeckSkillsPlayer(done, guild).forces['tueks_sietch:5'], undefined);
assert.equal(selectedDeckSkillsPlayer(done, guild).tanks, selectedDeckSkillsPlayer(f.game, guild).tanks + committed);
assertSelectedDeckSkillsInventory(done);
});
void test('the same bare table with original Homeworlds ships out of its real native pools', () => {
const f = createSelectedDeckSkillsProgramme({ homeworlds: true });
const owner = f.owner, home = `homeworld:${selectedDeckSkillsPlayer(f.shipment.before, owner).faction}`;
assert.ok(f.afterSetup.homeworlds?.custody);
const before = nativeHomeworldArmy(f.shipment.before, owner, home);
const after = nativeHomeworldArmy(f.shipment.after, owner, home);
assert.equal(before.normal + before.elite - after.normal - after.elite, 2);
assert.equal(selectedDeckSkillsPlayer(f.shipment.before, owner).spice -
selectedDeckSkillsPlayer(f.shipment.after, owner).spice, 2);
assert.equal(selectedDeckSkillsPlayer(f.shipment.after, owner).forces['tueks_sietch:5'], 2);
assertSelectedDeckSkillsInventory(f.shipment.after);
const done = advanceSelectedDeckSkills(f.game, g => !g.battle && selectedDeckSkillsClean(g), f.actions);
assert.equal(done.phase, 7);
assertSelectedDeckSkillsInventory(done);
});
void test('a base-only table needs only its selected ordinary deck, even when no seat uses that deck', () => {
const f = createSelectedDeckSkillsProgramme({ factions: ['emperor', 'atreides', 'guild'], expansions: ['ecaz'] });
assert.equal(selectedDeckSkillsQuote(f.game).winner, f.owner);
const done = advanceSelectedDeckSkills(f.game, g => !g.battle && selectedDeckSkillsClean(g), f.actions);
assert.equal(done.phase, 7);
assertSelectedDeckSkillsInventory(done);
});
void test('a real battle death keeps the native Advanced-only foreign ghola guard on a bare table', () => {
const basic = createSelectedDeckSkillsRevivalCase({ advanced: false });
const advanced = createSelectedDeckSkillsRevivalCase({ advanced: true });
for (const f of [basic, advanced]) {
assert.equal(f.game.turn, 2);
assert.equal(f.game.phase, 4);
assert.equal(f.game.players.flatMap(p => p.leaders).filter(l => l.id === f.dead).length, 1);
assert.equal(f.game.leaderSkills!.assignments
.some(a => a.owner === f.tleilaxu && a.leader === f.trainer), false,
'The poisoned trainer returned its physical technique.');
assertSelectedDeckSkillsInventory(f.game);
}
const action: Action = { type: 'reviveForeignGhola', leader: basic.dead };
const untouched = reload(basic.game);
assert.throws(() => applyAction(basic.game, basic.tleilaxu, action), /advanced Tleilaxu/);
assert.deepEqual(basic.game, untouched, 'A Basic bare table pays nothing and revives nothing.');
const disc = selectedDeckSkillsPlayer(advanced.game, advanced.guild).leaders.find(l => l.id === advanced.dead)!;
const cost = Math.ceil(disc.strength / 2), purse = selectedDeckSkillsPlayer(advanced.game, advanced.tleilaxu).spice;
const revived = applyAction(reload(advanced.game), advanced.tleilaxu, action);
assert.equal(disc.dead, true, 'The caller-owned input keeps its own physical disc.');
assert.equal(selectedDeckSkillsPlayer(revived, advanced.guild).leaders.find(l => l.id === advanced.dead)!.dead, false);
assert.equal(selectedDeckSkillsPlayer(revived, advanced.guild).leaders.find(l => l.id === advanced.dead)!.gholaBy,
advanced.tleilaxu);
assert.equal(selectedDeckSkillsPlayer(revived, advanced.tleilaxu).spice, purse - cost);
assert.ok(!revived.response && !revived.pendingRevival);
assert.deepEqual(normalizeAutomaticGame(reload(revived)), revived,
'The Advanced bare selected-deck table keeps the foreign ghola custody authorized.');
assertSelectedDeckSkillsInventory(revived);
});
+2 -19
View File
@@ -2,9 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { createElement } from 'react';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { basicTleilaxuLeaderSkillsProfile } from '../game/leader-skill-profile';
import { registerHooks } from 'node:module';
const aliases = registerHooks({resolve(specifier,context,next){return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier,context);}});
const { RihaniChoice } = await import('../components/rihani-decipherer');
@@ -55,28 +54,12 @@ function exchange(g: Game, owner: string) {
return act(g, owner, { type: 'decision', event, cards: [r.drawn[0], r.eligible[0]] });
}
void test('all fourteen skills admit genuine Basic Tleilaxu setup while Richese and module gates remain closed', () => {
void test('all fourteen skills assign genuine Basic Tleilaxu discs through original setup', () => {
for (const card of LEADER_SKILL_CARDS) {
const game = completedTleilaxuSkillsGame({ requestedSkill: card.id });
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 't')!.skill, card.id);
assert.equal(basicTleilaxuLeaderSkillsProfile(game), true);
assert.equal(basicTleilaxuLeaderSkillsProfile(viewGame(game, 'a')), true);
stable(game);
}
// Native E1 Discovery Leader Skills are now admitted for this fresh lobby;
// unsupported Discovery overlays still reject.
const discovery = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
joinGame(discovery,newPlayer('e','Emperor','emperor'));discovery.players.forEach(p => {p.ready = true;});
discovery.discoveryEnabled = true;
assert.equal(basicTleilaxuLeaderSkillsProfile(initializeLeaderSkillsGameForAudit(discovery)), true);
for (const mutate of [(g: Game) => {g.players[1] = newPlayer('x','Richese','richese');},
(g: Game) => {g.expansions.push('choam');},
(g: Game) => {g.discoveryEnabled = true; g.players[1] = newPlayer('x','Richese','richese');},
(g: Game) => {g.discoveryEnabled = true; g.expansions.push('choam');}]) {
const g = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
joinGame(g,newPlayer('e','Emperor','emperor'));g.players.forEach(p => {p.ready = true;});mutate(g);
const before = JSON.stringify(g);assert.throws(() => initializeLeaderSkillsGameForAudit(g));assert.equal(JSON.stringify(g),before);
}
});
void test('native Rihani keeps a private Face Dancer, returns only an old unrevealed card and survives JSON across every choice', () => {
+65
View File
@@ -5,6 +5,7 @@ import { combatArmy } from '../game/combat-location';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { harassHomeworldPool as pool, finishHarassHomeworldBattle } from './fixture-harass-homeworld';
import { advanceNativeWorldBattle } from './fixture-native-world-battle';
import {
finishReinforcementsHomeworldBattle, revealReinforcementsHomeworldBattle,
} from './fixture-reinforcements-homeworld';
@@ -188,3 +189,67 @@ void test('foreign World R+H: original split normal fee precedes exact normal/el
assert.equal(done.battle, null); assert.ok(worldPhaseBattleClean(done));
assertWorldPhaseBattleInventory(done);
});
void test('all14 Emperor visitor chooses original typed H returns or committed-only Suk rescue after the same split normal fee', () => {
const f = createWorldPhaseVisitorBattleFixture({ skills: true, skilled: true,
training: { emperor: 'suk-graduate', guild: 'swordmaster-of-ginaz', atreides: 'sandmaster' } });
assert.deepEqual(f.sources, {
'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 2, elite: 0 },
});
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null,
'Original unused modules never subsidize a foreign World');
const trainer = f.planActions[0].action.leader;
const untrained = player(f.game, f.owner).leaders.filter(l => !l.dead && !l.usedAt && l.id !== trainer)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(untrained);
f.planActions[0].action.leader = untrained.id;
const withdrawn = finishHarassHomeworldBattle(revealReinforcementsHomeworldBattle(f),
{ returns: { [f.location]: { normal: 2, elite: 3 } } });
assert.deepEqual(pool(withdrawn, f.owner, 'homeworld:emperor'), { normal: 11, elite: 0 });
assert.deepEqual(pool(withdrawn, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 4 });
assert.equal(player(withdrawn, f.owner).tanks, 5);
assert.equal(player(withdrawn, f.owner).elites!.tanks, 1);
assert.equal(withdrawn.lastBattleContext!.sukRescue, undefined,
'A concealed native trainer used as a bluff grants neither normal nor skilled Suk rescue');
assert.deepEqual(viewGame(f.game, f.owner).battle!.traitorVoters, [f.opponent]);
assertWorldPhaseBattleInventory(withdrawn);
// The real alternative keeps H in hand and lets the selected surviving disc
// rescue only the two actually committed visitor casualties.
f.planActions[0].action.defense = null;
f.planActions[0].action.leader = trainer;
let game = advanceNativeWorldBattle(revealReinforcementsHomeworldBattle(f), g => g.decision?.kind === 'sukRescue', [],
{ casualties: { normal: 1, elite: 1 } });
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: f.location, normal: 3, elite: 4 }]);
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 0);
assert.ok(game.decision.options.every(o => o.normal + o.elite <= 2), 'The original fee3N cannot enlarge the rescue');
const choice = game.decision.options.findIndex(o => o.normal === 1 && o.elite === 1 && o.kept?.kind === 'normal');
assert.ok(choice >= 0);
const action = { type: 'decision', event: game.decision.event, choice };
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.owner, action), 'Two native homes need a sealed typed rescue allocation');
assert.throws(() => applyAction(game, f.owner, { ...action,
destinations: { [f.location]: { normal: 0, elite: 1 } } }), 'Foreign visitor custody is never a reserve return destination');
assert.deepEqual(game, before);
game = applyAction(game, f.owner, { ...action,
destinations: { 'homeworld:emperor:salusa': { normal: 0, elite: 1 } } });
game = finishReinforcementsHomeworldBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(game.lastBattleContext!.sukRescue!.physical!.source, 'visitor-homeworld');
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical!.destinations,
{ 'homeworld:emperor:salusa': { normal: 0, elite: 1 } });
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor'), { normal: 9, elite: 0 });
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 2 });
assert.deepEqual(pool(game, f.owner, f.location), { normal: 3, elite: 3 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 0);
assert.equal(player(game, f.owner).battleLosses, 5);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - 1,
'Native Worlds cannot borrow Arrakeen support or refund the original paid support');
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(player(game, f.owner).hand.some(c => c.id === HARASS_WITHDRAW_CARD));
assertWorldPhaseBattleInventory(game);
});
+31 -4
View File
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { ownedTech } from '../game/tech-tokens';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { assertRecruitsHomeworldInventory } from './fixture-recruits-homeworld';
import { advanceRecruitsHomeworld, assertRecruitsHomeworldInventory,
recruitsHomeworldClean } from './fixture-recruits-homeworld';
import {
createWorldPhaseRecruitsFixture, worldPhaseRecruitsNativePool as pool,
worldPhaseRecruitsPlayer as player,
@@ -108,9 +109,12 @@ for (const options of cases) {
});
}
void test('A bulk native low-population request locks its quoted seven, unlike the six separate typed returns', () => {
const f = createWorldPhaseRecruitsFixture();
const { fremen } = f.actors;
void test('Advanced all14 Worlds + Tech + earned Strongholds: a bulk low-World seven differs from separate typed six through phase settlement', () => {
const f = createWorldPhaseRecruitsFixture({ advanced: true, strongholds: true, skills: true,
seatIds: ['world-emperor', 'world-fremen', 'world-atreides', 'world-guild'],
training: { emperor: 'swordmaster-of-ginaz', fremen: 'mentat',
atreides: 'warmaster', guild: 'spice-banker' } });
const { fremen, atreides, owner, guild } = f.actors;
const bulk = applyAction(f.beforeFremen, fremen, { type: 'revive', amount: 7, elite: 0 });
assert.equal(player(bulk, fremen).revived, 7);
assert.equal(player(bulk, fremen).freeForcesRevived, 7);
@@ -120,5 +124,28 @@ void test('A bulk native low-population request locks its quoted seven, unlike t
assert.deepEqual(pool(bulk, fremen), { normal: 9, elite: 0 });
assert.equal(bulk.techTokens!.axlotl.spice, f.expected.income,
'Another eligible bulk return cannot accrue a second payment in the same original phase');
assert.equal(player(f.afterReturns, fremen).revived, 6,
'The separate typed request crosses the native population threshold before its followup');
assert.equal(player(f.afterReturns, fremen).elites!.reserves, 1);
assert.equal(viewGame(f.afterTypedReturn, fremen).revival.eliteRemaining, 0);
rejectsUnchanged(f.afterTypedReturn, fremen, { type: 'revive', amount: 1, elite: 1 });
const next = advanceRecruitsHomeworld(bulk,
game => game.phase === 5 && recruitsHomeworldClean(game));
assert.equal(next.turn, 2);
assert.equal(player(next, fremen).revived, 7);
assert.equal(player(next, fremen).freeForcesRevived, 7);
assert.deepEqual(pool(next, fremen), { normal: 9, elite: 0 });
assert.equal(player(next, fremen).spice, player(f.beforeFremen, fremen).spice);
assert.equal(player(next, atreides).revived, 5);
assert.equal(player(next, atreides).freeForcesRevived, 4);
assert.equal(player(next, atreides).spice, player(f.game, atreides).spice - 2,
'Bulk free return and phase end do not refund the original paid reopening ledger');
assert.equal(next.techTokens!.axlotl.spice, 0);
assert.equal(next.techTokens!.axlotl.triggeredTurn, 2);
assert.equal(player(next, owner).spice, player(bulk, owner).spice + f.expected.income);
assert.equal(viewGame(next, guild).spiceBankerIncome!.deferred.length, 0,
'Free typed or bulk deposits and the two-spice invoice never qualify normal Banker');
assert.deepEqual(next.strongholdCards, f.game.strongholdCards);
assertRecruitsHomeworldInventory(next);
assertRecruitsHomeworldInventory(bulk);
});