Compose World Skills cards with selected ordinary decks
This commit is contained in:
1 parent
b9330d489e
commit
01985af36b
36 files changed
+1201
-283
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))!;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user