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:
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user