Compose native Ix and CHOAM Skills with independent Treachery Cards
This commit is contained in:
1 parent
7182502b16
commit
2ad947af54
17 files changed
+1376
-22
No files matched your search
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { forceRevivalLimit, forceRevivalQuote } from '../game/revival';
|
||||
import type { BattleResolutionQuote } from '../game/battle-resolution-quote';
|
||||
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
|
||||
import {
|
||||
assertChoamIndependentSkillsCustody, choamIndependentSkillsPlayer,
|
||||
createChoamIndependentSkillsFixture,
|
||||
finishChoamIndependentSkillsBattle, openChoamIndependentSkillsBattle,
|
||||
sealChoamIndependentSkillsBoth, type ChoamIndependentSkillsFixture,
|
||||
} from './fixture-choam-independent-skills';
|
||||
|
||||
const owner = (fixture: ChoamIndependentSkillsFixture, game: Game) =>
|
||||
choamIndependentSkillsPlayer(game, fixture.richese);
|
||||
const rival = (fixture: ChoamIndependentSkillsFixture, game: Game) =>
|
||||
choamIndependentSkillsPlayer(game, fixture.opponent);
|
||||
/** The quote side belongs to whichever combatant the order-based label names. */
|
||||
const side = (game: Game, actor: string) =>
|
||||
game.battle!.attacker === actor ? 'attacker' as const : 'defender' as const;
|
||||
const bonusOf = (game: Game, quote: BattleResolutionQuote, actor: string) =>
|
||||
quote.leaderSkillBonuses[side(game, actor)];
|
||||
const scoreOf = (game: Game, quote: BattleResolutionQuote, actor: string) => {
|
||||
assert.ok(quote.scores, 'A resolved battle carries totals');
|
||||
return quote.scores[side(game, actor)];
|
||||
};
|
||||
const held = (game: Game, id: string) =>
|
||||
[...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand)]
|
||||
.filter((card) => card.id === id);
|
||||
|
||||
for (const advanced of [false, true] as const)
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} CHOAM/Richese independent all14 funds Reinforcements after the concealed marker materializes and returns the real undialed army`, () => {
|
||||
const fixture = createChoamIndependentSkillsFixture(advanced ? { advanced: true } : {});
|
||||
// The independent three-card marker rides the selected E2 ordinary deck, and
|
||||
// Richese keeps its own separate ten-card cache alongside all fourteen skills.
|
||||
assert.equal(fixture.offered.richeseCache!.length, 10);
|
||||
// Live plan window: the preview already forecasts the capped materialization
|
||||
// and the three reserves the fee must find afterwards.
|
||||
const open = openChoamIndependentSkillsBattle(fixture);
|
||||
assert.deepEqual(open.battle!.plans, {});
|
||||
assert.equal(owner(fixture, open).reserves, advanced ? 20 : 6);
|
||||
const preview = viewGame(open, fixture.richese).battle!.reinforcements!;
|
||||
assert.equal(preview.blocked, null);
|
||||
assert.equal(preview.noField!.materializes, 3);
|
||||
assert.equal(preview.noField!.reservesAfterReveal, advanced ? 17 : 3);
|
||||
assert.equal(preview.normal, 3);
|
||||
const withdraw = viewGame(open, fixture.richese).battle!.harassWithdraw!;
|
||||
assert.equal(withdraw.blocked, null);
|
||||
assert.equal(withdraw.forces.normal, 3);
|
||||
assert.equal(withdraw.forces.elite, 0);
|
||||
assert.deepEqual(withdraw.locations, { [fixture.location]: { normal: 3, elite: 0 } });
|
||||
// The reveal materializes the capped physical counters; the fee follows them.
|
||||
const revealed = sealChoamIndependentSkillsBoth(fixture, 'both', open);
|
||||
assert.equal(revealed.battle!.revealed, true);
|
||||
const shown = owner(fixture, revealed);
|
||||
assert.equal(shown.forces[fixture.location], 3);
|
||||
assert.equal(shown.reserves, advanced ? 17 : 3);
|
||||
assert.equal(shown.tanks, 0);
|
||||
assert.equal(shown.noField!.deployed, null);
|
||||
assert.equal(shown.noField!.lastShipped, fixture.token.id);
|
||||
assert.equal(new Set(shown.noField!.tokens.map((token) => token.id)).size, 3);
|
||||
// The revealed totals: dial + Reinforcements + trained Mentat + physical leader.
|
||||
const quote = quoteIndependentSkillsBattle(revealed, { returns: fixture.returns });
|
||||
assert.deepEqual(quote.reinforcements,
|
||||
[{ player: fixture.richese, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
||||
assert.deepEqual(quote.harassWithdraw?.map((entry) => ({ returned: entry.returned, locations: entry.locations })),
|
||||
[{ returned: { normal: advanced ? 2 : 1, elite: 0 },
|
||||
locations: { [fixture.location]: { normal: advanced ? 2 : 1, elite: 0 } } }]);
|
||||
assert.deepEqual(bonusOf(revealed, quote, fixture.richese),
|
||||
{ bonus: 2, applied: [{ skill: 'mentat', amount: 2, mode: 'skilled' }] });
|
||||
assert.deepEqual(bonusOf(revealed, quote, fixture.opponent), { bonus: 0, applied: [] });
|
||||
assert.equal(scoreOf(revealed, quote, fixture.richese) - scoreOf(revealed, quote, fixture.opponent), 1);
|
||||
// The whole undialed army lies in the marker's own sector, so the native
|
||||
// return is unambiguous and needs no typed allocation.
|
||||
const spice = owner(fixture, revealed).spice;
|
||||
const done = finishChoamIndependentSkillsBattle(revealed, { return: fixture.returns });
|
||||
assert.equal(done.lastBattleContext?.winner, fixture.richese);
|
||||
assert.equal(done.lastBattleContext?.result, 'normal');
|
||||
const after = owner(fixture, done);
|
||||
assert.equal(after.reserves, advanced ? 16 : 1,
|
||||
'Revealed reserves, minus the three-reserve fee, plus the returned undialed counter');
|
||||
assert.equal(after.tanks, advanced ? 4 : 5);
|
||||
assert.deepEqual(after.forces, advanced ? {} : { 'broken_land:12': 5, 'carthag:11': 9 });
|
||||
assert.equal(after.spice, spice - Number(fixture.plans.both.action.support ?? 0));
|
||||
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) {
|
||||
assert.equal(held(done, card).length, 1, card);
|
||||
assert.equal(done.discard.filter((entry) => entry.id === card).length, 1, card);
|
||||
assert.ok(done.players.every((player) => !player.hand.some((face) => face.id === card)), card);
|
||||
}
|
||||
assertChoamIndependentSkillsCustody(fixture, done);
|
||||
// The same physical dial without Reinforcements loses: its +2 score,
|
||||
// rather than another force or support payment, supplies the winning margin.
|
||||
const plain = finishChoamIndependentSkillsBattle(
|
||||
sealChoamIndependentSkillsBoth(fixture, 'plain', open), { return: fixture.returns });
|
||||
assert.equal(plain.lastBattleContext?.winner, fixture.opponent);
|
||||
assert.equal(owner(fixture, plain).reserves, advanced ? 17 : 3);
|
||||
assert.equal(owner(fixture, plain).tanks, 3);
|
||||
assert.deepEqual(owner(fixture, plain).forces, advanced ? {} : { 'broken_land:12': 5, 'carthag:11': 9 });
|
||||
assertChoamIndependentSkillsCustody(fixture, plain);
|
||||
});
|
||||
|
||||
void test('A reserve-capped marker five at three reserves refuses the funded plan before the reveal and still plays the real Harass fallback', () => {
|
||||
const fixture = createChoamIndependentSkillsFixture({ marker: 5, reserves: 3, stopBeforePlans: true });
|
||||
const preview = viewGame(fixture.beforePlans, fixture.richese).battle!.reinforcements!;
|
||||
assert.equal(preview.noField!.materializes, 3, 'The reveal is capped by the physical reserves');
|
||||
assert.equal(preview.noField!.reservesAfterReveal, 0);
|
||||
assert.equal(preview.normal + preview.elite, 0);
|
||||
const saved = structuredClone(fixture.beforePlans);
|
||||
for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) {
|
||||
assert.throws(() => applyAction(fixture.beforePlans, fixture.richese, plan.action));
|
||||
assert.deepEqual(fixture.beforePlans, saved, 'A refused plan reserves no counter, card or window');
|
||||
}
|
||||
const revealed = sealChoamIndependentSkillsBoth(fixture, 'harass');
|
||||
assert.equal(revealed.battle!.revealed, true);
|
||||
const shown = owner(fixture, revealed);
|
||||
assert.equal(shown.forces[fixture.location], 3, 'The capped marker still materializes its three counters');
|
||||
assert.equal(shown.reserves, 0);
|
||||
assert.equal(shown.tanks, 0);
|
||||
const done = finishChoamIndependentSkillsBattle(revealed, { return: fixture.returns });
|
||||
assert.equal(done.lastBattleContext?.winner, fixture.opponent);
|
||||
const after = owner(fixture, done);
|
||||
assert.equal(after.reserves, 1, 'Only the undialed counter returned; no fee was charged');
|
||||
assert.equal(after.tanks, 2, 'The two dialed counters reached the Tanks');
|
||||
assert.deepEqual(after.forces, { 'broken_land:12': 5, 'carthag:11': 12 });
|
||||
assertChoamIndependentSkillsCustody(fixture, done);
|
||||
});
|
||||
|
||||
void test('The independent CHOAM/Richese setup keeps the separate cache and each seat printed revival exception', () => {
|
||||
const fixture = createChoamIndependentSkillsFixture({});
|
||||
assert.equal(fixture.offered.richeseCache!.length, 10);
|
||||
assert.ok(fixture.offered.richeseCache!.every((card) => card.id.startsWith('richese-')),
|
||||
'The cache holds only Richese faces, never ordinary deck cards');
|
||||
for (const card of fixture.offered.richeseCache!)
|
||||
assert.ok(!fixture.offered.deck.some((face) => face.id === card.id),
|
||||
`${card.id} stays out of the ordinary deck`);
|
||||
const choam = choamIndependentSkillsPlayer(fixture.afterSetup, fixture.choam);
|
||||
assert.equal(forceRevivalLimit(fixture.afterSetup, choam), 20);
|
||||
assert.deepEqual(forceRevivalQuote(fixture.afterSetup, choam, 5),
|
||||
{ free: 0, normalCost: 10, cost: 5 },
|
||||
'CHOAM pays one spice per returned force with no free revival and no normal limit');
|
||||
for (const actor of [fixture.richese, fixture.classic]) {
|
||||
const seat = choamIndependentSkillsPlayer(fixture.afterSetup, actor);
|
||||
assert.equal(forceRevivalLimit(fixture.afterSetup, seat), 3);
|
||||
assert.deepEqual(forceRevivalQuote(fixture.afterSetup, seat, 5),
|
||||
{ free: 2, normalCost: 6, cost: 6 });
|
||||
}
|
||||
const done = finishChoamIndependentSkillsBattle(
|
||||
sealChoamIndependentSkillsBoth(fixture, 'both'), { return: fixture.returns });
|
||||
assertChoamIndependentSkillsCustody(fixture, done);
|
||||
// Every seat keeps its own counters and the printed hand limit end to end.
|
||||
for (const player of done.players) {
|
||||
const pool = player.reserves + player.tanks +
|
||||
Object.values(player.forces).reduce((sum, count) => sum + count, 0);
|
||||
assert.equal(pool, 20, player.faction);
|
||||
}
|
||||
assert.equal(rival(fixture, done).tanks, rival(fixture, fixture.beforePlans).tanks +
|
||||
(rival(fixture, fixture.beforePlans).forces[fixture.location] ?? 0),
|
||||
'The defeated garrison, not armies elsewhere, reached the Tanks');
|
||||
});
|
||||
@@ -0,0 +1,631 @@
|
||||
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;
|
||||
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 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 === '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');
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { treacheryDeck, type Card } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
|
||||
|
||||
export type IxIndependentSkillsStep = { actor: string; action: Action };
|
||||
export type IxIndependentSkillsOptions = { advanced?: boolean };
|
||||
export const ixIndependentSkillsPlayer = (game: Game, actor: string): Player => {
|
||||
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const ixIndependentSkillsClean = (game: Game) =>
|
||||
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Scoped real shuffle entropy, restored even when a native action rejects. */
|
||||
function entropy<T>(run: () => T, rolls: readonly number[] = []): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array) {
|
||||
for (let i = 0; i < array.length; i++) array[i] = rolls[cursor++] ?? 0xffffffff;
|
||||
} else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function skillRolls(): number[] {
|
||||
const source = LEADER_SKILL_CARDS.map(c => c.id);
|
||||
const chosen: LeaderSkillId[] = ['suk-graduate', 'killer-medic', 'master-of-assassins'];
|
||||
const rest = source.filter(c => !chosen.includes(c));
|
||||
const desired = chosen.flatMap(c => [c, rest.shift()!]).concat(rest);
|
||||
const work = [...source], rolls: number[] = [];
|
||||
for (let i = work.length - 1; i > 0; i--) {
|
||||
const from = work.indexOf(desired[i]); assert.ok(from >= 0 && from <= i);
|
||||
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1)));
|
||||
[work[i], work[from]] = [work[from], work[i]];
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
export function stepIxIndependentSkills(game: Game, step: IxIndependentSkillsStep): Game {
|
||||
return entropy(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
/** Original private projection and real policy; never alter the human lobby seat. */
|
||||
export function ixIndependentSkillsPolicyActions(game: Game, actor: string, difficulty: Difficulty): Action[] {
|
||||
const view = viewGame(game, actor);
|
||||
view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
return entropy(() => botActions(view));
|
||||
}
|
||||
/** Stop at allocations and rescue: these remain actual projected human controls. */
|
||||
export function nextIxIndependentSkillsStep(game: Game): IxIndependentSkillsStep | null {
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player,
|
||||
action: { type: 'leaderSkillVisibility', event: d.event, hide: d.player === 'ixians' } };
|
||||
if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } };
|
||||
if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
|
||||
if (d?.kind === 'mentatQuestion') return { actor: d.player,
|
||||
action: { type: 'decision', event: d.event, decline: true } };
|
||||
if (d?.kind === 'rihani') return { actor: d.player,
|
||||
action: { type: 'decision', event: d.event, draw: false } };
|
||||
if (d?.kind === 'sukRescue' || d?.kind === 'harassWithdraw') return null;
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceIxIndependentSkills(state: Game, until: (game: Game) => boolean,
|
||||
actions: IxIndependentSkillsStep[] = []): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let i = 0; i < 1600; i++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextIxIndependentSkillsStep(game); assert.ok(step, 'Supply the original private plan, typed return or rescue');
|
||||
actions.push(structuredClone(step)); game = stepIxIndependentSkills(game, step);
|
||||
}
|
||||
throw Error('Native independent programme did not reach its requested boundary');
|
||||
}
|
||||
/** Only physical card custody is staged; no stock, forces, wallets or receipts. */
|
||||
function conserveCard(game: Game, actor: string, id: string, staging: string[]): Card {
|
||||
const p = ixIndependentSkillsPlayer(game, actor), held = p.hand.find(c => c.id === id);
|
||||
if (held) return held;
|
||||
const zones = [{ label: 'remaining original deck', cards: game.deck },
|
||||
{ label: 'original discard', cards: game.discard },
|
||||
...game.players.filter(other => other.id !== actor).map(other => ({ label: other.id, cards: other.hand }))];
|
||||
const source = zones.find(zone => zone.cards.some(c => c.id === id)); assert.ok(source);
|
||||
if (p.hand.length >= handLimit(p)) {
|
||||
const returned = p.hand.shift()!; game.deck.push(returned);
|
||||
staging.push(`Conserved ${returned.id}: ${actor} hand → original remaining deck for printed hand capacity`);
|
||||
}
|
||||
const card = source.cards.splice(source.cards.findIndex(c => c.id === id), 1)[0];
|
||||
p.hand.push(card); staging.push(`Conserved ${id}: ${source.label} → ${actor} hand; not a purchase or native deal`);
|
||||
return card;
|
||||
}
|
||||
export function assertIxIndependentSkillsCustody(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(),
|
||||
[...treacheryDeck(['ix']), ...ecazTreacheryCards()].map(c => c.id).sort());
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
|
||||
assert.ok([p.reserves, p.tanks, ...Object.values(p.forces)].every(n => Number.isSafeInteger(n) && n >= 0));
|
||||
assert.ok(p.hand.length <= handLimit(p));
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), 7);
|
||||
assert.ok([p.elites.reserves, p.elites.tanks, ...Object.values(p.elites.forces)]
|
||||
.every(n => Number.isSafeInteger(n) && n >= 0));
|
||||
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
|
||||
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
if (game.status === 'playing') assert.deepEqual([
|
||||
...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors),
|
||||
...game.players.flatMap(p => (p.faceDancers ?? []).map(c => c.leader)),
|
||||
].sort(), traitorDeck(game.players, true).sort());
|
||||
}
|
||||
export type IxIndependentSkillsFixture = {
|
||||
initial: Game; beforeIxCard: Game; ixCard: IxIndependentSkillsStep; offered: Game; afterSetup: Game;
|
||||
beforeShipment: Game; shipment: IxIndependentSkillsStep; afterShipment: Game;
|
||||
move: IxIndependentSkillsStep; afterMove: Game; opponentMove: IxIndependentSkillsStep;
|
||||
beforeBattle: Game; battleStep: IxIndependentSkillsStep;
|
||||
/** Original before-plan window, not a synthetic revealed Battle. */
|
||||
game: Game; plans: IxIndependentSkillsStep[]; returns: HarassWithdrawSelection;
|
||||
ixians: string; tleilaxu: string; opponent: string; trainer: string;
|
||||
first: string; second: string; actions: IxIndependentSkillsStep[]; staging: string[];
|
||||
};
|
||||
|
||||
/** Normal fresh lobby start → original Ix card-before-training → native armies →
|
||||
* real desert shipment and split movement. Only the two singleton cards move zones. */
|
||||
export function createIxIndependentSkillsFixture(options: IxIndependentSkillsOptions = {}): IxIndependentSkillsFixture {
|
||||
const advanced = options.advanced ?? false;
|
||||
let game = createGame('IXINDEPENDENTSKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
|
||||
joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
const initial = structuredClone(game), actions: IxIndependentSkillsStep[] = [], staging: string[] = [];
|
||||
const record = (step: IxIndependentSkillsStep): Game => {
|
||||
actions.push(structuredClone(step)); game = stepIxIndependentSkills(game, step);
|
||||
return game;
|
||||
};
|
||||
for (const option of ['leaderSkills', 'ecazTreachery'] as const)
|
||||
record({ actor: game.host, action: { type: 'moduleOption', module: option, enabled: true } });
|
||||
for (const p of game.players) record({ actor: p.id, action: { type: 'ready' } });
|
||||
const start: IxIndependentSkillsStep = { actor: game.host,
|
||||
action: { type: 'start', ...(advanced ? { advancedPreview: true } : {}) } };
|
||||
actions.push(start); game = entropy(() => applyAction(game, start.actor, start.action), skillRolls());
|
||||
staging.push('Scoped/restored original all14 lottery and canonical native setup shuffle; no offer or deck replacement');
|
||||
assert.equal(game.setupStage, 'skillTreachery'); assert.equal(game.decision?.kind, 'ixSetup');
|
||||
const beforeIxCard = structuredClone(game);
|
||||
const ixCard: IxIndependentSkillsStep = { actor: 'ixians', action: { type: 'decision', card: game.ixSetupCards![0].id } };
|
||||
game = record(ixCard); const offered = structuredClone(game);
|
||||
const chosen: Record<string, LeaderSkillId> = { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'master-of-assassins' };
|
||||
for (let i = 0; game.status === 'setup' && i < 120; i++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
|
||||
assert.ok(offer.cards.includes(chosen[actor]));
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const leader = ixIndependentSkillsPlayer(game, actor).leaders
|
||||
.filter(l => view.eligibleLeaders.some(e => e.id === l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(leader); record({ actor, action: { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id } });
|
||||
} else { const step = nextIxIndependentSkillsStep(game); assert.ok(step); record(step); }
|
||||
}
|
||||
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === 'ixians')!.leader;
|
||||
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g), actions);
|
||||
const beforeShipment = structuredClone(game), first = 'pasty_mesa:5', second = 'pasty_mesa:6';
|
||||
const shipment: IxIndependentSkillsStep = { actor: 'ixians', action: {
|
||||
type: 'ship', territory: 'pasty_mesa', sector: 5, amount: 4, elite: 2, allyPayment: 0 } };
|
||||
record(shipment);
|
||||
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g) && !g.pendingShipment, actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
const move: IxIndependentSkillsStep = { actor: 'ixians', action: {
|
||||
type: 'move', from: first, amount: 2, elite: 1, territory: 'pasty_mesa', sector: 6 } };
|
||||
record(move);
|
||||
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g), actions);
|
||||
const afterMove = structuredClone(game); record({ actor: 'ixians', action: { type: 'endMovement' } });
|
||||
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'guild' && ixIndependentSkillsClean(g), actions);
|
||||
const opponentMove: IxIndependentSkillsStep = { actor: 'guild', action: {
|
||||
type: 'move', from: 'tueks_sietch:5', amount: 5, territory: 'pasty_mesa', sector: 6 } };
|
||||
record(opponentMove);
|
||||
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'guild' && ixIndependentSkillsClean(g), actions);
|
||||
record({ actor: 'guild', action: { type: 'endMovement' } });
|
||||
game = advanceIxIndependentSkills(game, g => g.phase === 6 && ixIndependentSkillsClean(g), actions);
|
||||
conserveCard(game, 'ixians', REINFORCEMENTS_CARD, staging);
|
||||
conserveCard(game, 'ixians', HARASS_WITHDRAW_CARD, staging);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === 'pasty_mesa'); assert.ok(choice);
|
||||
const battleStep: IxIndependentSkillsStep = { actor: choice.chooser,
|
||||
action: { type: 'chooseBattle', territory: 'pasty_mesa', target: choice.defender } };
|
||||
record(battleStep);
|
||||
game = advanceIxIndependentSkills(game, g => !!g.battle && !g.battle.revealed && ixIndependentSkillsClean(g), actions);
|
||||
const enemy = ixIndependentSkillsPlayer(game, 'guild').leaders
|
||||
.filter(l => !l.dead && l.id !== game.leaderSkills!.assignments.find(a => a.owner === 'guild')!.leader)
|
||||
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy);
|
||||
const plans: IxIndependentSkillsStep[] = [
|
||||
{ actor: 'ixians', action: { type: 'battlePlan', dial: advanced ? 1.5 : 2.5, support: 0,
|
||||
leader: trainer, weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } },
|
||||
{ actor: 'guild', action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } },
|
||||
];
|
||||
const returns: HarassWithdrawSelection = { [first]: { normal: 1, elite: 0 }, [second]: { normal: 0, elite: 1 } };
|
||||
assertIxIndependentSkillsCustody(game);
|
||||
return { initial, beforeIxCard, ixCard, offered, afterSetup, beforeShipment, shipment, afterShipment,
|
||||
move, afterMove, opponentMove, beforeBattle, battleStep, game, plans, returns,
|
||||
ixians: 'ixians', tleilaxu: 'tleilaxu', opponent: 'guild', trainer, first, second, actions, staging };
|
||||
}
|
||||
export function revealIxIndependentSkillsBattle(f: IxIndependentSkillsFixture, state = f.game): Game {
|
||||
let game = structuredClone(state);
|
||||
for (const step of f.plans) game = stepIxIndependentSkills(game, step);
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function quoteIxIndependentSkillsBattle(f: IxIndependentSkillsFixture, state = revealIxIndependentSkillsBattle(f)) {
|
||||
return quoteIndependentSkillsBattle(state, { returns: f.returns });
|
||||
}
|
||||
export function finishIxIndependentSkillsBattle(f: IxIndependentSkillsFixture,
|
||||
options: { state?: Game; rescue?: 'save' | 'none'; stop?: 'rescue' | 'collection' } = {}): Game {
|
||||
let game = structuredClone(options.state ?? revealIxIndependentSkillsBattle(f));
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game;
|
||||
if (game.phase === 7 && ixIndependentSkillsClean(game)) return game;
|
||||
let step: IxIndependentSkillsStep | null;
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'harassWithdraw') step = { actor: d.player,
|
||||
action: { type: 'decision', event: d.event, returns: f.returns } };
|
||||
else if (d?.kind === 'sukRescue') {
|
||||
const choice = options.rescue === 'none' ? d.options.findIndex(o => o.normal + o.elite === 0)
|
||||
: d.options.findIndex(o => o.normal === 1 && o.elite === 1 && o.kept?.kind === 'elite');
|
||||
assert.ok(choice >= 0, 'Choose the original Cyborg/Suboid rescue');
|
||||
step = { actor: d.player, action: { type: 'decision', event: d.event, choice } };
|
||||
} else step = nextIxIndependentSkillsStep(game);
|
||||
assert.ok(step); game = stepIxIndependentSkills(game, step);
|
||||
}
|
||||
throw Error('Native independent battle did not reach Collection');
|
||||
}
|
||||
|
||||
/** Real combat Tanks → real escrow bribe and Mentat → turn-two Revival. */
|
||||
export function createIxIndependentSkillsRevivalFixture(options: IxIndependentSkillsOptions = {}) {
|
||||
const f = createIxIndependentSkillsFixture(options);
|
||||
const afterBattle = finishIxIndependentSkillsBattle(f, { rescue: 'none' });
|
||||
const bribe: IxIndependentSkillsStep = { actor: f.opponent, action: { type: 'bribe', target: f.ixians, amount: 3 } };
|
||||
let game = stepIxIndependentSkills(afterBattle, bribe);
|
||||
game = advanceIxIndependentSkills(game, g => g.turn === 2 && g.phase === 4 && ixIndependentSkillsClean(g) && !g.pendingRevival);
|
||||
const staging = [...f.staging]; conserveCard(game, f.ixians, RECRUITS_CARD_ID, staging);
|
||||
const beforeRecruits = structuredClone(game);
|
||||
const priorReturn: IxIndependentSkillsStep = { actor: f.ixians, action: { type: 'revive', amount: 1, elite: 0 } };
|
||||
game = stepIxIndependentSkills(game, priorReturn);
|
||||
game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival);
|
||||
const beforePlay = structuredClone(game);
|
||||
const play: IxIndependentSkillsStep = { actor: f.ixians, action: { type: 'card', card: RECRUITS_CARD_ID } };
|
||||
game = stepIxIndependentSkills(game, play);
|
||||
game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival);
|
||||
const beforeRevival = structuredClone(game);
|
||||
const revive: IxIndependentSkillsStep = { actor: f.ixians,
|
||||
action: { type: 'revive', amount: 3, elite: 1, freeElite: 0 } };
|
||||
const price = forceRevivalQuote(game, ixIndependentSkillsPlayer(game, f.ixians), 3, 1, 0);
|
||||
game = stepIxIndependentSkills(game, revive);
|
||||
game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival);
|
||||
return { ...f, afterBattle, bribe, beforeRecruits, priorReturn, beforePlay, play,
|
||||
beforeRevival, revive, price, afterRevival: game, staging };
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { smugglerShipmentGame } from './smuggler-shipment-fixture';
|
||||
import type { Action } from '../game/engine';
|
||||
|
||||
/** Genuine Leader Skills setup, followed by a conserved post-Nexus ride position. */
|
||||
export function sandmasterWormGame(advanced = false, intrusion = false) {
|
||||
const g = smugglerShipmentGame('fremen', advanced, 'sandmaster', intrusion ? 'beneGesserit' : 'guild');
|
||||
export function sandmasterWormGame(advanced = false, intrusion = false, independent = false) {
|
||||
const g = smugglerShipmentGame('fremen', advanced, 'sandmaster', intrusion ? 'beneGesserit' : 'guild', [], independent);
|
||||
const own = g.players[0];
|
||||
own.name = 'Sandmaster rider';
|
||||
own.forces = { 'wind_pass:14': 2, 'wind_pass:15': 2 };
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import {
|
||||
advanceIxIndependentSkills, assertIxIndependentSkillsCustody, createIxIndependentSkillsFixture,
|
||||
createIxIndependentSkillsRevivalFixture, finishIxIndependentSkillsBattle,
|
||||
ixIndependentSkillsClean, ixIndependentSkillsPlayer as player, ixIndependentSkillsPolicyActions,
|
||||
quoteIxIndependentSkillsBattle,
|
||||
revealIxIndependentSkillsBattle, stepIxIndependentSkills,
|
||||
} from './fixture-ix-independent-skills';
|
||||
|
||||
void test('original Ix deal and paid shipment preserve Cyborg/Suboid custody through the typed Harass commitment', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createIxIndependentSkillsFixture({ advanced });
|
||||
assert.equal(f.beforeIxCard.setupStage, 'skillTreachery');
|
||||
assert.ok(f.beforeIxCard.players.every(p => p.hand.length === 0));
|
||||
assert.equal(f.beforeIxCard.ixSetupCards!.length, 3);
|
||||
assert.equal(f.offered.setupStage, 'leaderSkills');
|
||||
assert.equal(player(f.offered, f.ixians).hand[0].id, f.ixCard.action.card);
|
||||
assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.ixians)!.skill, 'suk-graduate');
|
||||
const before = player(f.beforeShipment, f.ixians), shipped = player(f.afterShipment, f.ixians);
|
||||
assert.equal(before.reserves, 14); assert.equal(before.elites!.reserves, 4);
|
||||
assert.equal(shipped.reserves, 10); assert.equal(shipped.elites!.reserves, 2);
|
||||
assert.equal(shipped.spice, before.spice - 8, 'four desert shipments cost two spice per physical force');
|
||||
assert.equal(player(f.afterShipment, f.opponent).spice, player(f.beforeShipment, f.opponent).spice + 8);
|
||||
assert.equal(shipped.forces[f.first], 4); assert.equal(shipped.elites!.forces[f.first], 2);
|
||||
const moved = player(f.afterMove, f.ixians);
|
||||
for (const key of [f.first, f.second]) {
|
||||
assert.equal(moved.forces[key], 2); assert.equal(moved.elites!.forces[key], 1);
|
||||
}
|
||||
const revealed = revealIxIndependentSkillsBattle(f), quote = quoteIxIndependentSkillsBattle(f, revealed);
|
||||
assert.deepEqual(quote.reinforcements,
|
||||
[{ player: f.ixians, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
||||
assert.deepEqual(quote.scores, { attacker: advanced ? 8.5 : 9.5, defender: 1 },
|
||||
'Suboids always fight at half strength; unsupported Advanced Cyborgs are half their Basic strength');
|
||||
const withdrawal = quote.harassWithdraw![0];
|
||||
assert.deepEqual(withdrawal.returned, { normal: 1, elite: 1 });
|
||||
assert.deepEqual(withdrawal.locations, f.returns);
|
||||
assert.equal(withdrawal.remaining.normal, 1); assert.equal(withdrawal.remaining.elite, 1);
|
||||
assert.equal(revealed.decision?.kind, 'harassWithdraw');
|
||||
if (revealed.decision?.kind !== 'harassWithdraw') throw Error('Missing original typed withdrawal control');
|
||||
const event = revealed.decision.event;
|
||||
assert.throws(() => stepIxIndependentSkills(revealed, { actor: f.ixians,
|
||||
action: { type: 'decision', event,
|
||||
returns: { [f.first]: { normal: 2, elite: 0 } } } }), /undialed|physical|return|allocation/i);
|
||||
const rescue = finishIxIndependentSkillsBattle(f, { state: revealed, stop: 'rescue' });
|
||||
const p = player(rescue, f.ixians);
|
||||
assert.equal(p.reserves, 9); assert.equal(p.elites!.reserves, 3);
|
||||
assert.equal(p.tanks, 3); assert.equal(p.elites!.tanks, 0, 'only Suboids paid the reserve fee');
|
||||
assert.equal(p.forces[f.first], 1); assert.equal(p.elites!.forces[f.first], 1);
|
||||
assert.equal(p.forces[f.second], 1); assert.equal(p.elites!.forces[f.second] ?? 0, 0);
|
||||
assertIxIndependentSkillsCustody(rescue);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the real trained Suk rescues a committed Cyborg and Suboid without refunding reserve fees', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createIxIndependentSkillsFixture({ advanced });
|
||||
const rescue = finishIxIndependentSkillsBattle(f, { stop: 'rescue' });
|
||||
assert.equal(rescue.decision?.kind, 'sukRescue');
|
||||
if (rescue.decision?.kind !== 'sukRescue') throw Error('Missing original native rescue controls');
|
||||
assert.equal(rescue.decision.mode, 'skilled');
|
||||
assert.ok(rescue.decision.options.some(o => o.normal === 1 && o.elite === 1 &&
|
||||
o.kept?.kind === 'elite' && o.kept.key === f.first));
|
||||
const saved = finishIxIndependentSkillsBattle(f, { state: rescue });
|
||||
assert.equal(saved.lastBattleContext!.winner, f.ixians);
|
||||
assert.equal(saved.lastBattleContext!.sukRescue!.completed, true);
|
||||
const p = player(saved, f.ixians);
|
||||
assert.equal(p.reserves, 10); assert.equal(p.elites!.reserves, 3);
|
||||
assert.equal(p.tanks, 3); assert.equal(p.elites!.tanks, 0);
|
||||
assert.equal(p.forces[f.first], 1); assert.equal(p.elites!.forces[f.first], 1);
|
||||
assert.equal(p.forces[f.second] ?? 0, 0);
|
||||
assert.equal(p.spice, 2, 'unsupported Cyborgs do not invent a support charge');
|
||||
assert.equal(player(saved, f.opponent).tanks, 5);
|
||||
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
|
||||
assert.equal(saved.discard.filter(c => c.id === id).length, 1);
|
||||
assertIxIndependentSkillsCustody(saved);
|
||||
const lost = finishIxIndependentSkillsBattle(f, { state: rescue, rescue: 'none' });
|
||||
assert.equal(player(lost, f.ixians).reserves, 9);
|
||||
assert.equal(player(lost, f.ixians).elites!.reserves, 3);
|
||||
assert.equal(player(lost, f.ixians).tanks, 5);
|
||||
assert.equal(player(lost, f.ixians).elites!.tanks, 1);
|
||||
assert.equal(player(lost, f.ixians).forces[f.first] ?? 0, 0);
|
||||
assertIxIndependentSkillsCustody(lost);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Recruits counts prior native free returns, preserves the cap and charges the paid Cyborg surcharge', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createIxIndependentSkillsRevivalFixture({ advanced });
|
||||
const original = viewGame(f.beforeRecruits, f.ixians).revival;
|
||||
assert.equal(original.limit, 3); assert.equal(original.freeRemaining, 1);
|
||||
assert.equal(player(f.beforeRecruits, f.ixians).tanks, 5);
|
||||
assert.equal(player(f.beforeRecruits, f.ixians).elites!.tanks, 1);
|
||||
assert.equal(player(f.beforeRecruits, f.ixians).spice, 5, 'real turn-one escrow settled at the real Mentat Pause');
|
||||
assert.equal(viewGame(f.beforePlay, f.ixians).revival.freeRemaining, 0);
|
||||
assert.equal(player(f.beforePlay, f.ixians).spice, player(f.beforeRecruits, f.ixians).spice);
|
||||
assert.equal(player(f.beforePlay, f.tleilaxu).spice, player(f.beforeRecruits, f.tleilaxu).spice + 1,
|
||||
'native Tleilaxu receive one spice from the Bank for the real free revival');
|
||||
const active = viewGame(f.beforeRevival, f.ixians).revival;
|
||||
assert.equal(active.limit, 7); assert.equal(active.freeRemaining, 1);
|
||||
assert.equal(viewGame(f.beforeRevival, f.tleilaxu).revival.limit, 20,
|
||||
'the global card never reduces native unlimited revival to seven');
|
||||
assert.deepEqual(f.price, { free: 1, normalCost: 5, cost: 5 },
|
||||
'one free Suboid plus one paid Suboid at two and one paid Cyborg at three');
|
||||
const before = player(f.beforeRevival, f.ixians), after = player(f.afterRevival, f.ixians);
|
||||
assert.equal(after.spice, before.spice - 5);
|
||||
assert.equal(player(f.afterRevival, f.tleilaxu).spice, player(f.beforeRevival, f.tleilaxu).spice + 5,
|
||||
'the actual paid native revival reaches Tleilaxu, not an invented Bank sink');
|
||||
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.tanks, before.tanks - 3); assert.equal(after.elites!.tanks, 0);
|
||||
assert.equal(after.revived, 4); assert.equal(after.freeForcesRevived, 2);
|
||||
assert.equal(f.afterRevival.discard.filter(c => c.id === RECRUITS_CARD_ID).length, 1);
|
||||
assert.equal(viewGame(f.afterRevival, f.ixians).revival.freeRemaining, 0);
|
||||
assertIxIndependentSkillsCustody(f.afterRevival);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies can seal native paid plans and execute projected typed withdrawal and rescue controls', () => {
|
||||
const f = createIxIndependentSkillsFixture({ advanced: true });
|
||||
const revealed = revealIxIndependentSkillsBattle(f);
|
||||
const rescue = finishIxIndependentSkillsBattle(f, { state: revealed, stop: 'rescue' });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const plan = ixIndependentSkillsPolicyActions(f.game, f.ixians, difficulty).find(a => a.type === 'battlePlan'); assert.ok(plan);
|
||||
const sealed = stepIxIndependentSkills(f.game, { actor: f.ixians, action: plan });
|
||||
assert.equal(player(sealed, f.ixians).reserves, player(f.game, f.ixians).reserves,
|
||||
'sealing alone cannot spend the fee or dial physical counters');
|
||||
assert.equal(player(sealed, f.ixians).spice, player(f.game, f.ixians).spice);
|
||||
assert.equal(player(sealed, f.ixians).tanks, 0);
|
||||
const returns = ixIndependentSkillsPolicyActions(revealed, f.ixians, difficulty).find(a => a.type === 'decision'); assert.ok(returns);
|
||||
const allocated = stepIxIndependentSkills(revealed, { actor: f.ixians, action: returns });
|
||||
const committed = finishIxIndependentSkillsBattle(f, { state: allocated, stop: 'rescue' });
|
||||
assert.equal(player(committed, f.ixians).reserves, 9);
|
||||
assert.equal(player(committed, f.ixians).elites!.reserves, 3);
|
||||
assert.equal(player(committed, f.ixians).tanks, 3);
|
||||
const choice = ixIndependentSkillsPolicyActions(rescue, f.ixians, difficulty).find(a => a.type === 'decision'); assert.ok(choice);
|
||||
const saved = stepIxIndependentSkills(rescue, { actor: f.ixians, action: choice });
|
||||
const done = advanceIxIndependentSkills(saved, g => g.phase === 7 && ixIndependentSkillsClean(g));
|
||||
assert.equal(player(done, f.ixians).tanks, 3, 'policy rescue saves the commitment, never the fee');
|
||||
assert.equal(player(done, f.ixians).elites!.tanks, 0);
|
||||
assertIxIndependentSkillsCustody(done);
|
||||
}
|
||||
});
|
||||
@@ -17,9 +17,9 @@ function conserved(g: Game) {
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((s, n) => s + n, 0), 3);
|
||||
}
|
||||
}
|
||||
void test('Sandmaster collects once at a worm destination for multiple source sectors and preserves normal movement', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const g = sandmasterWormGame(advanced);
|
||||
void test('Sandmaster collects once at a worm destination with multiple sources, with or without independent cards', () => {
|
||||
for (const advanced of [false, true]) for (const independent of [false, true]) {
|
||||
const g = sandmasterWormGame(advanced, false, independent);
|
||||
const action = sandmasterRide();
|
||||
if (!advanced) delete action.eliteForces;
|
||||
const next = applyAction(JSON.parse(JSON.stringify(g)), 'p', action);
|
||||
@@ -122,7 +122,6 @@ void test('Sandmaster requires the owned living native trainer and the supported
|
||||
assert.equal(sandmasterWormCollection(bad, 'p', 'red_chasm', 7), null);
|
||||
}
|
||||
for (const mutate of [
|
||||
(g: typeof view) => { g.ecazTreachery = true; },
|
||||
(g: typeof view) => { g.players[1].faction = 'richese'; },
|
||||
]) {
|
||||
const unsupported = structuredClone(view); mutate(unsupported);
|
||||
|
||||
@@ -20,6 +20,7 @@ export function smugglerShipmentGame(
|
||||
skill: 'smuggler' | 'sandmaster' = 'smuggler',
|
||||
opponentFaction?: FactionId,
|
||||
expansions: readonly string[] = [],
|
||||
independent = false,
|
||||
): Game {
|
||||
let g = createGame('SMUGSHIP', newPlayer('p', 'Smuggler', faction), advanced, [...expansions]);
|
||||
joinGame(
|
||||
@@ -27,6 +28,7 @@ export function smugglerShipmentGame(
|
||||
newPlayer('h', 'Opponent', opponentFaction ?? (faction === 'guild' ? 'emperor' : 'guild')),
|
||||
);
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
if (independent) g.ecazTreachery = true;
|
||||
const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === skill);
|
||||
let cursor = LEADER_SKILL_CARDS.length - 1;
|
||||
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||
|
||||
Reference in new issue
Block a user