Unify fresh/live classic and native phase admission, retaining original counts, earned custody, separate income ledgers and physical card/skill continuations. Plain paired E3 phases no longer require cards or Nexus. Coupled44-file471/471, types/lint/build, genuine programmes and390px normal entry pass. Controlled card inputs explicit; no save retrofit or deployment.
735 lines
38 KiB
TypeScript
735 lines
38 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, joinGame, nativeHandLimit, newPlayer, viewGame,
|
|
type Action, type Game, type Player,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { MOBILE_STRONGHOLD, territory } from '../game/board';
|
|
import type { Card } from '../game/cards';
|
|
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
|
import { validateLeaderSkills } from '../game/leader-skills';
|
|
import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
|
|
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
|
import type { NoFieldValue } from '../game/richese-no-field';
|
|
import { nextRicheseReinforcementsStep } from './fixture-richese-reinforcements';
|
|
|
|
export type ChoamIndependentSkillsStep = { actor: string; action: Action };
|
|
/**
|
|
* 'immediate' ships the final concealed marker straight from twenty reserves on
|
|
* turn one (the only deterministic Advanced timeline: later turns depend on the
|
|
* original storm card). 'tuning' is the Basic route whose two real reveals plus
|
|
* one real paid tuning shipment leave an exact reserve budget off the one
|
|
* deterministic opening Spice Blow.
|
|
*/
|
|
export type ChoamIndependentSkillsRoute = 'tuning' | 'immediate';
|
|
export type ChoamIndependentSkillsPolicy = {
|
|
/** Real posture decision per seat: hide the trained leader behind the shield. */
|
|
hide?: Record<string, boolean>;
|
|
stone?: 'kill' | 'ignore';
|
|
guildTiming?: 'first' | 'last';
|
|
};
|
|
export type ChoamIndependentSkillsOptions = {
|
|
/** Continue only the original fresh undealt lobby or its own post-start setup. */
|
|
initial?: Game;
|
|
advanced?: boolean;
|
|
tech?: boolean;
|
|
strongholds?: boolean;
|
|
route?: ChoamIndependentSkillsRoute;
|
|
marker?: NoFieldValue;
|
|
/** Own physical reserves the Basic 'tuning' route must leave before shipping. */
|
|
reserves?: number;
|
|
/** Stop at the live pre-plan battle window so the caller seals the plans. */
|
|
stopBeforePlans?: boolean;
|
|
seatIds?: [string, string, string];
|
|
};
|
|
export type ChoamIndependentSkillsSetup = {
|
|
/** The original fresh undealt lobby before any staged module selection. */
|
|
initial: Game;
|
|
/** Immediately after the host's real module-selected start: skill offers dealt. */
|
|
offered: Game;
|
|
afterSetup: Game;
|
|
richese: string;
|
|
choam: string;
|
|
classic: string;
|
|
/** Requested and actually assigned skill per seat id. */
|
|
skills: Record<string, LeaderSkillId>;
|
|
/** The leader disc each seat attached its skill to. */
|
|
trainers: Record<string, string>;
|
|
actions: ChoamIndependentSkillsStep[];
|
|
staging: string[];
|
|
};
|
|
export type ChoamIndependentSkillsFixture = ChoamIndependentSkillsSetup & {
|
|
advanced: boolean;
|
|
route: ChoamIndependentSkillsRoute;
|
|
/** Real battle stronghold the concealed marker was shipped into. */
|
|
location: string;
|
|
opponent: string;
|
|
beforeShipment: Game;
|
|
shipmentStep: ChoamIndependentSkillsStep;
|
|
afterShipment: Game;
|
|
beforeBattle: Game;
|
|
battleStep: ChoamIndependentSkillsStep;
|
|
/** Live plan window: battle chosen, Mentat question and postures resolved. */
|
|
beforePlans: Game;
|
|
/** Revealed battle: both original plans sealed, postures committed. With
|
|
* stopBeforePlans this is instead the live pre-plan window awaiting a seal. */
|
|
game: Game;
|
|
/** Exact undialed complement of the commitment, for the caller's preview quote. */
|
|
returns: HarassWithdrawSelection;
|
|
token: { id: string; value: NoFieldValue };
|
|
/** Capped physical counters the concealed marker materializes on reveal. */
|
|
materializes: number;
|
|
tuning: number;
|
|
reservesBefore: Record<string, number>;
|
|
plans: {
|
|
both: ChoamIndependentSkillsStep;
|
|
reinforcements: ChoamIndependentSkillsStep;
|
|
harass: ChoamIndependentSkillsStep;
|
|
plain: ChoamIndependentSkillsStep;
|
|
opponent: ChoamIndependentSkillsStep;
|
|
};
|
|
hide: Record<string, boolean>;
|
|
/** Every real native action the programme took, in order. */
|
|
nativeSteps: ChoamIndependentSkillsStep[];
|
|
};
|
|
|
|
/** The one deterministic opening Spice Blow of the original initializer. */
|
|
export const CHOAM_INDEPENDENT_SKILLS_BLOW = { territory: 'broken_land', sector: 12 } as const;
|
|
/** Empty strongholds: cheapest to ship into and free of any starting army. */
|
|
export const CHOAM_INDEPENDENT_SKILLS_CITIES = [
|
|
{ territory: 'carthag', sector: 11 },
|
|
{ territory: 'sietch_tabr', sector: 14 },
|
|
{ territory: 'habbanya_ridge_sietch', sector: 17 },
|
|
] as const;
|
|
/** The ordinary deck of the selected E2 expansion plus the independent three. */
|
|
export const CHOAM_INDEPENDENT_SKILLS_EXPANSIONS = ['choam'] as const;
|
|
|
|
export function choamIndependentSkillsPlayer(game: Game, actor: string): Player {
|
|
const player = game.players.find((seat) => seat.id === actor);
|
|
assert.ok(player, `Missing original seat ${actor}`);
|
|
return player;
|
|
}
|
|
export function choamIndependentSkillsSeat(game: Game, faction: string): Player {
|
|
const player = game.players.find((seat) => seat.faction === faction);
|
|
assert.ok(player, `Missing native ${faction} seat`);
|
|
return player;
|
|
}
|
|
export const choamIndependentSkillsClean = (game: Game) =>
|
|
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
|
|
|
/** Conserved original cards, pools and the printed hand limit for every seat.
|
|
* The dealt card multiset is fixed from the original start onward; each seat's
|
|
* counter total is fixed from the completed setup onward. */
|
|
export function assertChoamIndependentSkillsCustody(
|
|
fixture: { offered: Game; afterSetup: Game },
|
|
game: Game,
|
|
): void {
|
|
assert.ok(game.leaderSkills);
|
|
validateLeaderSkills(game.leaderSkills, game.players);
|
|
const cards = (state: Game) => [...state.deck, ...state.discard, ...(state.richeseCache ?? []),
|
|
...(state.richeseRemoved ?? []), ...state.players.flatMap((player) => player.hand)]
|
|
.map((card) => card.id).sort();
|
|
assert.deepEqual(cards(game), cards(fixture.offered),
|
|
'Every original deck, cache, discard and hand face keeps physical custody.');
|
|
const pools = (state: Game) => state.players.map((player) => [player.id,
|
|
player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0),
|
|
player.elites
|
|
? player.elites.reserves + player.elites.tanks +
|
|
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0)
|
|
: 0]);
|
|
assert.deepEqual(pools(game), pools(fixture.afterSetup),
|
|
'Every physical counter stays inside its own native pool.');
|
|
for (const player of game.players)
|
|
assert.ok(player.hand.length <= nativeHandLimit(player),
|
|
`${player.faction} stays inside its printed hand limit.`);
|
|
}
|
|
/** Private two-card offers are dealt in seat order, so the first positions of
|
|
* the solved all-fourteen deck decide the requesting seat's real choice. */
|
|
function permutationDraws<T>(source: readonly T[], desired: readonly T[]): number[] {
|
|
const working = [...source], draws: number[] = [];
|
|
for (let index = working.length - 1; index > 0; index--) {
|
|
const from = working.indexOf(desired[index]);
|
|
assert.ok(from >= 0 && from <= index);
|
|
draws.push(Math.floor(((from + 0.5) / (index + 1)) * 4294967296));
|
|
[working[index], working[from]] = [working[from], working[index]];
|
|
}
|
|
assert.deepEqual(working, desired);
|
|
return draws;
|
|
}
|
|
/** Solve only the original fourteen-card skill lottery. Every later native
|
|
* shuffle keeps the same fixed maximum fill the tuned opening timeline uses. */
|
|
function skillDraws<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] = cursor < draws.length ? 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 from wherever it still lives. No face is
|
|
* ever minted, purchased or duplicated. */
|
|
function positionCard(game: Game, actor: string, cardId: string, staging: string[]): void {
|
|
const player = choamIndependentSkillsPlayer(game, actor);
|
|
if (player.hand.some((card) => card.id === cardId)) return;
|
|
const zones: { label: string; zone: Card[] }[] = [
|
|
{ label: 'remaining deck', zone: game.deck },
|
|
{ label: 'Richese cache', zone: game.richeseCache ?? [] },
|
|
{ label: 'discard pile', zone: game.discard },
|
|
];
|
|
for (const { label, zone } of zones) {
|
|
const index = zone.findIndex((card) => card.id === cardId);
|
|
if (index < 0) continue;
|
|
assert.ok(player.hand.length < nativeHandLimit(player),
|
|
`${actor}'s printed hand limit leaves room for ${cardId}`);
|
|
player.hand.push(zone.splice(index, 1)[0]);
|
|
staging.push(`Conserved ${cardId}: ${label} → ${actor}'s hand (no purchase, payment or replacement)`);
|
|
return;
|
|
}
|
|
assert.fail(`Conserved physical ${cardId} is missing for ${actor}`);
|
|
}
|
|
function availableLeaders(game: Game, actor: string, location: string) {
|
|
return choamIndependentSkillsPlayer(game, actor).leaders
|
|
.filter((leader) => !leader.dead && (!leader.usedAt || leader.usedAt === location))
|
|
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id));
|
|
}
|
|
|
|
/** Requested skill per native seat: only Richese's Mentat changes battle
|
|
* strength. The other two need a projectile weapon or snooper defense that the
|
|
* special slots hold, so they stay inert and need no extra card custody. */
|
|
function requestedSkills(richese: string, choam: string, classic: string): Record<string, LeaderSkillId> {
|
|
return {
|
|
[richese]: 'mentat',
|
|
[choam]: 'swordmaster-of-ginaz',
|
|
[classic]: 'killer-medic',
|
|
};
|
|
}
|
|
function skillDeckOrder(skills: Record<string, LeaderSkillId>, seats: readonly string[]): LeaderSkillId[] {
|
|
const requested = seats.map((seat) => skills[seat]);
|
|
const rest = LEADER_SKILL_CARDS.map((card) => card.id).filter((id) => !requested.includes(id));
|
|
return requested.flatMap((id) => [id, rest.shift()!]).concat(rest);
|
|
}
|
|
|
|
/** Genuine fresh CHOAM/Richese plus one classic seat: the original ordinary E2
|
|
* deck with Richese's separate ten-card cache, all fourteen Leader Skills and
|
|
* the independent three-card Treachery marker. The initial skill lottery and
|
|
* opening native shuffles are controlled; later actions keep real entropy. */
|
|
export function createChoamIndependentSkillsSetup(
|
|
options: ChoamIndependentSkillsOptions = {},
|
|
): ChoamIndependentSkillsSetup {
|
|
const advanced = options.initial ? options.initial.advanced : options.advanced ?? false;
|
|
const ids = options.seatIds ?? ['richese', 'choam', 'atreides'];
|
|
const factions = ['richese', 'choam', 'atreides'] as const;
|
|
let game: Game;
|
|
if (options.initial) {
|
|
game = structuredClone(options.initial);
|
|
assert.ok(game.status === 'lobby' ||
|
|
(game.status === 'setup' && !!game.leaderSkills && game.turn === 1 && game.phase === 0),
|
|
'Continue only the original fresh undealt independent-card setup.');
|
|
assert.equal(game.advanced, advanced);
|
|
assert.deepEqual([...game.expansions].sort(), [...CHOAM_INDEPENDENT_SKILLS_EXPANSIONS]);
|
|
assert.ok(game.players.some((player) => player.faction === 'richese'));
|
|
} else {
|
|
game = createGame('CHOAMINDEPENDENTSKILLS', newPlayer(ids[0], 'Richese', factions[0]), advanced,
|
|
[...CHOAM_INDEPENDENT_SKILLS_EXPANSIONS]);
|
|
joinGame(game, newPlayer(ids[1], 'CHOAM', factions[1]));
|
|
joinGame(game, newPlayer(ids[2], 'Atreides', factions[2]));
|
|
}
|
|
const richese = choamIndependentSkillsSeat(game, 'richese').id;
|
|
const choam = choamIndependentSkillsSeat(game, 'choam').id;
|
|
const classic = choamIndependentSkillsSeat(game, 'atreides').id;
|
|
const skills = requestedSkills(richese, choam, classic);
|
|
const staging: string[] = [];
|
|
const actions: ChoamIndependentSkillsStep[] = [];
|
|
const initial = structuredClone(game);
|
|
if (game.status === 'lobby') {
|
|
assert.ok(!game.lobbyOptions && !game.deck.length && !game.richeseCache &&
|
|
game.players.every((player) => !player.hand.length),
|
|
'Only the original fresh undealt lobby accepts the two module selections.');
|
|
for (const [type, enabled] of [
|
|
['techTokens', options.tech ?? false], ['strongholdCards', options.strongholds ?? false],
|
|
] as const) if (enabled) {
|
|
const action: Action = { type, enabled };
|
|
actions.push({ actor: game.host, action });
|
|
game = applyAction(game, game.host, action);
|
|
}
|
|
for (const option of ['leaderSkills', 'ecazTreachery'] as const) {
|
|
const action: Action = { type: 'moduleOption', module: option, enabled: true };
|
|
actions.push({ actor: game.host, action });
|
|
game = applyAction(game, game.host, action);
|
|
}
|
|
staging.push('The host selected the Leader Skills and independent Ecaz Treachery module options in the original lobby before any seat was ready.');
|
|
for (const player of game.players) if (!player.ready) {
|
|
const action: Action = { type: 'ready' };
|
|
actions.push({ actor: player.id, action });
|
|
game = applyAction(game, player.id, action);
|
|
}
|
|
const order = skillDeckOrder(skills, [richese, choam, classic]);
|
|
assert.equal(order.length, LEADER_SKILL_CARDS.length);
|
|
const action: Action = advanced ? { type: 'start', advancedPreview: true } : { type: 'start' };
|
|
actions.push({ actor: game.host, action });
|
|
game = skillDraws(permutationDraws(LEADER_SKILL_CARDS.map((card) => card.id), order),
|
|
() => applyAction(game, game.host, action));
|
|
staging.push('Solved the original fourteen-card skill lottery with real Fisher-Yates draws so each seat is offered its requested skill; the same start kept the tuned fixed-maximum fill for every later shuffle.');
|
|
}
|
|
assert.equal(game.status, 'setup');
|
|
assert.equal(game.ecazTreachery, true);
|
|
assert.equal(game.turn, 1);
|
|
assert.equal(game.phase, 0);
|
|
assert.ok(game.leaderSkills);
|
|
assert.equal(game.richeseCache?.length, 10, 'The separate ten-card Richese cache is dealt.');
|
|
const offered = structuredClone(game);
|
|
for (let count = 0; game.status === 'setup' && count < 400; count++) {
|
|
if (game.setupStage === 'leaderSkills') {
|
|
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
|
const offer = game.leaderSkills!.offers[actor];
|
|
const view = viewGame(game, actor).leaderSkills!;
|
|
const wanted = skills[actor];
|
|
assert.ok(offer.cards.includes(wanted) && !view.unavailableSkills?.[wanted],
|
|
`The requested original ${wanted} skill must be offered to ${actor}`);
|
|
const leader = choamIndependentSkillsPlayer(game, actor).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(leader);
|
|
const action: Action = { type: 'leaderSkill', event: offer.event, skill: wanted, leader: leader.id };
|
|
actions.push({ actor, action });
|
|
game = applyAction(game, actor, action);
|
|
continue;
|
|
}
|
|
let next: ChoamIndependentSkillsStep | undefined;
|
|
for (const player of game.players) {
|
|
const view = viewGame(game, player.id);
|
|
view.players.find((seat) => seat.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}`);
|
|
actions.push(structuredClone(next));
|
|
game = applyAction(game, next.actor, next.action);
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
// The undealt start is the reference for both the dealt card multiset and the
|
|
// untouched physical counter pools the setup redistributes.
|
|
assertChoamIndependentSkillsCustody({ offered, afterSetup: offered }, game);
|
|
const assigned = (seat: string) => game.leaderSkills!.assignments
|
|
.find((assignment) => assignment.owner === seat)!;
|
|
for (const seat of [richese, choam, classic])
|
|
assert.equal(assigned(seat).skill, skills[seat]);
|
|
return {
|
|
initial, offered, afterSetup: structuredClone(game), richese, choam, classic, skills,
|
|
trainers: { [richese]: assigned(richese).leader, [choam]: assigned(choam).leader,
|
|
[classic]: assigned(classic).leader },
|
|
actions, staging,
|
|
};
|
|
}
|
|
|
|
/** Only genuine native windows; a null result with a live battle means a human
|
|
* must seal a plan, and a typed withdrawal allocation is the caller's choice. */
|
|
export function nextChoamIndependentSkillsStep(
|
|
game: Game,
|
|
policy: ChoamIndependentSkillsPolicy = {},
|
|
): ChoamIndependentSkillsStep | null {
|
|
if (!game.response && !game.phaseOpening && game.decision) {
|
|
const decision = game.decision;
|
|
if (decision.kind === 'techToken')
|
|
return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
|
|
if (decision.kind === 'leaderSkillVisibility')
|
|
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event,
|
|
hide: policy.hide?.[decision.player] ?? false } };
|
|
if (decision.kind === 'mentatQuestion')
|
|
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
|
if (decision.kind === 'guildTiming')
|
|
return { actor: decision.player, action: { type: 'decision',
|
|
take: (policy.guildTiming ?? 'last') === 'first' } };
|
|
if (decision.kind === 'rihani')
|
|
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
|
|
// The typed withdrawal allocation and the Suk rescue choice stay the
|
|
// caller's; a winner's legal casualty pick continues through the shared
|
|
// native continuation policy.
|
|
if (decision.kind === 'harassWithdraw' || decision.kind === 'sukRescue') return null;
|
|
}
|
|
return nextRicheseReinforcementsStep(game, policy.stone ?? 'ignore');
|
|
}
|
|
|
|
/** Real native hidden-marker shipments, real ordinary movement and both
|
|
* original plans. Only conserved card faces are staged, and the trained leader
|
|
* is the physical disc the programme commits so its skill can resolve. */
|
|
export function createChoamIndependentSkillsFixture(
|
|
options: ChoamIndependentSkillsOptions = {},
|
|
): ChoamIndependentSkillsFixture {
|
|
const setup = createChoamIndependentSkillsSetup(options);
|
|
const advanced = setup.afterSetup.advanced;
|
|
const route: ChoamIndependentSkillsRoute = options.route ?? (advanced ? 'immediate' : 'tuning');
|
|
if (route === 'tuning')
|
|
assert.equal(advanced, false,
|
|
'The Advanced storm card makes every turn after the first nondeterministic; use the immediate route');
|
|
assert.ok(route !== 'immediate' || options.reserves === undefined || options.reserves === 20,
|
|
'The immediate native route starts with twenty real reserves; tuning is a separate timeline');
|
|
const staging = [...setup.staging];
|
|
let game = setup.afterSetup;
|
|
const { richese } = setup;
|
|
const marker: NoFieldValue = options.marker ?? 3;
|
|
const reuseValue: NoFieldValue = marker === 3 ? 0 : 3;
|
|
const baseline = marker === 3 ? 15 : 12;
|
|
const target = options.reserves ?? (marker === 3 ? 6 : 8);
|
|
const tuning = route === 'tuning' ? baseline - target : 0;
|
|
assert.ok(route === 'immediate' || (target >= 0 && target <= baseline),
|
|
'The tuning route can only leave these reserve budgets');
|
|
assert.ok(tuning <= 10, 'The tuning shipment must stay inside the spice the real route collected');
|
|
const nativeSteps: ChoamIndependentSkillsStep[] = [];
|
|
const act = (step: ChoamIndependentSkillsStep) => {
|
|
nativeSteps.push(structuredClone(step));
|
|
game = applyAction(game, step.actor, step.action);
|
|
};
|
|
const rich = () => choamIndependentSkillsPlayer(game, richese);
|
|
const tokenOf = (value: NoFieldValue) => rich().noField!.tokens.find((token) => token.value === value)!;
|
|
const stormFreeCity = () => {
|
|
const city = CHOAM_INDEPENDENT_SKILLS_CITIES.find((candidate) => candidate.sector !== game.storm);
|
|
assert.ok(city, 'The natural route keeps one empty stronghold outside the storm');
|
|
return city;
|
|
};
|
|
/** One untouched ordinary army holding a real stronghold outside the storm. */
|
|
const battleTarget = () => {
|
|
for (const player of game.players) {
|
|
if (player.id === richese) continue;
|
|
for (const [key, count] of Object.entries(player.forces)) {
|
|
if (count <= 0) continue;
|
|
const [stronghold, sector] = key.split(':');
|
|
if (!territory(stronghold) || territory(stronghold).type !== 'stronghold') continue;
|
|
if (stronghold === MOBILE_STRONGHOLD || Number(sector) === game.storm) continue;
|
|
return { seat: player.id, faction: player.faction, territory: stronghold, sector: Number(sector) };
|
|
}
|
|
}
|
|
assert.fail('The timeline needs one untouched starting army outside the storm');
|
|
};
|
|
const done = { shipment: {} as Record<number, boolean>, reveal: {} as Record<number, boolean> };
|
|
let open = false;
|
|
let beforeShipment: Game | undefined;
|
|
let afterShipment: Game | undefined;
|
|
let shipmentStep: ChoamIndependentSkillsStep | undefined;
|
|
let beforeBattle: Game | undefined;
|
|
let battleStep: ChoamIndependentSkillsStep | undefined;
|
|
let reservesBefore: Record<string, number> | undefined;
|
|
let army: { seat: string; faction: string; territory: string; sector: number } | undefined;
|
|
/** Both combatants hide their trained leader behind the shield. */
|
|
const hide: Record<string, boolean> = { [richese]: true };
|
|
const clear = () => choamIndependentSkillsClean(game);
|
|
// Native actions only: no counter is placed by hand and no wallet is set.
|
|
for (let count = 0; count < 2400 && !open; count++) {
|
|
assert.equal(game.status, 'playing');
|
|
if (done.shipment[4] && !afterShipment && clear()) afterShipment = structuredClone(game);
|
|
const choices = game.phase === 6 && clear()
|
|
? viewGame(game, richese).battleChoices ?? [] : [];
|
|
if (!open && choices.some((choice) => choice.territory === army?.territory)) {
|
|
const choice = choices.find((candidate) => candidate.territory === army!.territory)!;
|
|
positionCard(game, richese, REINFORCEMENTS_CARD, staging);
|
|
positionCard(game, richese, HARASS_WITHDRAW_CARD, staging);
|
|
beforeBattle = structuredClone(game);
|
|
battleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: army!.territory,
|
|
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
|
act(battleStep);
|
|
open = true;
|
|
break;
|
|
}
|
|
if (game.phase === 2 && game.turn === 4 && clear() &&
|
|
rich().charityTurn !== game.turn && rich().spice < 2) {
|
|
act({ actor: richese, action: { type: 'charity' } });
|
|
continue;
|
|
}
|
|
if (game.phase === 5 && game.active === richese && clear()) {
|
|
const turn = game.turn;
|
|
if (route === 'tuning' && turn === 1 && !done.shipment[1]) {
|
|
act({ actor: richese, action: { type: 'ship', noField: tokenOf(5).id,
|
|
event: rich().noFieldEvent, territory: CHOAM_INDEPENDENT_SKILLS_BLOW.territory,
|
|
sector: CHOAM_INDEPENDENT_SKILLS_BLOW.sector } });
|
|
done.shipment[1] = true;
|
|
continue;
|
|
}
|
|
if (route === 'tuning' && turn === 2 && rich().noField!.deployed && !done.reveal[2]) {
|
|
act({ actor: richese, action: { type: 'revealNoField', event: rich().noFieldEvent,
|
|
token: rich().noField!.deployed!.tokenId } });
|
|
done.reveal[2] = true;
|
|
continue;
|
|
}
|
|
if (route === 'tuning' && turn === 2 && !done.shipment[2] && !rich().shipped) {
|
|
const city = stormFreeCity();
|
|
act({ actor: richese, action: { type: 'ship', noField: tokenOf(reuseValue).id,
|
|
event: rich().noFieldEvent, territory: city.territory, sector: city.sector } });
|
|
done.shipment[2] = true;
|
|
continue;
|
|
}
|
|
if (route === 'tuning' && turn === 3 && rich().noField!.deployed && !done.reveal[3]) {
|
|
assert.equal(rich().noField!.deployed!.tokenId, tokenOf(reuseValue).id,
|
|
'The reuse token must still be the concealed one this timeline reveals');
|
|
act({ actor: richese, action: { type: 'revealNoField', event: rich().noFieldEvent,
|
|
token: rich().noField!.deployed!.tokenId } });
|
|
done.reveal[3] = true;
|
|
continue;
|
|
}
|
|
if (route === 'tuning' && turn === 3 && !done.shipment[3] && !rich().shipped) {
|
|
const city = stormFreeCity();
|
|
act({ actor: richese, action: { type: 'ship', territory: city.territory,
|
|
sector: city.sector, amount: tuning } });
|
|
done.shipment[3] = true;
|
|
continue;
|
|
}
|
|
const finalTurn = route === 'tuning' ? 4 : 1;
|
|
if (turn === finalTurn && !done.shipment[4] && !rich().shipped) {
|
|
army = battleTarget();
|
|
hide[army.seat] = true;
|
|
beforeShipment = structuredClone(game);
|
|
reservesBefore = Object.fromEntries(game.players.map((player) => [player.id, player.reserves]));
|
|
shipmentStep = { actor: richese, action: { type: 'ship', noField: tokenOf(marker).id,
|
|
event: rich().noFieldEvent, territory: army.territory, sector: army.sector } };
|
|
act(shipmentStep);
|
|
done.shipment[4] = true;
|
|
continue;
|
|
}
|
|
assert.ok(turn <= (route === 'tuning' ? 4 : 1),
|
|
'The natural timeline must reach its battle by the scheduled turn');
|
|
}
|
|
const step = nextChoamIndependentSkillsStep(game, { hide, stone: 'ignore' });
|
|
assert.ok(step, `Native Richese continuation stalled at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
|
if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0;
|
|
act(step);
|
|
}
|
|
assert.ok(open, 'The final concealed shipment must reach a real ordinary battle');
|
|
assert.ok(beforeShipment && afterShipment && shipmentStep && beforeBattle && battleStep && army && reservesBefore,
|
|
'The timeline must record its final shipment, army and battle admission');
|
|
for (let count = 0; count < 120; count++) {
|
|
const step = nextChoamIndependentSkillsStep(game, { hide, stone: 'ignore' });
|
|
if (!step) break;
|
|
act(step);
|
|
}
|
|
assert.ok(game.battle && !game.battle.revealed && clear());
|
|
assert.deepEqual(game.battle.plans, {});
|
|
const opponent = army.seat;
|
|
const location = `${army.territory}:${army.sector}`;
|
|
const enemyForces = choamIndependentSkillsPlayer(game, opponent).forces[location] ?? 0;
|
|
assert.ok(enemyForces > 0, 'The battle army must still hold the stronghold');
|
|
const reserves = reservesBefore[richese];
|
|
if (route === 'tuning')
|
|
assert.equal(reserves, target, 'The tuning route must leave the requested reserve budget');
|
|
const materializes = Math.min(tokenOf(marker).value, reserves);
|
|
const ownLeader = setup.trainers[richese];
|
|
assert.ok(availableLeaders(game, richese, army.territory).some((disc) => disc.id === ownLeader),
|
|
'The trained Mentat disc must still be an available battle leader');
|
|
const ownDial = advanced ? Math.min(1, Math.floor(materializes / 2)) : 2;
|
|
assert.ok(ownDial <= materializes, 'The plan may not dial counters the marker cannot materialize');
|
|
const undialed = materializes - ownDial;
|
|
assert.ok(undialed >= 1, 'Harass & Withdraw needs a real undialed counter to return');
|
|
// The announced dial is the battle number in both rules sets; the opposing
|
|
// army is tuned so the trained Mentat bonus is exactly decisive: the same
|
|
// physical plan loses by one without those two strength.
|
|
const defenderLeaders = availableLeaders(game, opponent, army.territory);
|
|
const ownStrength = choamIndependentSkillsPlayer(game, richese)
|
|
.leaders.find((disc) => disc.id === ownLeader)!.strength;
|
|
const attackerWith = ownDial + 2 + ownStrength + 2;
|
|
const boundary = defenderLeaders.flatMap((leader) => {
|
|
const dial = attackerWith - 1 - leader.strength;
|
|
return dial >= 0 && dial <= enemyForces ? [{ dial, leader: leader.id }] : [];
|
|
})[0];
|
|
assert.ok(boundary, 'The real opposing roster must hold the decisive skill boundary dial');
|
|
const otherLeader = boundary.leader;
|
|
const enemyDial = boundary.dial;
|
|
const plan = (weapon: string | null, defense: string | null): ChoamIndependentSkillsStep => ({
|
|
actor: richese,
|
|
action: { type: 'battlePlan', dial: ownDial, support: advanced ? ownDial : 0,
|
|
leader: ownLeader, weapon, defense },
|
|
});
|
|
const plans = {
|
|
both: plan(REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD),
|
|
reinforcements: plan(REINFORCEMENTS_CARD, null),
|
|
harass: plan(null, HARASS_WITHDRAW_CARD),
|
|
plain: plan(null, null),
|
|
opponent: { actor: opponent, action: { type: 'battlePlan', dial: enemyDial,
|
|
support: advanced ? enemyDial : 0, leader: otherLeader } },
|
|
} satisfies ChoamIndependentSkillsFixture['plans'];
|
|
const returns: HarassWithdrawSelection = { [location]: { normal: undialed, elite: 0 } };
|
|
const beforePlans = game;
|
|
if (options.stopBeforePlans)
|
|
return {
|
|
...setup, advanced, route, location, opponent, beforeShipment, shipmentStep, afterShipment,
|
|
beforeBattle, battleStep, beforePlans, game: beforePlans, returns,
|
|
token: structuredClone(tokenOf(marker)), materializes, tuning, reservesBefore, plans, hide, nativeSteps,
|
|
};
|
|
const before = applyAction(applyAction(game, richese, plans.both.action), opponent, plans.opponent.action);
|
|
assert.ok(before.battle?.revealed, 'Both original plans must reveal the marker and the machine');
|
|
assert.ok(!before.decision && !before.response && !before.phaseOpening,
|
|
`The whole undialed army is in one sector, so the native return needs no typed allocation (saw ${before.decision?.kind}).`);
|
|
return {
|
|
...setup, advanced, route, location, opponent, beforeShipment, shipmentStep, afterShipment,
|
|
beforeBattle, battleStep, beforePlans, game: before, returns, token: structuredClone(tokenOf(marker)),
|
|
materializes, tuning, reservesBefore, plans, hide, nativeSteps,
|
|
};
|
|
}
|
|
|
|
/** From the recorded pre-battle snapshot, choose the battle and consume only its
|
|
* native windows, stopping at the live window where a human seals a plan. */
|
|
export function openChoamIndependentSkillsBattle(
|
|
fixture: ChoamIndependentSkillsFixture, policy: ChoamIndependentSkillsPolicy = {},
|
|
): Game {
|
|
let state = applyAction(fixture.beforeBattle, fixture.battleStep.actor, fixture.battleStep.action);
|
|
for (let count = 0; count < 120; count++) {
|
|
const step = nextChoamIndependentSkillsStep(state, { ...policy, hide: policy.hide ?? fixture.hide });
|
|
if (!step) break;
|
|
state = applyAction(state, step.actor, step.action);
|
|
}
|
|
assert.ok(state.battle && !state.battle.revealed, 'The battle must open at its live plan window');
|
|
return state;
|
|
}
|
|
|
|
/** Seal the machine plan and the opposing plan from one real pre-plan window. */
|
|
export function sealChoamIndependentSkillsBoth(
|
|
fixture: ChoamIndependentSkillsFixture,
|
|
plan: 'both' | 'reinforcements' | 'harass' | 'plain' = 'both',
|
|
state: Game = fixture.beforePlans,
|
|
policy: ChoamIndependentSkillsPolicy = { hide: fixture.hide },
|
|
): Game {
|
|
const first = sealChoamIndependentSkillsPlan(state, fixture.plans[plan], policy);
|
|
return sealChoamIndependentSkillsPlan(first, fixture.plans.opponent, policy);
|
|
}
|
|
|
|
/** Seal one real plan and consume only the native windows it opens. */
|
|
export function sealChoamIndependentSkillsPlan(
|
|
game: Game, step: ChoamIndependentSkillsStep, policy: ChoamIndependentSkillsPolicy = {},
|
|
): Game {
|
|
let state = applyAction(game, step.actor, step.action);
|
|
for (let count = 0; count < 200; count++) {
|
|
if (!state.response && !state.phaseOpening && !state.decision) return state;
|
|
const next = nextChoamIndependentSkillsStep(state, policy);
|
|
assert.ok(next, `Sealed plan left an unconsumed native window at ${state.decision?.kind}`);
|
|
state = applyAction(state, next.actor, next.action);
|
|
}
|
|
throw Error('Sealed plan did not finish its native preparation');
|
|
}
|
|
|
|
/** Resolve the real aftermath: Traitor calls, an optional typed withdrawal
|
|
* allocation, the winner's legal casualty pick and mandatory disposal, stopping
|
|
* at the original clean Collection boundary. */
|
|
export function finishChoamIndependentSkillsBattle(
|
|
game: Game,
|
|
options: { callers?: readonly string[]; return?: HarassWithdrawSelection;
|
|
actions?: ChoamIndependentSkillsStep[]; until?: (state: Game) => boolean } = {},
|
|
): Game {
|
|
const until = options.until ?? ((state: Game) => state.phase === 7 && choamIndependentSkillsClean(state));
|
|
for (let count = 0; count < 400; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished');
|
|
let step: ChoamIndependentSkillsStep | null;
|
|
if (game.decision?.kind === 'harassWithdraw') {
|
|
assert.ok(options.return, 'Supply the genuine typed undialed return allocation');
|
|
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event,
|
|
returns: options.return } };
|
|
} else {
|
|
const next = nextChoamIndependentSkillsStep(game);
|
|
step = next && next.action.type === 'traitorCall'
|
|
? { ...next, action: { type: 'traitorCall', call: options.callers?.includes(next.actor) ?? false } }
|
|
: next;
|
|
}
|
|
assert.ok(step, 'Resolve both original plans before their physical aftermath');
|
|
options.actions?.push(structuredClone(step));
|
|
game = applyAction(game, step.actor, step.action);
|
|
}
|
|
throw Error('The original independent-card battle aftermath did not reach Collection');
|
|
}
|
|
|
|
/**
|
|
* Keep the original turn-one machine winner's two undialed forces in Arrakeen,
|
|
* earn its card at the first real Mentat, then ship a different native marker
|
|
* into that held city before the original Atreides seat pays to return there.
|
|
* No owner, army, wallet, marker or phase receipt is assigned.
|
|
*/
|
|
export function createChoamIndependentSkillsHeldSupportFixture(
|
|
options: Pick<ChoamIndependentSkillsOptions, 'tech' | 'strongholds'> = {},
|
|
) {
|
|
assert.equal(options.strongholds, true, 'The held-support programme needs original Stronghold Cards');
|
|
const first = createChoamIndependentSkillsFixture({
|
|
...options, advanced: true, route: 'immediate', stopBeforePlans: true,
|
|
});
|
|
assert.equal(first.location, 'arrakeen:10');
|
|
const actions: ChoamIndependentSkillsStep[] = [];
|
|
const firstAfterBattle = finishChoamIndependentSkillsBattle(
|
|
sealChoamIndependentSkillsBoth(first, 'reinforcements'), { actions });
|
|
let game = firstAfterBattle;
|
|
const act = (step: ChoamIndependentSkillsStep) => {
|
|
actions.push(structuredClone(step));
|
|
game = applyAction(game, step.actor, step.action);
|
|
};
|
|
const advance = (until: (state: Game) => boolean) => {
|
|
for (let count = 0; count < 1600; count++) {
|
|
if (until(game)) return;
|
|
const step = nextChoamIndependentSkillsStep(game, { hide: first.hide });
|
|
assert.ok(step, 'A real native continuation must reach the next held-city action');
|
|
act(step);
|
|
}
|
|
throw Error('Held Arrakeen programme did not reach its next native action');
|
|
};
|
|
advance(state => state.turn === 2 && state.phase === 0 && choamIndependentSkillsClean(state));
|
|
const afterFirstMentat = structuredClone(game);
|
|
let beforeMarkerShipment: Game | undefined, afterMarkerShipment: Game | undefined;
|
|
let beforeEnemyShipment: Game | undefined, markerShipment: ChoamIndependentSkillsStep | undefined;
|
|
const enemyShipment: ChoamIndependentSkillsStep = { actor: first.opponent, action: {
|
|
type: 'ship', territory: 'arrakeen', sector: 10, amount: 4,
|
|
} };
|
|
const waiting = new Set([first.richese, first.opponent]);
|
|
while (waiting.size) {
|
|
advance(state => state.turn === 2 && state.phase === 5 && !!state.active &&
|
|
waiting.has(state.active) && choamIndependentSkillsClean(state));
|
|
const actor = game.active!;
|
|
if (actor === first.richese) {
|
|
beforeMarkerShipment = structuredClone(game);
|
|
const richese = choamIndependentSkillsPlayer(game, actor);
|
|
const marker = richese.noField!.tokens.find(token => token.value === 5)!;
|
|
markerShipment = { actor, action: { type: 'ship', noField: marker.id,
|
|
event: richese.noFieldEvent, territory: 'arrakeen', sector: 10 } };
|
|
act(markerShipment);
|
|
} else {
|
|
beforeEnemyShipment = structuredClone(game);
|
|
act(enemyShipment);
|
|
}
|
|
advance(state => state.active === actor && choamIndependentSkillsClean(state) && !state.pendingShipment);
|
|
if (actor === first.richese) afterMarkerShipment = structuredClone(game);
|
|
act({ actor, action: { type: 'endMovement' } });
|
|
waiting.delete(actor);
|
|
}
|
|
assert.ok(beforeMarkerShipment && afterMarkerShipment && beforeEnemyShipment && markerShipment);
|
|
advance(state => state.turn === 2 && state.phase === 6 && choamIndependentSkillsClean(state));
|
|
const staging = [...first.staging];
|
|
positionCard(game, first.richese, REINFORCEMENTS_CARD, staging);
|
|
const beforeBattle = structuredClone(game);
|
|
const choice = viewGame(game, first.richese).battleChoices.find(candidate => candidate.territory === 'arrakeen');
|
|
assert.ok(choice);
|
|
const battleStep: ChoamIndependentSkillsStep = { actor: choice.chooser, action: {
|
|
type: 'chooseBattle', territory: 'arrakeen',
|
|
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
|
|
} };
|
|
act(battleStep);
|
|
advance(state => !!state.battle && !state.battle.revealed && choamIndependentSkillsClean(state) &&
|
|
!!state.battle.preLeader?.closed && !state.battle.preparation);
|
|
const beforePlans = structuredClone(game);
|
|
const plans: ChoamIndependentSkillsStep[] = [
|
|
{ actor: first.richese, action: { type: 'battlePlan', dial: 2, support: 2,
|
|
leader: first.trainers[first.richese], weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } },
|
|
{ actor: first.opponent, action: { type: 'battlePlan', dial: 0, support: 0,
|
|
leader: availableLeaders(game, first.opponent, 'arrakeen')[0].id } },
|
|
];
|
|
for (const plan of plans) {
|
|
actions.push(structuredClone(plan));
|
|
game = sealChoamIndependentSkillsPlan(game, plan, { hide: first.hide });
|
|
}
|
|
const revealed = structuredClone(game);
|
|
const returns: HarassWithdrawSelection = { [first.location]: { normal: 5, elite: 0 } };
|
|
const afterBattle = finishChoamIndependentSkillsBattle(game, { return: returns, actions });
|
|
return { first, firstAfterBattle, afterFirstMentat, beforeMarkerShipment, markerShipment,
|
|
afterMarkerShipment, beforeEnemyShipment, enemyShipment, beforeBattle, battleStep, beforePlans,
|
|
plans, revealed, returns, afterBattle, actions, staging };
|
|
}
|