Compose standalone E3 Skills and independent cards with native power defaults

Restore fresh Ecaz coalition and Advanced Moritani initialization; retain printed native continuations and selected-family card eligibility. Validate Mentat fallback hands against explicitly selected independent cards. Frozen25-file274/274, types/lint/build and genuine runtime/mobile entry proofs pass. No save retrofit or deployment.
This commit is contained in:
Quintine committed 2026-10-17 20:36:01 +11:00
1 parent 2ad947af54
commit fde44baab5
25 files changed
+1838 -103

No files matched your search

@@ -0,0 +1,244 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import {
assertEcazIndependentSkillsCustody,
createEcazIndependentSkillsFixture, ecazIndependentSkillsPlayer as player,
quoteEcazIndependentSkillsBattle, settleEcazIndependentSkillsBattle,
} from './fixture-ecaz-independent-skills';
const at = (game: Game, id: string, key: string) => player(game, id).forces[key] ?? 0;
const change = (before: number, after: number) => after - before;
const cards = (game: Game) => [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(seat => seat.hand)];
/** The admitted physical source is the selected original families exactly once. */
function physicalSource(game: Game, count: number): void {
const source = cards(game);
assert.equal(source.length, count);
assert.deepEqual(source.map(card => card.id).sort(), [...treacheryDeck(game.expansions),
...(game.ecazTreachery ? ecazTreacheryCards() : [])].map(card => card.id).sort());
}
for (const advanced of [false, true]) {
void test(`original independent cards with all14 ${advanced ? 'Advanced' : 'Basic'} reach the printed lead choice, and the trained Ecaz Suk rescues only its own committed losses`, () => {
const f = createEcazIndependentSkillsFixture({ advanced, lead: 'ecaz' });
physicalSource(f.initial, 36);
physicalSource(f.game, 36);
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
assert.deepEqual(f.beforeLead.decision?.kind === 'ecazBattleLead' ? f.beforeLead.decision.choices
: [], [f.ecaz, f.ally]);
assert.equal(player(f.beforeLead, f.ecaz).ally, f.ally);
assert.equal(player(f.beforeLead, f.ally).ally, f.ecaz);
assert.equal(f.trainer, 'ecaz-0');
assert.equal(f.game.leaderSkills!.assignments.find(a => a.owner === f.ecaz)!.skill, 'suk-graduate');
assert.equal(f.game.phase, 6);
// Both printed cards occupy either slot but stay in the lead's own plan.
const plan = f.plans[0].action;
assert.ok(plan.type === 'battlePlan');
assert.equal(plan.weapon, REINFORCEMENTS_CARD);
assert.equal(plan.defense, HARASS_WITHDRAW_CARD);
assert.equal(f.fixedEcazDial, 2, 'ceil(E/2) of Ecaz’s four committed counters is free');
assert.equal(f.planDial, f.fixedEcazDial + 2, 'the plan dial adds the ally’s two variable counters');
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.reinforcements,
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.equal(quote.harassWithdraw![0].remaining.normal, 2);
assert.equal(quote.harassWithdraw![0].remaining.elite, 0);
assert.equal(quote.harassWithdraw![0].remaining.freeSupport, true,
'the mandatory fixed Ecaz counters are fully supported by the coalition plan');
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 2, elite: 0 } });
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
assert.equal(quote.casualties?.owner, f.ally);
assert.deepEqual(quote.sukGraduate, { leader: 'ecaz-0', mode: 'skilled' });
assert.ok(quote.scores);
assert.equal(quote.scores[quote.winner === f.game.battle!.attacker ? 'attacker' : 'defender'],
player(f.game, f.ecaz).leaders.find(leader => leader.id === 'ecaz-0')!.strength +
f.planDial + 2, 'the card’s +2 stays a separate score modifier');
// The real rescue offer is the skilled band of Ecaz's own committed losses.
const rescue = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' });
assert.equal(rescue.decision?.kind, 'sukRescue');
if (rescue.decision?.kind !== 'sukRescue') throw Error('Expected the original Suk rescue window');
assert.equal(rescue.decision.mode, 'skilled');
assert.equal(rescue.decision.player, f.ecaz);
assert.deepEqual(rescue.decision.options.filter(option => option.kept),
[{ normal: 1, elite: 0, kept: { key: f.location, kind: 'normal' } },
{ normal: 2, elite: 0, kept: { key: f.location, kind: 'normal' } }]);
const end = settleEcazIndependentSkillsBattle(f, { state: rescue });
assert.equal(end.phase, 7, 'the original battle continues to native Spice Collection');
assert.equal(end.lastBattleContext?.winner, f.ecaz);
assert.equal(end.lastBattleContext?.result, 'normal');
assert.equal(end.lastBattleContext?.sukRescue?.completed, true);
assert.equal(end.pendingSukRescue, null);
// Ecaz: three fee counters in the Tanks, two returned undialed, one rescued
// counter kept in the sector and one back in reserves.
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 3);
assert.equal(at(f.game, f.ecaz, f.location), 4);
assert.equal(at(end, f.ecaz, f.location), 1);
assert.equal(at(end, f.ecaz, 'imperial_basin:9'), at(f.game, f.ecaz, 'imperial_basin:9'));
// The ally's own variable army is lossy and never rescued by Ecaz's skill.
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), 0);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assert.equal(at(end, f.ally, f.location), 2);
// The defeated defender loses its whole army and never touches the fee.
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
assert.equal(at(end, f.opponent, f.location), 0);
assert.equal(change(player(f.game, f.opponent).reserves, player(end, f.opponent).reserves), 0);
assertEcazIndependentSkillsCustody(end);
});
}
for (const card of ['reinforcements', 'harass'] as const) {
void test(`${card === 'reinforcements' ? 'Reinforcements alone pays three own reserves before the rescue' : 'Harass & Withdraw alone returns only the card user’s undialed counters'}, never the fixed Ecaz contribution`, () => {
const f = createEcazIndependentSkillsFixture({ cards: [card] });
physicalSource(f.game, 36);
const quote = quoteEcazIndependentSkillsBattle(f);
if (card === 'reinforcements') {
assert.deepEqual(quote.reinforcements,
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.equal(quote.harassWithdraw, undefined);
} else {
assert.equal(quote.reinforcements, undefined);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.equal(quote.harassWithdraw![0].remaining.freeSupport, true);
}
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.ecaz);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks),
card === 'reinforcements' ? 3 : 0, 'only the Reinforcements fee reaches the Tanks');
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves),
card === 'reinforcements' ? -2 : 3);
assert.equal(at(end, f.ecaz, f.location), card === 'reinforcements' ? 3 : 1);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assert.equal(at(end, f.ally, f.location), 2);
assertEcazIndependentSkillsCustody(end);
});
}
void test('an ally-led coalition spends the ally’s own fee and army and never rescues Ecaz’s fixed commitment', () => {
const f = createEcazIndependentSkillsFixture({ lead: 'ally' });
physicalSource(f.game, 36);
assert.equal(f.plans[0].actor, f.ally);
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.reinforcements,
[{ player: f.ally, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }],
'the card user pays the printed reserve cost, not the coalition partner');
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 0, elite: 2 });
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 0, elite: 2 } });
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
assert.equal(quote.sukGraduate, undefined, 'the trained Ecaz disc is not the winning plan’s leader');
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.ally);
assert.equal(end.lastBattleContext?.sukRescue, undefined);
assert.equal(f.trainer, 'ecaz-0');
assert.equal(player(end, f.ecaz).leaders.find(leader => leader.id === f.trainer)!.dead, false);
// Ecaz's fixed commitment dies unrescued while its own card custody is untouched.
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 2);
assert.equal(at(f.game, f.ecaz, f.location), 4);
assert.equal(at(end, f.ecaz, f.location), 2);
// The ally pays three reserve fee counters plus its own two commitment losses.
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), -1);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 5);
assert.equal(at(end, f.ally, f.location), 0);
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
assertEcazIndependentSkillsCustody(end);
});
void test('a revealed skill keeps the skilled leader out of the plan and rescues exactly one committed counter with no kept counter', () => {
const f = createEcazIndependentSkillsFixture({ hide: false });
physicalSource(f.game, 36);
const plan = f.plans[0];
assert.ok(plan.action.type === 'battlePlan' && plan.action.leader !== f.trainer,
'the face-up skilled leader cannot be in the battle plan');
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.sukGraduate, { leader: 'ecaz-0', mode: 'normal' });
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.pendingSukRescue, null, 'the single normal-band option settles automatically');
assert.equal(end.lastBattleContext?.sukRescue?.completed, true);
// Three fee counters plus the one unrescued committed counter reach the Tanks;
// the normal band never keeps a counter in the battle sector.
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 4);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
assert.equal(at(end, f.ecaz, f.location), 0);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assertEcazIndependentSkillsCustody(end);
});
void test('an opposing successful Traitor cancels Harass but preserves the card-user reserve fee', () => {
const f = createEcazIndependentSkillsFixture({ traitor: true, advanced: true });
assert.equal(f.traitor, f.trainer);
assert.ok(player(f.game, f.opponent).traitors.includes(f.traitor!));
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.result, 'traitor');
assert.equal(end.lastBattleContext?.winner, f.opponent);
assert.equal(end.lastBattleContext?.sukRescue, undefined);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), -3);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 7);
assert.equal(at(end, f.ecaz, f.location), 0);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 4);
assert.equal(at(end, f.ally, f.location), 0);
assert.equal(at(end, f.opponent, f.location), 2);
assertEcazIndependentSkillsCustody(end);
});
for (const advanced of [false, true]) {
void test(`the printed ${advanced ? 'Advanced' : 'Basic'} Occupy needs no Skills: the no-Skills independent control settles the same real coalition with both cards`, () => {
const f = createEcazIndependentSkillsFixture({ advanced, skills: false });
assert.equal(f.trainer, null);
assert.equal(f.game.leaderSkills, undefined);
physicalSource(f.game, 36);
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.reinforcements,
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.equal(quote.sukGraduate, undefined);
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.ecaz);
assert.equal(end.lastBattleContext?.sukRescue, undefined);
// Fee first, then the return, then the mandatory commitment losses.
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 5);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), -1);
assert.equal(at(end, f.ecaz, f.location), 0);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assert.equal(at(end, f.ally, f.location), 2);
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
assertEcazIndependentSkillsCustody(end);
});
}
for (const advanced of [false, true]) {
void test(`the real no-card, no-Skills ${advanced ? 'Advanced' : 'Basic'} coalition still exposes its lead choice and settles to Collection`, () => {
const f = createEcazIndependentSkillsFixture({ advanced, skills: false, independent: false, cards: [] });
assert.equal(f.initial.ecazTreachery, undefined);
assert.equal(f.game.leaderSkills, undefined);
assert.deepEqual(f.cardIds, []);
physicalSource(f.initial, 33);
physicalSource(f.game, 33);
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.ecaz);
assert.equal(end.lastBattleContext?.result, 'normal');
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 2);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
assert.equal(at(end, f.ecaz, f.location), 2);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assert.equal(at(end, f.ally, f.location), 2);
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
assert.equal(at(end, f.opponent, f.location), 0);
assertEcazIndependentSkillsCustody(end);
});
}
-17
View File
@@ -13,7 +13,6 @@ import {
ecazOccupyCardsCase, finishOccupyCardsAftermath, occupyCardsPlayer as seat,
type OccupyCardsCase,
} from './fixture-ecaz-occupy-cards';
import { harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
const forces = (result: OccupyCardsCase, id: string, game = result.game) =>
ecazOccupyTerritoryForces(game, id, result.fixture.territory);
@@ -361,19 +360,3 @@ void test('selected typed Harass losses survive an opposing mandatory Reinforcem
collection(result.game);
});
void test('Basic co-present Ecaz card armies remain unsupported instead of admitting an ordinary single-army outcome', () => {
for (const card of ['ecaz-harass-withdraw', 'ecaz-reinforcements']) {
const game = harassWithdrawGame({ advanced: false, factions: ['guild', 'emperor', 'ecaz'] });
const guild = game.players[0], ecaz = game.players[2];
// Labelled co-side rule-unit staging on the genuine ordinary variant setup.
guild.ally = ecaz.id;
ecaz.ally = guild.id;
ecaz.forces = { 'arrakeen:10': 2 };
ecaz.reserves -= 2;
takeHarassCard(game, guild.id, card);
assert.throws(() => applyAction(game, guild.id,
{ type: 'battlePlan', dial: 1, leader: 'guild-0', weapon: card }),
/supported Advanced Occupy battle profile/);
assert.equal(game.battle?.plans[guild.id], undefined);
}
});
+432
View File
@@ -0,0 +1,432 @@
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game, type Player } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { withoutEcazLoyalty } from '../game/ecaz-loyalty';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { traitorDeck } from '../game/traitors';
import {
defaultHarassWithdrawAllocation, HARASS_WITHDRAW_CARD,
type HarassWithdrawSelection,
} from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import type { SukRescueOption } from '../game/suk-graduate';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { chooseEcazOccupyLead, finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { createHomeworldEcazOccupyFixture } from './fixture-homeworld-ecaz-occupy';
/** Printed E3 shared rule (docs/ECAZ_TREACHERY_RULES.md): the three-card variant
* is independent of faction selection and other variants, so a standalone Ecaz
* seat with classic opponents keeps it; all14 Leader Skills is the separate
* optional module documented in docs/LEADER_SKILLS_RULES.md. Card timings and the
* fee-before-return order stay provisional as recorded there, not new rulings. */
export type EcazIndependentSkillsStep = { actor: string; action: Action };
export type EcazIndependentSkillsCard = 'reinforcements' | 'harass';
export type EcazIndependentSkillsOptions = {
advanced?: boolean;
/** All14 skills; Basic and Advanced are both genuine native bands. */
skills?: boolean;
/** The independent physical three-card variant; `false` is the printed no-card control. */
independent?: boolean;
/** Real native Battle Plan cards in the lead's own plan. */
cards?: readonly EcazIndependentSkillsCard[];
lead?: 'ecaz' | 'ally';
/** Trained posture: hide the Ecaz skill for the skilled band, reveal for normal. */
hide?: boolean;
/** The opponent holds the real Traitor matching the committed lead disc and calls it. */
traitor?: boolean;
};
export type EcazIndependentSkillsPolicy = { hide?: Record<string, boolean> };
export const ECAZ_INDEPENDENT_SKILLS_CARD_IDS = {
reinforcements: REINFORCEMENTS_CARD, harass: HARASS_WITHDRAW_CARD,
} as const;
/** Reinforcements occupies the weapon slot, Harass & Withdraw the defense slot. */
export const ECAZ_INDEPENDENT_SKILLS_SLOTS = {
reinforcements: 'weapon', harass: 'defense',
} as const;
export const ecazIndependentSkillsPlayer = (game: Game, actor: string): Player => {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original seat ${actor}`);
return player;
};
export const ecazIndependentSkillsClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
/** Scoped deterministic entropy for this bounded programme, following the
* existing independent-skills fixtures: the fourteen-card lottery is solved with
* real Fisher-Yates draws, later single-word draws in the same creation keep the
* fixed maximum value, larger native requests are forwarded, and the patch is
* 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 && array.length === 1)
array[0] = cursor < rolls.length ? 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');
}
}
/** Actual Fisher-Yates draws for one desired fourteen-card order. */
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;
}
export function stepEcazIndependentSkills(game: Game, step: EcazIndependentSkillsStep): Game {
return applyAction(game, step.actor, step.action);
}
/** Real native decisions only; a human plan, withdrawal allocation or rescue
* choice is never invented by this helper. */
export function nextEcazIndependentSkillsStep(game: Game,
policy: EcazIndependentSkillsPolicy = {}): EcazIndependentSkillsStep | null {
if (!game.response && !game.phaseOpening) {
const decision = game.decision;
if (decision?.kind === 'leaderSkillVisibility')
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event,
hide: policy.hide?.[decision.player] ?? true } };
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'rihani')
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
if (decision?.kind === 'faceDance')
return { actor: decision.player, action: { type: 'decision', reveal: false } };
if (['sukRescue', 'harassWithdraw', 'battleLosses'].includes(decision?.kind ?? '')) return null;
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceEcazIndependentSkills(state: Game, until: (game: Game) => boolean,
actions: EcazIndependentSkillsStep[] = [], policy: EcazIndependentSkillsPolicy = {}): Game {
let game = state;
for (let count = 0; count < 1500; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextEcazIndependentSkillsStep(game, policy);
assert.ok(step, 'Supply the genuine private plan, typed return or rescue choice');
actions.push(structuredClone(step));
game = stepEcazIndependentSkills(game, step);
}
throw Error('The native Ecaz independent programme did not reach its boundary');
}
/** Only conserved physical card custody moves; no stock, forces or wallet. */
export function stageEcazIndependentSkillsCard(game: Game, actor: string,
id: string, staging: string[]): string {
const staged = stageEcazOccupyCard(game, actor, card => card.id === id);
staging.push(`Conserved ${staged}: original deck/discard/hand → ${actor} hand; not a purchase, native deal or a new physical card`);
return staged;
}
/** The admitted original card source (thirty-six with the independent variant),
* all14 skills and ordinary counters. */
export function assertEcazIndependentSkillsCustody(game: Game): void {
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
const sum = (values: Record<string, number>) => Object.values(values).reduce((a, b) => a + b, 0);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + sum(player.forces), 20, player.faction);
assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
.every(count => Number.isSafeInteger(count) && count >= 0), player.faction);
if (player.elites) {
assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces),
player.faction === 'emperor' ? 5 : 3, player.faction);
assert.ok([player.elites.reserves, player.elites.tanks, ...Object.values(player.elites.forces)]
.every(count => Number.isSafeInteger(count) && count >= 0), player.faction);
assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks);
for (const [key, count] of Object.entries(player.elites.forces))
assert.ok(count <= (player.forces[key] ?? 0));
}
assert.ok(player.hand.length <= handLimit(player), player.faction);
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(cards.map(card => card.id).sort(), [...treacheryDeck(game.expansions),
...(game.ecazTreachery ? ecazTreacheryCards() : [])].map(card => card.id).sort());
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors),
...game.players.flatMap(p => (p.faceDancers ?? []).map(dancer => dancer.leader))].sort(),
withoutEcazLoyalty(traitorDeck(game.players, game.expansions.includes('ix')), game.ecazLoyalty).sort());
}
export type EcazIndependentSkillsFixture = {
/** Original fresh lobby/setup snapshots taken by the shared genuine programme. */
initial: Game; afterSetup: Game;
/** Original before-lead window and the recorded native lead choice. */
beforeLead: Game; leadStep: EcazIndependentSkillsStep; afterLead: Game;
/** Clean before-plan window (both plans still unsealed) and the real plans. */
beforePlans: Game; plans: EcazIndependentSkillsStep[];
/** Revealed window: both original plans sealed, postures committed. */
game: Game;
/** Typed physical allocation derived from the real projected offer. */
returns: HarassWithdrawSelection;
ecaz: string; ally: string; opponent: string; territory: string; location: string;
fixedEcazDial: number; planDial: number; support: number;
cardUser: string; cardIds: string[]; cardKinds: EcazIndependentSkillsCard[];
/** Ecaz's real trained disc; null when the no-Skills control is used. */
trainer: string | null;
/** Conserved opposing Traitor identity and its actual caller seats. */
traitor: string | null; callers: string[];
advanced: boolean; skills: boolean; independent: boolean; hide: boolean;
actions: EcazIndependentSkillsStep[]; staging: string[];
};
/** Original offered card and eligible disc for every seat; Ecaz requests its
* trained Suk Graduate. The captured `initial` snapshot stays untouched. */
function trainEcazSeat(initial: Game, steps: EcazIndependentSkillsStep[]): Game {
let game = initial;
while (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const skill: LeaderSkillId = actor === 'ecaz' ? 'suk-graduate'
: offer.cards.find(card => !view.unavailableSkills?.[card])!;
assert.ok(offer.cards.includes(skill), 'The requested original skill must be in the real offer');
assert.ok(!view.unavailableSkills?.[skill], 'The requested original skill must be selectable');
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = ecazIndependentSkillsPlayer(game, actor).leaders
.filter(disc => eligible.has(disc.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader, 'The real eligible leaders must supply this seat’s choice');
const step: EcazIndependentSkillsStep = { actor,
action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
steps.push(structuredClone(step));
game = stepEcazIndependentSkills(game, step);
}
return finishEcazOccupySetup(game);
}
/** Conserved original Traitor identity: one real held card exchanges with the
* opponent's reserve copy so the printed call has genuine custody. */
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const player = ecazIndependentSkillsPlayer(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0];
assert.ok(returned, 'The opponent needs an original Traitor Card to exchange');
const reserve = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(seat => seat.id !== actor && seat.traitors.includes(identity));
assert.ok(reserve >= 0 || donor, 'The requested Traitor must retain original custody');
if (reserve >= 0) game.traitorReserve!.splice(reserve, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Conserved Traitor ${identity}: original ${donor?.id ?? 'reserve'} custody exchanged with ${actor}'s ${returned}; no new Traitor Card`);
}
/** Real living disc for a sealed plan; a revealed skilled leader has to stay
* behind the shield, so it is never offered here. */
function battleLeader(game: Game, seat: string, exclude: string | null, strongest: boolean): string {
const discs = ecazIndependentSkillsPlayer(game, seat).leaders
.filter(disc => !disc.dead && !disc.usedAt && disc.id !== exclude);
assert.ok(discs.length, `The ${seat} roster must supply a legal living leader`);
return discs.sort((a, b) => (strongest ? b.strength - a.strength : a.strength - b.strength) ||
a.id.localeCompare(b.id))[0].id;
}
/** Genuine native setup, a real worm Nexus alliance, paid Arrakis shipments and
* the original lead/plan windows. With skills the all14 lottery is solved with
* real Fisher-Yates draws so Ecaz’s own offer holds its Suk Graduate, while the
* remaining single-word draws in this one creation keep the fixed maximum value
* and larger native requests are forwarded unchanged. */
export function createEcazIndependentSkillsFixture(
options: EcazIndependentSkillsOptions = {},
): EcazIndependentSkillsFixture {
const advanced = options.advanced ?? true;
const skills = options.skills ?? true;
const independent = options.independent ?? true;
const hide = options.hide ?? true;
const lead = options.lead ?? 'ecaz';
const cardKinds = [...(options.cards ?? ['reinforcements', 'harass'] as const)];
const picks: EcazIndependentSkillsStep[] = [];
const staging: string[] = [];
const order = ['suk-graduate' as LeaderSkillId,
...LEADER_SKILL_CARDS.map(card => card.id).filter(id => id !== 'suk-graduate')];
const draws = skills ? permutationDraws(LEADER_SKILL_CARDS.map(card => card.id), order) : [];
const shared = entropy(() => createHomeworldEcazOccupyFixture({
advanced, skills, independent, homeworlds: false,
...(skills ? { finishSetup: (initial: Game) => trainEcazSeat(initial, picks) } : {}),
}), draws);
if (skills) {
staging.push('Solved the original fourteen-card lottery with real Fisher-Yates draws for Ecaz’s own offer; later single-word draws in this creation kept the fixed maximum value and larger native requests were forwarded unchanged.');
assert.equal(shared.initial.leaderSkills !== undefined, true);
const ecazAssignment = shared.afterSetup.leaderSkills!.assignments.find(a => a.owner === shared.ecaz);
assert.equal(ecazAssignment?.skill, 'suk-graduate',
'The requested original skill must be the real assignment of that seat');
} else {
assert.equal(shared.initial.leaderSkills, undefined);
assert.equal(shared.afterSetup.leaderSkills, undefined);
}
assert.equal(shared.initial.ecazTreachery === true, independent,
'The independent three-card variant must be an original lobby selection');
const trainer = skills ? shared.afterSetup.leaderSkills!.assignments.find(a => a.owner === shared.ecaz)!.leader : null;
const beforeLead = structuredClone(shared.game);
assert.equal(beforeLead.decision?.kind, 'ecazBattleLead',
'The printed Ecaz Occupy lead choice must be the genuine pending decision');
const leadStep: EcazIndependentSkillsStep = { actor: shared.ecaz, action: { type: 'decision',
event: beforeLead.decision!.event, lead: lead === 'ecaz' ? shared.ecaz : shared.ally } };
const actions: EcazIndependentSkillsStep[] = [...shared.actions, ...picks];
let game = chooseEcazOccupyLead({ ...shared, game: beforeLead }, lead);
actions.push(structuredClone(leadStep));
const policy: EcazIndependentSkillsPolicy = { hide: {
[shared.ecaz]: hide, [shared.ally]: true, [shared.opponent]: true } };
game = advanceEcazIndependentSkills(game,
state => !!state.battle && ecazIndependentSkillsClean(state) && !state.battle.preparation &&
state.battle.preLeader?.closed !== false, actions, policy);
const afterLead = structuredClone(game);
const beforePlans = structuredClone(game);
const cardUser = lead === 'ecaz' ? shared.ecaz : shared.ally;
const cardIds = cardKinds.map(kind => stageEcazIndependentSkillsCard(game, cardUser,
ECAZ_INDEPENDENT_SKILLS_CARD_IDS[kind], staging));
const profile = viewGame(game, cardUser).battle?.ecazOccupy?.profile;
assert.ok(profile, 'The selected coalition must quote its real Occupy profile');
assert.equal(profile.lead, cardUser);
const own: { weapon?: string; defense?: string } = {};
for (const [index, kind] of cardKinds.entries())
own[ECAZ_INDEPENDENT_SKILLS_SLOTS[kind]] = cardIds[index];
const variable = 2;
const support = variable;
const allyTrainer = game.leaderSkills?.assignments.find(a => a.owner === shared.ally)?.leader;
const planLeader = cardUser === shared.ecaz
? (skills && hide ? trainer! : battleLeader(game, shared.ecaz, trainer, true))
: battleLeader(game, shared.ally, allyTrainer ?? null, true);
const defender = battleLeader(game, shared.opponent, null, false);
const traitor = options.traitor ? planLeader : null;
const callers: string[] = [];
if (traitor) {
conserveTraitor(game, shared.opponent, traitor, staging);
callers.push(shared.opponent);
}
const plans: EcazIndependentSkillsStep[] = [
{ actor: cardUser, action: { type: 'battlePlan', dial: profile.fixedEcazDial + variable,
support, allyPayment: 0, leader: planLeader, ...own } },
{ actor: shared.opponent, action: { type: 'battlePlan', dial: 0, support: 0,
leader: defender, weapon: null, defense: null } },
];
for (const plan of plans) {
actions.push(structuredClone(plan));
game = stepEcazIndependentSkills(game, plan);
}
assert.ok(game.battle?.revealed, 'Both original plans must be revealed');
// A sole legal allocation is applied by the engine; otherwise the card user
// still holds a genuine typed sector choice, derived from the real offer.
let returns: HarassWithdrawSelection = {};
if (game.decision?.kind === 'harassWithdraw') {
const offer = viewGame(game, game.decision.player).battle?.harassAllocation;
assert.ok(offer, 'Use only the actual revealed physical allocation offer');
returns = defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support);
}
const fixture: EcazIndependentSkillsFixture = {
initial: shared.initial, afterSetup: shared.afterSetup, beforeLead, leadStep, afterLead,
beforePlans, plans, game, returns, ecaz: shared.ecaz, ally: shared.ally, opponent: shared.opponent,
territory: shared.territory, location: shared.location, fixedEcazDial: profile.fixedEcazDial,
planDial: profile.fixedEcazDial + variable, support, cardUser, cardIds, cardKinds, trainer,
traitor, callers, advanced, skills, independent, hide, actions, staging,
};
assertEcazIndependentSkillsCustody(game);
return fixture;
}
/** Real revealed quote: the card user's own reserve fee, the coalition scores
* and the typed own-army withdrawal groups, never a merged army. */
export function quoteEcazIndependentSkillsBattle(f: EcazIndependentSkillsFixture, state?: Game) {
const game = state ?? f.game;
const battle = game.battle;
assert.ok(battle?.revealed, 'Quote only the genuinely revealed original battle');
const view = viewGame(game, f.cardUser).battle!;
const seat = (actor: string): ResolutionCombatant => {
const player = ecazIndependentSkillsPlayer(game, actor);
const own = viewGame(game, actor).battle!;
const plan = battle.plans[actor];
assert.ok(plan, `The ${actor} plan must be sealed`);
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand, plan,
leader: player.leaders.find(leader => leader.id === plan.leader),
forces: own.ownForces!,
leaderSkills: (game.leaderSkills?.assignments ?? []).filter(a => a.owner === actor)
.map(a => ({ skill: a.skill, leader: a.leader,
faceUp: battle.leaderSkillHidden?.[actor] !== true, captured: false })),
...(own.reinforcements ? { reinforcementsReserves: {
normal: player.reserves - (player.elites?.reserves ?? 0), elite: player.elites?.reserves ?? 0 } } : {}),
...(own.harassWithdraw ? { harassWithdraw: { blocked: null, forces: own.harassWithdraw.forces,
locations: own.harassWithdraw.locations,
...(own.harassWithdraw.occupy ? { occupy: own.harassWithdraw.occupy } : {}) },
...(Object.keys(f.returns).length ? { harassSelection: f.returns } : {}) } : {}) };
};
assert.ok(view.ecazOccupy?.profile, 'Quote the actual selected native coalition');
return quoteBattleResolution({
advanced: game.advanced,
turn: game.turn,
territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor,
ecazOccupy: view.ecazOccupy.profile,
attacker: seat(battle.attacker),
defender: seat(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(id => ({ id,
beneficiary: battle.ecazOccupy?.lead && [f.ecaz, f.ally].includes(id)
? battle.ecazOccupy.lead : id,
called: false, traitors: ecazIndependentSkillsPlayer(game, id).traitors })),
participants: game.players,
physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)],
pendingAuditorPresent: false,
pendingRetentionPresent: false,
});
}
/** Real lead choice for a custom programme; the recorded step stays legal. */
export function chooseEcazIndependentSkillsLead(game: Game, lead: 'ecaz' | 'ally',
ecaz: string, ally: string): EcazIndependentSkillsStep {
const decision = game.decision;
assert.ok(decision?.kind === 'ecazBattleLead', 'Only the native Ecaz lead choice may select this plan owner');
return { actor: ecaz, action: { type: 'decision', event: decision.event, lead: lead === 'ecaz' ? ecaz : ally } };
}
/** Resolve the original aftermath: Traitor calls, the typed withdrawal
* allocation, the real casualty choices, the Suk rescue and mandatory disposal,
* stopping at native Spice Collection. */
export function settleEcazIndependentSkillsBattle(f: EcazIndependentSkillsFixture,
options: { state?: Game; returns?: HarassWithdrawSelection;
rescue?: (options: readonly SukRescueOption[]) => number;
stop?: 'rescue' | 'returns' | 'collection' } = {}): Game {
let game = structuredClone(options.state ?? f.game);
for (let count = 0; count < 400; count++) {
if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game;
if (options.stop === 'returns' && game.decision?.kind === 'harassWithdraw') return game;
if (game.phase === 7 && ecazIndependentSkillsClean(game)) return game;
assert.notEqual(game.status, 'finished');
let step: EcazIndependentSkillsStep | null;
const decision = game.decision;
if (decision?.kind === 'harassWithdraw') {
const returns = options.returns ?? f.returns;
const offer = viewGame(game, decision.player).battle?.harassAllocation;
assert.ok(offer, 'Use only the actual revealed physical allocation offer');
step = { actor: decision.player, action: { type: 'decision', event: decision.event, returns } };
} else if (decision?.kind === 'battleLosses') {
const index = decision.options.findIndex(option => option.normal === f.planDial - f.fixedEcazDial &&
option.elite === 0);
step = { actor: decision.player, action: { type: 'decision',
choice: index >= 0 ? index : 0 } };
} else if (decision?.kind === 'sukRescue') {
const choice = options.rescue ? options.rescue(decision.options)
: decision.options.findIndex(option => option.normal === f.fixedEcazDial && option.elite === 0 && option.kept);
assert.ok(choice >= 0, 'Choose one real Suk Graduate rescue option');
step = { actor: decision.player, action: { type: 'decision', event: decision.event, choice } };
} else {
const next = nextEcazIndependentSkillsStep(game);
step = next && next.action.type === 'traitorCall'
? { ...next, action: { type: 'traitorCall', call: f.callers.includes(next.actor) } } : next;
}
assert.ok(step, 'Resolve both original plans before their physical aftermath');
game = stepEcazIndependentSkills(game, step);
}
throw Error('The native Ecaz independent battle did not reach Collection');
}
+5 -2
View File
@@ -35,9 +35,11 @@ export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null {
* Arrakis shipments. Only unopened Spice order is staged; armies, wallets,
* phases, alliances, World custody and offered skills follow the real actions. */
export function createHomeworldEcazOccupyFixture(options: {
advanced?: boolean; skills?: boolean;
advanced?: boolean; skills?: boolean; independent?: boolean;
/** No-World control uses the same genuine alliance/Arrakis route. */
homeworlds?: boolean;
/** Select only actual offered setup choices; the captured initial stays untouched. */
finishSetup?: (initial: Game) => Game;
} = {}): HomeworldEcazOccupyFixture {
const advanced = options.advanced ?? true;
const homeworlds = options.homeworlds ?? true;
@@ -50,11 +52,12 @@ export function createHomeworldEcazOccupyFixture(options: {
game = applyAction(game, actor, action);
};
if (homeworlds) act('ecaz', { type: 'homeworlds', enabled: true });
if (options.independent) act('ecaz', { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
if (options.skills) act('ecaz', { type: 'moduleOption', module: 'leaderSkills', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
act('ecaz', advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const initial = structuredClone(game);
game = finishEcazOccupySetup(game);
game = (options.finishSetup ?? finishEcazOccupySetup)(game);
const afterSetup = structuredClone(game);
const step = () => {
const next = nextHomeworldEcazStep(game);
@@ -0,0 +1,476 @@
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 { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
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, defaultHarassWithdrawAllocation,
type HarassWithdrawContext, type HarassWithdrawSelection,
} from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type MoritaniIndependentSkillsStep = { actor: string; action: Action };
/** 'guild' keeps the classic Heighliner opponent; 'harkonnen' is the native
* no-Skills Advanced roster and exercises its real capture offer. */
export type MoritaniIndependentSkillsOpponent = 'guild' | 'harkonnen';
export type MoritaniIndependentSkillsOptions = {
/** Original rules mode of the fresh lobby built here; never a preview marker. */
rules?: 'basic' | 'advanced';
/** All fourteen physical Leader Skill cards, dealt by the real initializer. */
skills?: boolean;
/** The independent three-card Ecaz Treachery variant of printed p. 18. */
cards?: boolean;
opponent?: MoritaniIndependentSkillsOpponent;
/** Conserved Traitor identity Moritani really holds: the unused target disc
* (default) or the winning committed disc, for a real normal Traitor call. */
traitor?: 'target' | 'winner';
/** Continue only the fresh classic/E3 lobby this fixture itself built. */
initial?: Game;
};
/** Real human controls only: the loss owner's typed withdrawal, every Traitor
* call, the winner's cleanup discard, the Harkonnen capture offer and the
* Assassinate choice are never invented by a fallback. */
export type MoritaniIndependentSkillsPolicy = {
placement?: { territory: string; sector: number };
harassReturns?: HarassWithdrawSelection | ((context: HarassWithdrawContext) => HarassWithdrawSelection);
traitorCalls?: readonly string[];
/** Played cards the winner really discards during the original cleanup. */
winnerDiscard?: 'played' | 'none';
/** Real Harkonnen Advanced choice: decline by default, or capture and then
* keep or execute the captive through the original offer and response. */
capture?: { accept: boolean; captured?: 'keep' | 'execute' };
hide?: Readonly<Record<string, boolean>>;
};
export type MoritaniIndependentSkillsFixture = {
initial: Game;
offered: Game;
afterSetup: Game;
beforeSplit: Game;
split: MoritaniIndependentSkillsStep;
afterSplit: Game;
entry: MoritaniIndependentSkillsStep;
afterEntry: Game;
beforeBattle: Game;
battleStep: MoritaniIndependentSkillsStep;
/** Phase-six window with the real battle chosen and both plans unsealed. */
game: Game;
plans: MoritaniIndependentSkillsStep[];
revealed: Game;
/** The real typed undialed return of the loser's revealed Harass plan. */
returns: HarassWithdrawSelection | null;
returnsContext: HarassWithdrawContext | null;
/** Real post-loss state: the owner's Assassinate choice (Advanced), the
* Harkonnen capture offer, or the clean continuation (Basic). */
pending: Game;
moritani: string;
opponent: string;
third: string;
territory: string;
first: string;
second: string;
/** The opposing leader disc that was really committed and won. */
winnerLeader: string;
/** The losing Moritani disc that was really committed. */
loserLeader: string;
/** Conserved physical Traitor identity held by Moritani for this battle. */
traitorTarget: string;
/** The identity Moritani really holds, which may be the winning disc. */
heldTraitor: string;
chosen: Record<string, LeaderSkillId>;
cards: { reinforcements: string | null; harass: string | null; winnerWeapon: string };
actions: MoritaniIndependentSkillsStep[];
staging: string[];
};
export const moritaniIndependentSkillsPlayer = (game: Game, actor: string): Player => {
const player = game.players.find((seat) => seat.id === actor);
assert.ok(player, `Missing original seat ${actor}`);
return player;
};
export const moritaniIndependentSkillsClean = (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 index = 0; index < array.length; index++) array[index] = 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');
}
}
/** Only the original fourteen-card lottery is solved; later native draws keep
* a fixed maximum value, and every roll is restored afterwards. */
function skillRolls(chosen: readonly LeaderSkillId[]): number[] {
const source = LEADER_SKILL_CARDS.map((card) => card.id);
const rest = source.filter((id) => !chosen.includes(id));
const desired = chosen.flatMap((id) => [id, rest.shift()!]).concat(rest);
const work = [...source], rolls: number[] = [];
for (let index = work.length - 1; index > 0; index--) {
const from = work.indexOf(desired[index]);
assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[work[index], work[from]] = [work[from], work[index]];
}
return rolls;
}
export function stepMoritaniIndependentSkills(game: Game, step: MoritaniIndependentSkillsStep): Game {
return entropy(() => applyAction(game, step.actor, step.action));
}
/** Real private projection and bot policy; never alters a human lobby seat. */
export function moritaniIndependentSkillsPolicy(
game: Game, actor: string, difficulty: Difficulty = 'Easy',
): Action[] {
const view = viewGame(game, actor);
view.players.find((player) => player.id === actor)!.bot = difficulty;
return entropy(() => botActions(view));
}
/** Only genuine native decisions; a human plan, Traitor call, typed return,
* capture response or Assassinate choice is never invented here. */
export function nextMoritaniIndependentSkillsStep(
game: Game, policy: MoritaniIndependentSkillsPolicy = {},
): MoritaniIndependentSkillsStep | null {
if (!game.response && !game.phaseOpening) {
const decision = game.decision;
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'moritaniSetup') {
const placement = policy.placement;
assert.ok(placement, 'Supply the real unoccupied Moritani starting territory');
return { actor: decision.player, action: { type: 'decision',
territory: placement.territory, sector: placement.sector } };
}
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 === 'rihani')
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
if (decision?.kind === 'battleLosses')
return { actor: decision.player, action: { type: 'decision', choice: 0 } };
if (decision?.kind === 'battleCards')
return { actor: decision.player, action: { type: 'decision',
discard: policy.winnerDiscard === 'none' ? [] : [...decision.cards] } };
if (decision?.kind === 'captureOffer')
return { actor: decision.player,
action: { type: 'decision', accept: policy.capture?.accept ?? false } };
if (decision?.kind === 'capturedLeader')
return { actor: decision.player,
action: { type: 'decision', mode: policy.capture?.captured ?? 'keep' } };
if (decision?.kind === 'moritaniPlacement')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision?.kind === 'moritaniSpecialKarama')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'moritaniAssassinate' || decision?.kind === 'harassWithdraw' ||
decision?.kind === 'sukRescue' || decision?.kind === 'handExchange') return null;
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceMoritaniIndependentSkills(
state: Game, until: (game: Game) => boolean,
actions: MoritaniIndependentSkillsStep[] = [], policy: MoritaniIndependentSkillsPolicy = {},
): Game {
let game = structuredClone(state);
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextMoritaniIndependentSkillsStep(game, policy);
assert.ok(step, `Supply the original human choice at ${game.phase}/${game.decision?.kind}`);
actions.push(structuredClone(step));
game = stepMoritaniIndependentSkills(game, step);
}
throw Error('The standalone Moritani programme did not reach its requested boundary');
}
const counterTotal = (forces: Record<string, number>) =>
Object.values(forces).reduce((sum, count) => sum + count, 0);
/** Conserved physical custody: counters, the real 33/36-card inventory, all
* fourteen skills and every Traitor identity, including Assassinate removal. */
export function assertMoritaniIndependentSkillsCustody(game: Game): void {
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
const removed = (game.moritaniAssassinate?.opportunities ?? [])
.filter((receipt) => receipt.stage === 'replaced').map((receipt) => receipt.card!);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + counterTotal(player.forces), 20, player.faction);
assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
.every((count) => Number.isSafeInteger(count) && count >= 0), player.faction);
if (player.elites) assert.equal(
player.elites.reserves + player.elites.tanks + counterTotal(player.elites.forces),
player.faction === 'emperor' ? 5 : 3, player.faction);
assert.ok(player.hand.length <= handLimit(player), player.faction);
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(cards.map((card) => card.id).sort(),
[...treacheryDeck(game.expansions), ...(game.ecazTreachery ? ecazTreacheryCards() : [])]
.map((card) => card.id).sort(),
'Every physical Treachery Card must stay in one known zone.');
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap((player) => player.traitors), ...removed].sort(),
traitorDeck(game.players).sort(), 'Every physical Traitor Card must stay in one known zone.');
}
/** Conserved physical card custody; no purchase, adoption or minted identity. */
function conserveCard(game: Game, actor: string, match: (card: Card) => boolean, staging: string[]): Card {
const player = moritaniIndependentSkillsPlayer(game, actor);
const held = player.hand.find(match);
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(match));
assert.ok(source, 'The required physical card must stay in deck, discard or hand custody');
if (player.hand.length >= handLimit(player)) {
const returned = player.hand.shift()!;
game.deck.push(returned);
staging.push(`Conserved ${returned.id}: ${actor} hand → undealt deck for printed hand capacity`);
}
const card = source.cards.splice(source.cards.findIndex(match), 1)[0];
player.hand.push(card);
staging.push(`Conserved original ${card.id}: ${source.label} → ${actor} hand; not a purchase or native deal`);
return card;
}
function board(opponent: MoritaniIndependentSkillsOpponent) {
return opponent === 'guild'
? { battle: 'pasty_mesa', from: 'tueks_sietch:5', amount: 5, sectors: [5, 6] }
: { battle: 'imperial_basin', from: 'carthag:11', amount: 10, sectors: [11, 10] };
}
/** Genuine normal lobby start (no audit profile seam) for a standalone
* Moritani/classic independent three-card app with optional all-fourteen skills. */
export function createMoritaniIndependentSkillsFixture(
options: MoritaniIndependentSkillsOptions = {},
): MoritaniIndependentSkillsFixture {
const advanced = (options.rules ?? 'advanced') === 'advanced';
const skills = options.skills ?? false;
const cards = options.cards ?? false;
const opponentFaction = options.opponent ?? 'guild';
assert.ok(!skills || opponentFaction === 'guild',
'Leader Skills keep the Harkonnen native roster out of this composition.');
const plan = board(opponentFaction);
const target = opponentFaction === 'guild' ? 'guild-1' : 'harkonnen-1';
const winner = opponentFaction === 'guild' ? 'guild-0' : 'harkonnen-0';
const loser = 'moritani-4';
const heldIdentity = (options.traitor ?? 'target') === 'winner' ? winner : target;
const chosen: Record<string, LeaderSkillId> = {
moritani: 'warmaster', [opponentFaction]: 'killer-medic', emperor: 'master-of-assassins',
};
const trainedLeader: Record<string, string> = {
moritani: 'moritani-0', [opponentFaction]: target, emperor: 'emperor-0',
};
let game: Game = options.initial ? structuredClone(options.initial)
: createGame('MORITANIINDEPENDENTSKILLS', newPlayer('moritani', 'Moritani', 'moritani'), advanced, ['ecaz']);
if (!options.initial) {
joinGame(game, newPlayer(opponentFaction, opponentFaction, opponentFaction));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
}
assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, ['ecaz']);
assert.equal(game.status, 'lobby', 'Continue only a fresh lobby of this exact composition.');
assert.ok(!game.homeworlds && !game.nexusCards && !game.discoveryEnabled &&
!game.techTokens && !game.strongholdCards,
'Only the standalone E3/classic composition without other modules is accepted.');
const actions: MoritaniIndependentSkillsStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action: structuredClone(action) });
game = stepMoritaniIndependentSkills(game, { actor, action });
return game;
};
const initial = structuredClone(game);
if (skills) act('moritani', { type: 'moduleOption', module: 'leaderSkills', enabled: true });
if (cards) act('moritani', { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const player of game.players) if (!player.ready) act(player.id, { type: 'ready' });
const start: MoritaniIndependentSkillsStep = { actor: 'moritani',
action: { type: 'start', ...(advanced ? { advancedPreview: true as const } : {}) } };
actions.push(structuredClone(start));
game = entropy(() => applyAction(game, start.actor, start.action),
skills ? skillRolls([chosen.moritani, chosen[opponentFaction], chosen.emperor]) : []);
staging.push('Normal lobby start through the real module options; the Advanced opt-in is the ordinary lobby action, never a development profile.');
if (skills) staging.push('Solved only the original fourteen-card Leader Skill lottery with real Fisher-Yates draws; offers, deck and later native shuffles are untouched.');
const offered = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 200; 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 leader = moritaniIndependentSkillsPlayer(game, actor).leaders
.filter((disc) => disc.id === trainedLeader[actor] &&
view.eligibleLeaders.some((eligible) => eligible.id === disc.id))[0];
assert.ok(leader, 'The requested original leader must be eligible for this offer');
assert.ok(offer.cards.includes(chosen[actor]), 'The requested original skill must be in the real offer');
act(actor, { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id });
continue;
}
if (game.setupStage === 'traitors' || game.players.some((player) => player.traitorChoices.length)) {
const player = game.players.find((seat) => seat.traitorChoices.length)!;
const keep = player.id === 'moritani'
? (player.traitorChoices.find((id) => id.startsWith(`${opponentFaction}-`)) ?? player.traitorChoices[0])
: player.traitorChoices[0];
act(player.id, { type: 'traitor', leader: keep });
continue;
}
if (game.decision?.kind === 'moritaniSetup') {
assert.notEqual(game.storm, plan.sectors[0], 'Moritani’s first setup sector must not open inside the storm.');
act(game.decision.player, { type: 'decision', territory: plan.battle, sector: plan.sectors[0] });
continue;
}
const step = nextStrongholdFactionsNativeStep(game);
assert.ok(step, `The original setup continuation stopped at ${game.setupStage ?? game.decision?.kind}`);
act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
const moritani = 'moritani';
const opponent = game.players.find((seat) => seat.faction === opponentFaction)!.id;
const third = game.players.find((seat) => seat.faction === 'emperor')!.id;
assert.equal(moritaniIndependentSkillsPlayer(game, moritani).reserves, 14);
assert.equal(!!game.moritaniAssassinate, advanced,
'The printed Advanced assassination producer follows the real rules mode alone.');
if (skills) assert.equal(game.leaderSkills!.assignments.find((entry) => entry.owner === opponent)!.leader, target);
holdMoritaniStrongholdsTraitor(game, moritani, heldIdentity, staging);
const afterSetup = structuredClone(game);
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === moritani && moritaniIndependentSkillsClean(state), actions);
const beforeSplit = structuredClone(game);
assert.equal(moritaniIndependentSkillsPlayer(game, moritani).forces[`${plan.battle}:${plan.sectors[0]}`], 6);
const split: MoritaniIndependentSkillsStep = { actor: moritani, action: {
type: 'move', from: `${plan.battle}:${plan.sectors[0]}`, amount: 2,
territory: plan.battle, sector: plan.sectors[1] } };
assert.notEqual(game.storm, plan.sectors[1], 'The real within-territory move must not enter the storm sector.');
game = stepMoritaniIndependentSkills(game, split);
actions.push(structuredClone(split));
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === moritani && moritaniIndependentSkillsClean(state), actions);
assert.deepEqual(moritaniIndependentSkillsPlayer(game, moritani).forces,
{ [`${plan.battle}:${plan.sectors[0]}`]: 4, [`${plan.battle}:${plan.sectors[1]}`]: 2 });
staging.push('Moritani split two real counters into a second sector of the same territory, so the printed Harass & Withdraw return is physically ambiguous.');
const afterSplit = structuredClone(game);
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === opponent && moritaniIndependentSkillsClean(state), actions);
const entrySector = plan.sectors[1];
assert.notEqual(game.storm, entrySector, 'The real opponent entry must not enter the storm sector.');
const entry: MoritaniIndependentSkillsStep = { actor: opponent, action: {
type: 'move', from: plan.from, amount: plan.amount, territory: plan.battle, sector: entrySector } };
game = stepMoritaniIndependentSkills(game, entry);
actions.push(structuredClone(entry));
const afterEntry = structuredClone(game);
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 6 && !state.battle && moritaniIndependentSkillsClean(state), actions);
const beforeBattle = structuredClone(game);
const offeredBattle = viewGame(game, game.active!).battleChoices
.find((choice) => choice.territory === plan.battle);
assert.ok(offeredBattle, 'The real co-occupation must produce a native battle choice');
const battleStep: MoritaniIndependentSkillsStep = { actor: offeredBattle.chooser, action: {
type: 'chooseBattle', territory: plan.battle,
target: offeredBattle.attacker === offeredBattle.chooser ? offeredBattle.defender : offeredBattle.attacker } };
game = stepMoritaniIndependentSkills(game, battleStep);
actions.push(structuredClone(battleStep));
game = advanceMoritaniIndependentSkills(game,
(state) => !!state.battle && !state.battle.revealed && moritaniIndependentSkillsClean(state), actions);
const reinforcements = cards
? conserveCard(game, moritani, (card) => card.id === REINFORCEMENTS_CARD, staging) : null;
const harass = cards
? conserveCard(game, moritani, (card) => card.id === HARASS_WITHDRAW_CARD, staging) : null;
const winnerWeapon = conserveCard(game, opponent, (card) => card.kind === 'worthless', staging);
const plans: MoritaniIndependentSkillsStep[] = [
{ actor: moritani, action: { type: 'battlePlan', dial: 2, support: 0, leader: loser,
...(reinforcements && harass ? { weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } : {}) } },
{ actor: opponent, action: { type: 'battlePlan', dial: cards ? 1 : 0, support: 0,
leader: winner, weapon: winnerWeapon.id } },
];
const window = structuredClone(game);
let revealed = structuredClone(game);
for (const plan of plans) {
revealed = stepMoritaniIndependentSkills(revealed, plan);
actions.push(structuredClone(plan));
}
assert.ok(revealed.battle?.revealed);
const footer = (viewGame(revealed, moritani).battle as {
harassAllocation?: { context: HarassWithdrawContext; dial: number; support: number };
} | null)?.harassAllocation ?? null;
assert.ok(!cards || footer, 'The committed Reinforcements fee plus Harass return must be a real physical choice');
const returns = footer ? defaultHarassWithdrawAllocation(footer.context, footer.dial, footer.support) : null;
const fixture: MoritaniIndependentSkillsFixture = {
initial, offered, afterSetup, beforeSplit, split, afterSplit, entry, afterEntry, beforeBattle,
battleStep, game: window, plans, revealed, returns, returnsContext: footer?.context ?? null,
pending: structuredClone(revealed), moritani, opponent, third, territory: plan.battle,
first: `${plan.battle}:${plan.sectors[0]}`, second: `${plan.battle}:${plan.sectors[1]}`,
winnerLeader: winner, loserLeader: loser, traitorTarget: target, heldTraitor: heldIdentity, chosen,
cards: { reinforcements: reinforcements?.id ?? null, harass: harass?.id ?? null, winnerWeapon: winnerWeapon.id },
actions, staging,
};
return { ...fixture, pending: resolveMoritaniIndependentSkillsBattle(fixture, revealed) };
}
/** The real revealed quote of both sealed plans and the typed return. The
* shared quote helper needs the all-fourteen skills composition. */
export function quoteMoritaniIndependentSkillsBattle(
fixture: MoritaniIndependentSkillsFixture, state = fixture.revealed,
) {
assert.ok(state.leaderSkills,
'The shared revealed-battle quote needs the all-fourteen Leader Skills composition.');
return quoteIndependentSkillsBattle(state, fixture.returns ? { returns: fixture.returns } : {});
}
/** Resolve the real aftermath up to the owner's Assassinate choice (Advanced)
* or the clean post-battle continuation (Basic), never past a human choice. */
export function resolveMoritaniIndependentSkillsBattle(
fixture: MoritaniIndependentSkillsFixture, state = fixture.pending,
policy: MoritaniIndependentSkillsPolicy = {},
): Game {
const context = fixture.returnsContext;
const chosenReturns = (typeof policy.harassReturns === 'function' && context
? policy.harassReturns(context)
: policy.harassReturns as HarassWithdrawSelection | undefined) ?? fixture.returns;
let game = structuredClone(state);
for (let count = 0; count < 500; count++) {
if (game.decision?.kind === 'moritaniAssassinate' || game.decision?.kind === 'handExchange') return game;
if ((game.decision?.kind === 'captureOffer' || game.decision?.kind === 'capturedLeader') && !policy.capture)
return game;
if (!game.battle && moritaniIndependentSkillsClean(game) &&
(game.phase === 7 || game.phase === 8)) return game;
assert.notEqual(game.status, 'finished');
let step: MoritaniIndependentSkillsStep | null;
if (game.decision?.kind === 'harassWithdraw') {
assert.ok(chosenReturns, 'Supply the loser’s real typed undialed return');
step = { actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, returns: chosenReturns } };
} else if (game.battle?.revealed && (viewGame(game, game.host).battle?.traitorVoters ?? [])
.some((voter) => game.battle!.traitorCalls[voter] === undefined)) {
const voter = viewGame(game, game.host).battle!.traitorVoters
.find((id) => game.battle!.traitorCalls[id] === undefined)!;
step = { actor: voter,
action: { type: 'traitorCall', call: policy.traitorCalls?.includes(voter) ?? false } };
} else step = nextMoritaniIndependentSkillsStep(game, policy);
assert.ok(step, 'Resolve the real inherited decisions before the owner’s choice');
game = stepMoritaniIndependentSkills(game, step);
}
throw Error('The original standalone battle aftermath did not settle');
}
/** Apply the real owner choice: one held eligible Traitor Card, or null. */
export function chooseMoritaniIndependentSkillsAssassination(state: Game, card: string | null): Game {
const decision = state.decision;
assert.equal(decision?.kind, 'moritaniAssassinate');
if (decision?.kind !== 'moritaniAssassinate') throw new Error('Expected the original Assassinate choice.');
return stepMoritaniIndependentSkills(state, { actor: decision.player, action: {
type: 'decision', event: decision.event,
...(card === null ? { decline: true } : { card }),
} });
}
/** Continue the settled fight through the real Mentat replacement window. */
export function finishMoritaniIndependentSkillsBattle(
state: Game, policy: MoritaniIndependentSkillsPolicy = {},
until: (game: Game) => boolean = (game) => game.phase === 8 && moritaniIndependentSkillsClean(game),
): Game {
return advanceMoritaniIndependentSkills(state, until, [], policy);
}
@@ -14,7 +14,7 @@ export type NativeFamilyReinforcementsStep = { actor: string; action: Action };
export type NativeFamilyReinforcementsOptions = {
faction?: 'ixians' | 'tleilaxu' | 'choam';
advanced?: boolean;
expansions?: ('ix' | 'choam')[];
expansions?: ('ix' | 'choam' | 'ecaz')[];
slot?: 'weapon' | 'defense';
outcome?: 'winner' | 'loser' | 'traitor' | 'explosion';
/** Nine legally paid suboids leave one suboid and four cyborgs in reserve. */
@@ -98,7 +98,7 @@ export function createNativeFamilyReinforcementsFixture(
): NativeFamilyReinforcementsFixture {
const faction = options.faction ?? 'ixians', advanced = options.advanced ?? options.initial?.advanced ?? false;
const expansions = options.expansions ?? options.initial?.expansions ?? [faction === 'choam' ? 'choam' : 'ix'];
assert.ok(expansions.length > 0 && expansions.every(id => id === 'ix' || id === 'choam'));
assert.ok(expansions.length > 0 && expansions.every(id => id === 'ix' || id === 'choam' || id === 'ecaz'));
assert.equal(new Set(expansions).size, expansions.length);
assert.ok(expansions.includes(faction === 'choam' ? 'choam' : 'ix'));
assert.ok(!options.mixedFee || faction === 'ixians');
+6 -2
View File
@@ -3,7 +3,6 @@ import { mock } from 'node:test';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
viewGame,
@@ -31,12 +30,14 @@ export function takeMentatCard(game: Game, player: string, name: string) {
export function mentatQuestionGame({
advanced = false,
choam = false,
independent = false,
cards = ['Crysknife', 'Snooper'],
begin = true,
captured = false,
}: {
advanced?: boolean;
choam?: boolean;
independent?: boolean;
cards?: string[];
begin?: boolean;
captured?: boolean;
@@ -53,6 +54,9 @@ export function mentatQuestionGame({
newPlayer('d', 'Questioned opponent', captured ? 'harkonnen' : 'atreides'),
);
joinGame(game, newPlayer('o', 'Observer', 'guild'));
game = applyAction(game, game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
if (independent)
game = applyAction(game, game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
const index = LEADER_SKILL_CARDS.findIndex((card) => card.id === 'mentat');
@@ -62,7 +66,7 @@ export function mentatQuestionGame({
return array;
});
try {
game = initializeLeaderSkillsGameForAudit(game);
game = applyAction(game, game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
} finally {
mock.restoreAll();
}
+40 -4
View File
@@ -13,6 +13,7 @@ import {
quoteMentatReveal,
} from '../game/mentat-question';
import { baseDeck, ixDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import {
finishMentatPosture,
mentatPlayer,
@@ -100,13 +101,48 @@ void test('Mentat names exact printed/default weapons and validates physical han
]) {
assert.ok(!choam.includes(name));
assert.ok(!ix.includes(name));
assert.throws(() => quoteMentatReveal(baseDeck().slice(0, 1), name, []));
assert.throws(() => quoteMentatReveal(baseDeck().slice(0, 1), name, [], false));
}
assert.throws(() => mentatHand([baseDeck()[0], baseDeck()[0]], []));
assert.throws(() => mentatHand([{ ...baseDeck()[0], name: 'Lasgun' }], []));
assert.throws(() => mentatHand(ixDeck().slice(0, 1), []));
assert.throws(() => mentatHand([baseDeck()[0], baseDeck()[0]], [], false));
assert.throws(() => mentatHand([{ ...baseDeck()[0], name: 'Lasgun' }], [], false));
assert.throws(() => mentatHand(ixDeck().slice(0, 1), [], false));
});
void test('Mentat accepts selected independent Specials as fallback disclosures, never as named weapons or unselected cards', () => {
const specials = ecazTreacheryCards();
const names = mentatWeaponNames(['ecaz']);
for (const card of specials) {
assert.ok(!names.includes(card.name));
assert.deepEqual(quoteMentatReveal([card], 'Lasgun', ['ecaz'], true).cards, [card]);
assert.throws(() => quoteMentatReveal([card], 'Lasgun', ['ecaz'], false));
}
});
for (const hide of [false, true])
void test(`independent-card Mentat disclosure keeps physical custody and resumes ${hide ? 'trained' : 'normal'} posture`, () => {
let game = mentatQuestionGame({ independent: true, cards: ecazTreacheryCards().map(card => card.name) });
const before = resources(game);
game = nameWeapon(game, 'Lasgun');
const pending = viewGame(game, 'd').mentat.pending!;
assert.deepEqual(pending.cards.map(card => card.id), ecazTreacheryCards().map(card => card.id));
assert.deepEqual(viewGame(game, 'a').mentat.pending!.cards, []);
assert.deepEqual(viewGame(game, 'o').mentat.pending!.cards, []);
const shown = pending.cards.find(card => card.effect === 'harassWithdraw')!;
game = applyAction(game, 'd', { type: 'decision', event: pending.event, card: shown.id });
assert.deepEqual(resources(game), before, 'Showing a card neither transfers nor reserves it');
assert.deepEqual(viewGame(game, 'a').mentat.history[0].card, shown);
assert.deepEqual(viewGame(game, 'o').mentat.history, []);
game = finishMentatPosture(game, hide);
assert.equal(game.battle!.leaderSkillHidden!.a, hide);
game = finishPowers(game);
game = applyAction(game, 'a', {
type: 'battlePlan', dial: 0, leader: hide ? 'emperor-0' : 'emperor-1',
});
assert.equal(game.battle!.plans.a.leader, hide ? 'emperor-0' : 'emperor-1');
assert.equal(game.battle!.plans.d, undefined, 'The other combatant still owns its unsealed plan');
assert.ok(mentatPlayer(game, 'd').hand.some(card => card.id === shown.id));
});
void test('fresh supported Leader Skills battles offer a private Mentat question without a separate opt-in', () => {
for (const advanced of [false, true])
for (const choam of [false, true]) {
+1 -9
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {applyAction,viewGame,initializeMoritaniAssassinateGameForAudit,createGame,newPlayer,joinGame,type Game} from '../game/engine';
import {applyAction,viewGame,type Game} from '../game/engine';
import {DIFFICULTIES} from '../game/bot-profiles';
import {assassinationGame,stageAssassinationBattle,prepareAssassinationBattle,resolveAssassinationBattle,assassinationChoice,assassinationToMentat,assassinationPhysical,assassinationRejects,assassinationActions} from './moritani-assassinate-fixture';
import {advanceToNextStorm,finishToMovement} from './fixture-advanced-source';
@@ -122,14 +122,6 @@ void test('saved assassination receipts and cleanup obligations fail closed afte
assert.throws(()=>viewGame(missing,'m'));
});
void test('the explicit preview refuses Basic and exceptional-faction lobbies without mutation',()=>{
for(const [advanced,opponent] of [[false,'guild'],[true,'harkonnen'],[true,'ecaz']] as const){
const g=createGame('ASSAINIT',newPlayer('m','Moritani','moritani'),advanced,['ecaz']);
joinGame(g,newPlayer('x','Opponent',opponent));for(const p of g.players)p.ready=true;
const before=structuredClone(g);assert.throws(()=>initializeMoritaniAssassinateGameForAudit(g));assert.deepEqual(g,before);
}
});
void test('a sealed normal traitor call does not publish the assassination guard before the other voter replies',()=>{
const prepared=prepareAssassinationBattle(stageAssassinationBattle(assassinationGame(),'guild-0'));
const called=applyAction(prepared,'m',{type:'traitorCall',call:true});
@@ -0,0 +1,343 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import {
advanceMoritaniIndependentSkills,
assertMoritaniIndependentSkillsCustody,
chooseMoritaniIndependentSkillsAssassination,
createMoritaniIndependentSkillsFixture,
finishMoritaniIndependentSkillsBattle,
moritaniIndependentSkillsClean,
moritaniIndependentSkillsPlayer,
quoteMoritaniIndependentSkillsBattle,
resolveMoritaniIndependentSkillsBattle,
stepMoritaniIndependentSkills,
type MoritaniIndependentSkillsFixture,
} from './fixture-moritani-independent-skills';
const player = moritaniIndependentSkillsPlayer;
const reject = (game: Game, actor: string, action: Action) => {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
};
const owned = (fixture: MoritaniIndependentSkillsFixture, game: Game, identity: string) =>
player(game, fixture.opponent).leaders.find((leader) => leader.id === identity)!;
const choiceOf = (game: Game, viewer: string) =>
viewGame(game, viewer).moritaniAssassinate!.pending!;
void test('Advanced standalone Moritani keeps the printed Assassinate power through the real lobby, the real loss and one held unused Traitor', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true, skills: true });
const moritani = player(f.afterSetup, f.moritani), guild = player(f.afterSetup, f.opponent);
// The producer is the ordinary fresh roster, never the separate audit profile.
assert.equal(f.afterSetup.moritaniAssassinatePreview, true);
assert.equal(f.afterSetup.moritaniAssassinate!.owner, f.moritani);
assert.deepEqual(f.afterSetup.moritaniAssassinate!.opportunities, []);
assert.equal(f.afterSetup.moritaniAssassinate!.normalTraitorCall, false);
assert.deepEqual(f.afterSetup.moritaniAssassinateCallEvents, []);
assert.deepEqual(f.afterSetup.expansions, ['ecaz']);
assert.equal(f.afterSetup.ecazTreachery, true);
assert.equal(f.afterSetup.homeworlds, undefined);
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 3);
assert.equal(moritani.spice, 12);
assert.equal(moritani.reserves, 14);
assert.equal(moritani.tanks, 0);
assert.deepEqual(moritani.forces, { [f.first]: 6 });
assert.equal(guild.reserves, 15);
assert.deepEqual(guild.forces, { 'tueks_sietch:5': 5 });
assertMoritaniIndependentSkillsCustody(f.afterSetup);
// The real revealed battle: Moritani's own physical Reinforcements/Harass loss.
const battle = f.revealed.battle!;
assert.equal(battle.territory, f.territory);
assert.equal(battle.plans[f.moritani].weapon, REINFORCEMENTS_CARD);
assert.equal(battle.plans[f.moritani].defense, HARASS_WITHDRAW_CARD);
assert.equal(battle.plans[f.moritani].leader, f.loserLeader);
assert.equal(battle.plans[f.opponent].leader, f.winnerLeader);
assert.equal(f.returnsContext!.locations[f.first].normal, 4);
assert.equal(f.returnsContext!.locations[f.second].normal, 2);
assert.deepEqual(f.returns, { [f.first]: { normal: 2, elite: 0 } });
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.equal(f.pending.lastBattleContext!.territory, f.territory);
// Real fee, real casualties and real returns, before the owner's choice.
const held = player(f.pending, f.moritani), rival = player(f.pending, f.opponent);
assert.equal(held.spice, 12);
assert.equal(held.reserves, 13);
assert.equal(held.tanks, 7);
assert.deepEqual(held.forces, {});
assert.equal(rival.spice, 5);
assert.equal(rival.reserves, 15);
assert.equal(rival.tanks, 2);
assert.deepEqual(rival.forces, { [f.second]: 3 });
assert.deepEqual(held.traitors, [f.traitorTarget]);
assert.equal(owned(f, f.pending, f.traitorTarget).dead, false);
assert.equal(owned(f, f.pending, f.winnerLeader).dead, false);
// The owner's real, private choice: one conserved unused Traitor identity.
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
assert.equal(f.pending.decision?.player, f.moritani);
const decision = f.pending.decision;
if (decision?.kind !== 'moritaniAssassinate') throw new Error('Expected the original Assassinate choice.');
const event = decision.event;
const choice = choiceOf(f.pending, f.moritani);
assert.equal(choice.opponent, f.opponent);
assert.equal(choice.territory, f.territory);
assert.equal(choice.blocked, null);
assert.deepEqual(choice.cards, [{ card: f.traitorTarget,
name: owned(f, f.pending, f.traitorTarget).name,
bounty: owned(f, f.pending, f.traitorTarget).strength, dead: false }]);
assert.equal(choice.cards.some((card) => card.card === f.winnerLeader), false);
assert.deepEqual(choiceOf(f.pending, f.opponent).cards, []);
assert.deepEqual(choiceOf(f.pending, f.third).cards, []);
reject(f.pending, f.opponent, { type: 'decision', event, card: f.traitorTarget });
reject(f.pending, f.moritani, { type: 'decision', event: 'stale', card: f.traitorTarget });
reject(f.pending, f.moritani, { type: 'decision', event, card: f.winnerLeader });
reject(f.pending, f.moritani, { type: 'decision', event, card: 'guild-2' });
reject(f.pending, f.moritani, { type: 'decision', event, decline: true, card: f.traitorTarget });
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
const receipt = spent.moritaniAssassinate!.opportunities.at(-1)!;
const target = owned(f, spent, f.traitorTarget);
assert.equal(target.dead, true);
assert.equal(target.deaths, 1);
assert.equal(target.usedAt, undefined);
assert.equal(receipt.stage, 'revealed');
assert.equal(receipt.card, f.traitorTarget);
assert.equal(receipt.opposingLeader, f.winnerLeader);
assert.equal(receipt.bounty, owned(f, f.pending, f.traitorTarget).strength);
assert.equal(receipt.replacement, null);
assert.equal(player(spent, f.moritani).spice, 12 + receipt.bounty);
assert.equal(player(spent, f.opponent).spice, 5);
// Ordered leader-skill custody: the dead leader's card returns once.
assert.equal(spent.leaderSkills!.deck.filter((id) => id === 'killer-medic').length, 1);
assert.deepEqual(spent.leaderSkills!.assignments, [
{ owner: f.moritani, leader: 'moritani-0', skill: f.chosen.moritani },
{ owner: f.third, leader: 'emperor-0', skill: f.chosen.emperor },
]);
assert.deepEqual(player(spent, f.moritani).traitors, [f.traitorTarget]);
// The original winner-card cleanup is preserved, not erased by the choice.
assert.equal(spent.decision?.kind, 'battleCards');
assert.equal(spent.decision?.player, f.opponent);
assert.equal(player(spent, f.opponent).hand.some((card) => card.id === f.cards.winnerWeapon), true);
assert.equal(spent.discard.filter((card) => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(spent.discard.filter((card) => card.id === HARASS_WITHDRAW_CARD).length, 1);
assert.equal(spent.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 0);
assertMoritaniIndependentSkillsCustody(spent);
const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(player(settled, f.opponent).hand.some((card) => card.id === f.cards.winnerWeapon), false);
assert.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
const replacement = settled.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(replacement.stage, 'replaced');
assert.ok(replacement.replacement && replacement.replacement !== f.traitorTarget);
assert.deepEqual(player(settled, f.moritani).traitors, [replacement.replacement]);
assert.equal(settled.traitorReserve!.includes(replacement.replacement!), false);
assert.equal(settled.players.flatMap((seat) => seat.traitors).includes(f.traitorTarget), false);
assert.equal(player(settled, f.moritani).spice, 12 + replacement.bounty);
assert.equal(settled.leaderSkills!.deck.filter((id) => id === 'killer-medic').length, 1);
assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1);
assert.equal(settled.phase, 8);
assert.equal(viewGame(settled, f.opponent).moritaniAssassinate!.history.at(-1)!.card, f.traitorTarget);
assert.equal('replacement' in viewGame(settled, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
assertMoritaniIndependentSkillsCustody(settled);
// Real quote of the revealed plans, including the committed specials.
const quote = quoteMoritaniIndependentSkillsBattle(f);
assert.equal(quote.result, 'normal');
assert.equal(quote.winner, f.opponent);
assert.deepEqual(quote.reinforcements, [{ player: f.moritani, card: REINFORCEMENTS_CARD,
normal: 3, elite: 0 }]);
assert.equal(quote.harassWithdraw!.length, 1);
assert.equal(quote.harassWithdraw![0].player, f.moritani);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.deepEqual(quote.harassWithdraw![0].remaining, {
normal: 4, normalFixedHalf: false, elite: 0, eliteStrength: 2, freeSupport: false });
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.first]: { normal: 2, elite: 0 } });
});
void test('Advanced standalone Moritani keeps the printed power without Leader Skills, and a real decline changes nothing', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true });
assert.equal(f.afterSetup.leaderSkills, undefined);
assert.equal(f.afterSetup.moritaniAssassinatePreview, true);
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
const choice = choiceOf(f.pending, f.moritani);
assert.deepEqual(choice.cards.map((card) => card.card), [f.traitorTarget]);
assert.equal(choice.cards[0].bounty, owned(f, f.pending, f.traitorTarget).strength);
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
const target = owned(f, spent, f.traitorTarget);
assert.equal(target.dead, true);
assert.equal(player(spent, f.moritani).spice, 12 + spent.moritaniAssassinate!.opportunities.at(-1)!.bounty);
const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(settled.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(settled.leaderSkills, undefined);
assert.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
assertMoritaniIndependentSkillsCustody(settled);
// A second identical lobby: declining is a real, state-preserving choice.
const declined = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true });
const before = structuredClone(declined.pending);
const after = chooseMoritaniIndependentSkillsAssassination(declined.pending, null);
const kept = owned(declined, after, declined.traitorTarget);
assert.equal(kept.dead, false);
assert.equal(kept.deaths, 0);
assert.equal(after.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assert.equal(player(after, declined.moritani).spice, player(before, declined.moritani).spice);
assert.deepEqual(player(after, declined.moritani).traitors, [declined.traitorTarget]);
assert.deepEqual(player(after, declined.moritani).leaders, player(before, declined.moritani).leaders);
const ended = finishMoritaniIndependentSkillsBattle(after, { winnerDiscard: 'played' });
assert.deepEqual(ended.moritaniAssassinate!.opportunities, after.moritaniAssassinate!.opportunities);
assert.equal(ended.traitorReserve!.length, declined.pending.traitorReserve!.length);
assert.equal(viewGame(ended, declined.moritani).moritaniAssassinate!.history.length, 0);
assertMoritaniIndependentSkillsCustody(ended);
});
void test('a no-card no-Skills normal start still produces the Advanced assassination, including the native Harkonnen roster', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: false, opponent: 'harkonnen' });
const harkonnen = player(f.afterSetup, f.opponent);
// The plain 33-card classic inventory: no independent card is staged or played.
assert.equal(f.afterSetup.ecazTreachery, undefined);
assert.equal(f.cards.reinforcements, null);
assert.equal(f.cards.harass, null);
const cards = [...f.afterSetup.deck, ...f.afterSetup.discard,
...f.afterSetup.players.flatMap((seat) => seat.hand)];
assert.equal(cards.length, 33);
assert.equal(f.returns, null);
// The Harkonnen native roster: four dealt Traitor Cards and a real capture.
assert.equal(harkonnen.traitors.length, 4);
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
const assassination = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
assert.equal(owned(f, assassination, f.traitorTarget).dead, true);
assert.equal(assassination.moritaniAssassinate!.opportunities.at(-1)!.bounty,
owned(f, f.pending, f.traitorTarget).strength);
const offer = resolveMoritaniIndependentSkillsBattle(f, assassination, { capture: { accept: true, captured: 'keep' } });
const settled = finishMoritaniIndependentSkillsBattle(offer,
{ winnerDiscard: 'played', capture: { accept: true, captured: 'keep' } });
const captives = settled.players.flatMap((seat) => seat.leaders)
.filter((leader) => leader.capturedBy === f.opponent);
assert.equal(captives.length, 1);
assert.equal(captives[0].faction, 'moritani');
assert.equal(captives[0].dead, false);
assert.equal(settled.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(player(settled, f.moritani).tanks, 6);
assert.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
assertMoritaniIndependentSkillsCustody(settled);
});
void test('Basic independent Moritani resolves the same real Reinforcements/Harass loss without the Advanced assassination', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'basic', cards: true, skills: true });
assert.equal(f.afterSetup.moritaniAssassinate, undefined);
assert.equal(f.afterSetup.moritaniAssassinatePreview, undefined);
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 3);
const battle = f.revealed.battle!;
assert.equal(battle.plans[f.moritani].weapon, REINFORCEMENTS_CARD);
assert.equal(battle.plans[f.moritani].defense, HARASS_WITHDRAW_CARD);
assert.deepEqual(f.returns, { [f.first]: { normal: 4, elite: 0 } });
assert.ok(moritaniIndependentSkillsClean(f.pending));
assert.equal(f.pending.phase, 7);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.pending.moritaniAssassinate, undefined);
const held = player(f.pending, f.moritani), rival = player(f.pending, f.opponent);
assert.equal(held.spice, 12);
assert.equal(held.reserves, 15);
assert.equal(held.tanks, 5);
assert.deepEqual(held.forces, {});
assert.deepEqual(held.traitors, [f.traitorTarget]);
assert.equal(rival.tanks, 1);
assert.deepEqual(rival.forces, { [f.second]: 4 });
assert.equal(owned(f, f.pending, f.traitorTarget).dead, false);
assert.equal(owned(f, f.pending, f.winnerLeader).dead, false);
assert.equal(f.pending.discard.filter((card) => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(f.pending.discard.filter((card) => card.id === HARASS_WITHDRAW_CARD).length, 1);
assert.equal(f.pending.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
const settings = structuredClone(f.pending.leaderSkills);
const settled = finishMoritaniIndependentSkillsBattle(f.pending);
assert.deepEqual(settled.leaderSkills, settings);
assert.deepEqual(player(settled, f.moritani).traitors, [f.traitorTarget]);
assertMoritaniIndependentSkillsCustody(settled);
const plain = createMoritaniIndependentSkillsFixture({ rules: 'basic', cards: false, opponent: 'harkonnen' });
assert.equal(plain.afterSetup.moritaniAssassinate, undefined);
assert.equal(plain.pending.phase, 7);
assert.equal(plain.pending.moritaniAssassinate, undefined);
assert.deepEqual(player(plain.pending, plain.moritani).traitors, [plain.traitorTarget]);
assert.equal(player(plain.pending, plain.moritani).tanks, 6);
assert.equal(owned(plain, plain.pending, plain.traitorTarget).dead, false);
assertMoritaniIndependentSkillsCustody(plain.pending);
const variant = createMoritaniIndependentSkillsFixture({ rules: 'basic', cards: true });
assert.equal(variant.afterSetup.moritaniAssassinate, undefined);
assert.equal(variant.afterSetup.ecazTreachery, true);
assert.equal(variant.pending.phase, 7);
assert.equal(variant.pending.moritaniAssassinate, undefined);
assert.equal(variant.pending.lastBattleContext!.winner, variant.opponent);
assert.deepEqual(player(variant.pending, variant.moritani).traitors, [variant.traitorTarget]);
assert.equal(owned(variant, variant.pending, variant.traitorTarget).dead, false);
assert.equal(variant.pending.discard.filter((card) => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(variant.pending.discard.filter((card) => card.id === HARASS_WITHDRAW_CARD).length, 1);
assertMoritaniIndependentSkillsCustody(variant.pending);
});
void test('a real normal Traitor call forfeits the printed power for the game and the used faction is never offered twice', () => {
const called = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true, traitor: 'winner' });
assert.equal(called.heldTraitor, called.winnerLeader);
assert.deepEqual(choiceOf(called.pending, called.moritani).cards, []);
const played = resolveMoritaniIndependentSkillsBattle(called, called.revealed,
{ traitorCalls: [called.moritani], winnerDiscard: 'played' });
assert.equal(played.lastBattleContext!.result, 'traitor');
assert.equal(played.lastBattleContext!.winner, called.moritani);
assert.equal(played.moritaniAssassinate!.normalTraitorCall, true);
assert.deepEqual(played.moritaniAssassinate!.opportunities, []);
assert.equal(played.moritaniAssassinateCallEvents!.includes(played.lastBattleContext!.event), true);
assert.equal(player(played, called.moritani).spice, 12 + owned(called, played, called.winnerLeader).strength);
assert.deepEqual(player(played, called.moritani).revealedTraitors, [called.winnerLeader]);
assert.ok((viewGame(played, called.moritani).moritaniAssassinate!.blocked ?? '').includes('forfeited'));
assert.equal(played.decision?.kind, undefined);
assertMoritaniIndependentSkillsCustody(played);
// The original once-per-faction rule survives a second real battle.
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true });
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
let game = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(game.turn, 1);
const actions: { actor: string; action: Action }[] = [];
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === f.moritani && moritaniIndependentSkillsClean(state), actions);
assert.equal(player(game, f.moritani).spice, 15);
game = stepMoritaniIndependentSkills(game,
{ actor: f.moritani, action: { type: 'ship', territory: f.territory, sector: Number(f.second.split(':')[1]),
amount: 4, elite: 0, allyPayment: 0 } });
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === f.moritani && moritaniIndependentSkillsClean(state) && !state.pendingShipment,
actions);
assert.deepEqual(player(game, f.moritani).forces, { [f.second]: 4 });
game = stepMoritaniIndependentSkills(game, { actor: f.moritani, action: { type: 'endMovement' } });
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 6 && !state.battle && moritaniIndependentSkillsClean(state), actions);
const offered = viewGame(game, game.active!).battleChoices.find((entry) => entry.territory === f.territory);
assert.ok(offered);
game = stepMoritaniIndependentSkills(game, { actor: offered!.chooser, action: { type: 'chooseBattle',
territory: f.territory, target: offered!.attacker === offered!.chooser ? offered!.defender : offered!.attacker } });
game = advanceMoritaniIndependentSkills(game,
(state) => !!state.battle && !state.battle.revealed && moritaniIndependentSkillsClean(state), actions);
game = stepMoritaniIndependentSkills(game, { actor: f.moritani, action: {
type: 'battlePlan', dial: 2, support: 0, leader: f.loserLeader } });
game = stepMoritaniIndependentSkills(game, { actor: f.opponent, action: {
type: 'battlePlan', dial: 0, support: 0, leader: f.winnerLeader } });
const second = resolveMoritaniIndependentSkillsBattle(f, game, { winnerDiscard: 'played' });
assert.equal(second.lastBattleContext!.result, 'normal');
assert.equal(second.lastBattleContext!.winner, f.opponent);
assert.equal(second.moritaniAssassinate!.opportunities.length, 1);
assert.equal(second.moritaniAssassinate!.opportunities[0].stage, 'replaced');
assert.notEqual(second.decision?.kind, 'moritaniAssassinate');
assert.equal(viewGame(second, f.moritani).moritaniAssassinate!.pending, null);
assert.equal(viewGame(second, f.moritani).moritaniAssassinate!.history.length, 1);
assert.equal(player(second, f.moritani).spice, 7);
assert.equal(owned(f, second, f.traitorTarget).dead, true);
assertMoritaniIndependentSkillsCustody(second);
});
@@ -45,7 +45,6 @@ void test('Moritani skill starts retain immutable Ecaz and optional-module rejec
(g: Game) => { g.expansions.push('choam'); },
(g: Game) => { g.discoveryEnabled = true; g.expansions.push('choam'); },
(g: Game) => { g.discoveryEnabled = true; g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
(g: Game) => { g.ecazTreachery = true; },
]) {
const lobby = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
joinGame(lobby, newPlayer('a', 'Atreides', 'atreides'));
+2 -2
View File
@@ -122,9 +122,9 @@ for (const advanced of [false, true]) {
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} selected Ix+CHOAM: nine paid suboids leave a real one-suboid/two-cyborg fee`, () => {
void test(`${advanced ? 'Advanced' : 'Basic'} selected Ix+CHOAM+Ecaz cards: nine paid suboids leave a real one-suboid/two-cyborg fee`, () => {
const f = createNativeFamilyReinforcementsFixture({ advanced, faction: 'ixians', mixedFee: true,
expansions: ['ix', 'choam'], slot: advanced ? 'weapon' : 'defense' });
expansions: ['ix', 'choam', 'ecaz'], slot: advanced ? 'weapon' : 'defense' });
const own = player(f.game, f.owner);
assert.equal(own.reserves, 5);
assert.equal(own.elites!.reserves, 4);