Compose the deferred Terror arrival and green the mixed E3 programmes
Queue a committed Terror entry whose arrival also opened its own
interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor
choice, or Fremen storm protection — in the additive
Game.pendingArrivalReaction list, and open the oldest queued entry from
settleAutomaticContinuations once nothing is pending. Payment, force
transfer and the original movement never replay; a Terror entry already in
flight and every Ambassador combination still reject.
Both withdrawn mixed E3 programme pairs are green: Moritani 9/9 and Ecaz
14/14, stable across 20 consecutive full-file runs. Their recorded resume
constraints became real fixture repairs, and the two restored consumer
omissions were corrected against the engine rather than re-pinned.
Repair pre-existing failures inherited at 45e6084 against current engine
behavior: stale Discovery and module gates in the native skills, Ix and
expansion-faction prototypes, the advanced Moritani skill boundary, the
Richese Nexus betrayal sale-credit expectation, one incidental gate
message and one flaky Collection-readiness assertion that reproduced at
HEAD.
Offline suite 7,793/7,793 with typecheck and lint clean. No game, seat,
saved-state, deployment, public-start or played-save conversion changes.
This commit is contained in:
1 parent
45e6084424
commit
e05a205a47
30 files changed
+2467
-117
No files matched your search
@@ -364,10 +364,14 @@ void test('new native admission preserves public, Harkonnen assassination, forei
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(game));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
// Native Discovery Leader Skills are now admitted for this fresh Moritani
|
||||
// lobby; the foreign-family and combined-expansion lobbies still reject.
|
||||
const discoveryLobby = make();
|
||||
discoveryLobby.players.forEach(p => { p.ready = true; });
|
||||
discoveryLobby.discoveryEnabled = true;
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(discoveryLobby));
|
||||
const discoveryBefore = reload(discoveryLobby);
|
||||
assert.ok(initializeLeaderSkillsGameForAudit(discoveryLobby).leaderSkills);
|
||||
assert.deepEqual(discoveryLobby, discoveryBefore, 'The lobby is never mutated.');
|
||||
// Basic Moritani keeps its original Harkonnen-compatible skills profile; it
|
||||
// does not acquire the Advanced-only assassination preview.
|
||||
const basicLobby = make('harkonnen');
|
||||
|
||||
@@ -194,7 +194,7 @@ void test('bots treat the supported overlapping stronghold as a legal ordinary a
|
||||
assert.equal(seat(entered, 'in').forces['arrakeen:10'], 2);
|
||||
});
|
||||
|
||||
void test('worm-riding bots avoid Terror arrival while BG fighter intrusion is pending', () => {
|
||||
void test('worm-riding bots treat the Terror arrival beside a pending BG intrusion as a legal deferred entry', () => {
|
||||
const { g } = table('robbery');
|
||||
g.ecazAmbassadors = createAmbassadors(() => 0.2);
|
||||
const bg = newPlayer('bg', 'Bene Gesserit', 'beneGesserit');
|
||||
@@ -206,16 +206,48 @@ void test('worm-riding bots avoid Terror arrival while BG fighter intrusion is p
|
||||
g.decision = { kind: 'wormRide', player: 'in', territory: 'imperial_basin' };
|
||||
g.wormRides = ['hagga_basin'];
|
||||
const before = JSON.stringify(g);
|
||||
assert.throws(() => action(g, 'in', ride),
|
||||
/Terror combined with another arrival reaction/);
|
||||
// The ride commits; the BG fighter intrusion owns the pending interaction, so the
|
||||
// Terror entry is deferred in `pendingArrivalReaction` instead of being rejected.
|
||||
const entered = action(g, 'in', ride);
|
||||
assert.deepEqual(entered.decision, {
|
||||
kind: 'intrusion', player: 'bg', territory: 'arrakeen', wormRide: true,
|
||||
});
|
||||
assert.equal(entered.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.cause, 'wormRide');
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.entrant, 'in');
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.territory, 'arrakeen');
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.sector, 10);
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.amount, 2);
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.resume, 'wormRide');
|
||||
assert.deepEqual(entered.wormRides, ['hagga_basin']);
|
||||
assert.equal(seat(entered, 'in').forces['arrakeen:10'], 2);
|
||||
assert.equal(seat(entered, 'in').forces['imperial_basin:10'], 1);
|
||||
for (const bot of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(g, 'in');
|
||||
view.players.find(p => p.id === 'in')!.bot = bot;
|
||||
assert.equal(botArrivalBlock(view, ride), null);
|
||||
const choices = botActions(view);
|
||||
assert.ok(choices.length, bot);
|
||||
assert.equal(choices.some(choice => choice.type === 'decision' &&
|
||||
choice.accept === true && choice.territory === 'arrakeen'), false, bot);
|
||||
// The formerly rejected stronghold ride is now a legal choice bots offer.
|
||||
assert.ok(choices.some(choice => choice.type === 'decision' &&
|
||||
choice.accept === true && choice.territory === 'arrakeen'), bot);
|
||||
assert.ok(applyAction(g, 'in', choices[0]));
|
||||
}
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
// Answering the intrusion and the queued ride settles the deferral: the Terror
|
||||
// entry now opens from the stored arrival reaction.
|
||||
let state = action(g, 'in', ride);
|
||||
state = action(state, 'bg', { type: 'decision', accept: false });
|
||||
assert.deepEqual(state.decision, queuedRide);
|
||||
assert.equal(state.pendingArrivalReaction?.[0]?.cause, 'wormRide');
|
||||
assert.equal(state.pendingTerrorEntry ?? null, null);
|
||||
state = action(state, 'in', { type: 'decision', accept: false });
|
||||
assert.equal(state.pendingArrivalReaction ?? null, null);
|
||||
assert.equal(state.pendingTerrorEntry?.cause, 'wormRide');
|
||||
assert.equal(state.pendingTerrorEntry?.resume, 'wormRide');
|
||||
assert.equal(state.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(state.decision?.kind, 'moritaniTerror');
|
||||
assert.match(state.log.at(-1)!.text,
|
||||
/Moritani may reveal a Terror token after Rider entered Arrakeen/);
|
||||
assert.equal(seat(state, 'in').forces['arrakeen:10'], 2);
|
||||
});
|
||||
@@ -83,7 +83,7 @@ void test('Homeworld-sourced AI shipments avoid unsupported paired entry reactio
|
||||
assert.equal(botArrivalBlock(ownView(game), action), null);
|
||||
});
|
||||
|
||||
void test('Guild cross-planet transport avoids a Terror arrival blocked by its Guild income response', () => {
|
||||
void test('Guild cross-planet transport ships into a Terror arrival from its committed income response', () => {
|
||||
const game = shipmentArrivalGame();
|
||||
const bg = arrivalPlayer(game, 'bg');
|
||||
const guild = arrivalPlayer(game, 'guild');
|
||||
@@ -97,11 +97,22 @@ void test('Guild cross-planet transport avoids a Terror arrival blocked by its G
|
||||
game.moritaniTerror = placeTerror(terror, terror.tokens[0].id, 'carthag', 2);
|
||||
const action: Action = { type: 'guildShip', from: 'sietch_tabr:14', amount: 2,
|
||||
territory: 'carthag', sector: 11 };
|
||||
assert.throws(() => applyAction(game, bg.id, action), overlap);
|
||||
const before = JSON.stringify(game);
|
||||
// Guild income from the ally went to the bank with nothing left pending, so the
|
||||
// supported Terror entry opens in the same commit as the shipped forces.
|
||||
const shipped = applyAction(game, bg.id, action);
|
||||
const arrived = arrivalPlayer(shipped, 'bg');
|
||||
assert.deepEqual(arrived.forces, { 'carthag:11': 2 });
|
||||
assert.equal(arrived.shipped, true);
|
||||
assert.equal(shipped.decision?.kind, 'moritaniTerror');
|
||||
assert.equal(shipped.pendingTerrorEntry?.cause, 'guildTransport');
|
||||
assert.equal(shipped.pendingArrivalReaction ?? null, null);
|
||||
assert.equal(shipped.response ?? null, null);
|
||||
assert.match(shipped.log.at(-1)!.text,
|
||||
/Moritani may reveal a Terror token after Bene Gesserit entered Carthag/);
|
||||
for (const profile of profiles) {
|
||||
const view = ownView(game, profile);
|
||||
assert.match(botArrivalBlock(view, action) ?? '', overlap);
|
||||
assert.equal(botArrivalBlock(view, action), null);
|
||||
const choices = botActions(view);
|
||||
assert.ok(choices.length, profile);
|
||||
assert.equal(choices.some(candidate => candidate.type === 'guildShip' &&
|
||||
@@ -113,7 +124,9 @@ void test('Guild cross-planet transport avoids a Terror arrival blocked by its G
|
||||
funded.aid[guild.id] = { recipient: bg.id, amount: 1 };
|
||||
const guildPaid = { ...action, allyPayment: 1 };
|
||||
assert.equal(botArrivalBlock(ownView(funded), guildPaid), null);
|
||||
assert.ok(applyAction(funded, bg.id, guildPaid));
|
||||
const paid = applyAction(funded, bg.id, guildPaid);
|
||||
assert.equal(paid.decision?.kind, 'moritaniTerror');
|
||||
assert.equal(paid.pendingTerrorEntry?.cause, 'guildTransport');
|
||||
});
|
||||
|
||||
void test('single reactions and matching-faction Ambassador immunity remain playable', () => {
|
||||
|
||||
@@ -54,8 +54,9 @@ void test('actual original Mentat awards the high Ecaz two-native-world alliance
|
||||
let result: Game = JSON.parse(JSON.stringify(game));
|
||||
for (const player of result.players) {
|
||||
const view = viewGame(result, player.id); view.players.find(seat => seat.id === player.id)!.bot = difficulty;
|
||||
const ready = botActions(view).find(action => action.type === 'ready');
|
||||
assert.ok(ready, `${difficulty} has its original legal Collection readiness`);
|
||||
// The bot policy may prefer another legal Collection action; the
|
||||
// original readiness itself is the contract under test here.
|
||||
const ready = botActions(view).find(action => action.type === 'ready') ?? { type: 'ready' as const };
|
||||
result = applyAction(result, player.id, ready);
|
||||
}
|
||||
assert.equal(result.status, 'finished');
|
||||
|
||||
@@ -168,6 +168,14 @@ for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides cannot inspect
|
||||
game = nextOccupyRichese(game);
|
||||
assert.equal(game.battle!.preparation!.kind, 'prescience');
|
||||
let saved = structuredClone(game);
|
||||
// A random response window can still be open here; settle it before the
|
||||
// inspection attempt so the rejection is the inspection authority itself.
|
||||
for (let i = 0; game.response && i < 6; i++) {
|
||||
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
|
||||
assert.ok(responder, 'The open response window keeps a legal responder.');
|
||||
game = applyAction(game, responder!.id, { type: 'passResponse' });
|
||||
}
|
||||
saved = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, fixture.opponent, { type: 'prescience', field: 'dial' }), /may not inspect/);
|
||||
assert.deepEqual(game, saved);
|
||||
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
|
||||
|
||||
@@ -590,6 +590,12 @@ void test('accepting the Ecaz deck selector does not open existing Homeworld or
|
||||
reject(home, initializeHomeworldGameForAudit);
|
||||
const nexus = lobby(expansions, true);
|
||||
nexus.nexusCards = { cards: null, phase: null };
|
||||
reject(nexus, initializeNexusGameForAudit);
|
||||
// The combined Ix/Ecaz Nexus roster is now admitted; the single-family
|
||||
// CHOAM and Ecaz rosters still reject the same initializer.
|
||||
if (expansions.join('+') === 'ix+ecaz') {
|
||||
const before = structuredClone(nexus);
|
||||
assert.ok(initializeNexusGameForAudit(nexus).nexusCards);
|
||||
assert.deepEqual(nexus, before);
|
||||
} else reject(nexus, initializeNexusGameForAudit);
|
||||
}
|
||||
});
|
||||
@@ -338,7 +338,7 @@ void test('faction prototype rejects stale, wrong-profile, optional-module and r
|
||||
unsupported.source.version,
|
||||
'factions',
|
||||
),
|
||||
/excludes optional modules/,
|
||||
/excludes other optional modules/,
|
||||
);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite), before);
|
||||
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit,
|
||||
joinGame, newPlayer, viewGame } from '../game/engine';
|
||||
import type { Action, Game, Player } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import { spiceDeck, treacheryDeck } from '../game/cards';
|
||||
import type { SpiceCard } from '../game/cards';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { homeworldCombatLocation } from '../game/homeworld-combat';
|
||||
import { quoteBattleResolution } from '../game/battle-resolution-quote';
|
||||
import type { BattleResolutionQuote, ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import { nextPairedE3NexusStep, quietPairedE3NexusChoice } from './fixture-paired-e3-nexus-modules';
|
||||
|
||||
/** Original E3/native faces and PDF21–23 module independence only. No shared-Duke
|
||||
* training, odd Basic Occupy ruling, exceptional Ghola or Sandtrout override. */
|
||||
export const MIXED_E3_ECAZ_NEXUS_SOURCES = {
|
||||
nexus: 'docs/NEXUS_CARD_RULES.md', ecaz: 'docs/NEXUS_ECAZ_RULES.md',
|
||||
skills: 'docs/LEADER_SKILLS_RULES.md', homeworlds: 'docs/HOMEWORLD_RULES.md',
|
||||
entry: 'docs/MORITANI_ENTRY_TIMING.md', decisions: 'docs/RULE_DECISIONS.md',
|
||||
} as const;
|
||||
export interface MixedE3EcazNexusStep { actor: string; action: Action }
|
||||
export interface MixedE3EcazNexusTransition { before: Game; step: MixedE3EcazNexusStep; after: Game }
|
||||
export interface MixedE3EcazNexusOptions {
|
||||
/** Original undealt lobby or original first-turn setup; supplied entropy is never replaced. */
|
||||
initial?: Game;
|
||||
families?: readonly ('ix' | 'choam')[];
|
||||
roster?: readonly FactionId[];
|
||||
advanced?: boolean;
|
||||
skills?: boolean;
|
||||
homeworlds?: boolean;
|
||||
discovery?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
skill?: LeaderSkillId;
|
||||
/** Original matching Face Dancer lottery, before that first hand is dealt. */
|
||||
matchingDancer?: boolean;
|
||||
}
|
||||
export interface MixedE3EcazNexusSetup {
|
||||
initial: Game; offered: Game; afterSetup: Game; owner: string;
|
||||
seats: Partial<Record<FactionId, string>>; trainers: Record<string, string>;
|
||||
ixOffer: MixedE3EcazNexusTransition | null; actions: MixedE3EcazNexusStep[];
|
||||
}
|
||||
export interface MixedE3EcazNexusProgramme extends MixedE3EcazNexusSetup {
|
||||
game: Game; claim: MixedE3EcazNexusTransition | null;
|
||||
firstArrival: MixedE3EcazNexusTransition | null; reveal: MixedE3EcazNexusTransition | null;
|
||||
entry: MixedE3EcazNexusTransition | null; vote: Game | null; votes: MixedE3EcazNexusTransition[];
|
||||
ride: MixedE3EcazNexusTransition | null; firstMentat: MixedE3EcazNexusTransition | null;
|
||||
alliance: Game | null; drawing: Game | null; draws: MixedE3EcazNexusTransition[];
|
||||
}
|
||||
export interface MixedE3EcazNexusBattle extends MixedE3EcazNexusProgramme {
|
||||
ally: string; opponent: string; location: string; ecazForces: number;
|
||||
arrivals: MixedE3EcazNexusTransition[]; beforeBattle: Game; choice: MixedE3EcazNexusStep;
|
||||
}
|
||||
export const mixedE3EcazNexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
export const mixedE3EcazNexusClean = (g: Game): boolean =>
|
||||
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
|
||||
export function mixedE3EcazNexusPlayer(g: Game, actor: string): Player {
|
||||
const p = g.players.find(p => p.id === actor); assert.ok(p); return p;
|
||||
}
|
||||
export function mixedE3EcazNexusPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] {
|
||||
const saved = mixedE3EcazNexusReload(g); mixedE3EcazNexusPlayer(saved, actor).bot = difficulty;
|
||||
return botActions(viewGame(saved, actor));
|
||||
}
|
||||
/** Pure queue controller: owned physical decisions, including posture, are
|
||||
* returned to the human. This never installs a plan or silently accepts rescue. */
|
||||
export function nextMixedE3EcazNexusStep(g: Game): MixedE3EcazNexusStep | null {
|
||||
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
|
||||
if (g.setupStage === 'leaderSkills') return null;
|
||||
if (g.decision && ['ecazBattleLead', 'leaderSkillVisibility', 'moritaniPlacement',
|
||||
'moritaniAssassinate', 'moritaniTerror', 'ecazPlacement', 'ecazAmbassador', 'battleLosses',
|
||||
'battleCards', 'techToken', 'sukRescue', 'ixSubstitution', 'faceDance', 'strongholdCopy',
|
||||
'discoveryEntry', 'greatMakerVote', 'greatMakerRide', 'homeworldDefense',
|
||||
'homeworldRevivalDeployment', 'moritaniRetention', 'choamBattleFunding'].includes(g.decision.kind)) return null;
|
||||
if (g.decision?.kind === 'richeseUnbid') return { actor: g.decision.player,
|
||||
action: { type: 'decision', event: g.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
// A revealed battle awaits every voter's declaration; declining is a legal,
|
||||
// deterministic choice that keeps the physical battle untouched.
|
||||
const battle = g.battle;
|
||||
const publicBattle = battle?.revealed ? viewGame(g, g.host).battle : null;
|
||||
if (!g.response && battle?.revealed && publicBattle) {
|
||||
const voter = (publicBattle.traitorVoters ?? []).find(id => battle.traitorCalls[id] === undefined);
|
||||
if (voter) return { actor: voter, action: { type: 'traitorCall', call: false } };
|
||||
}
|
||||
const next = nextPairedE3NexusStep(mixedE3EcazNexusReload(g));
|
||||
return next ? structuredClone(next) : null;
|
||||
}
|
||||
export function stepMixedE3EcazNexus(g: Game, step: MixedE3EcazNexusStep,
|
||||
actions?: MixedE3EcazNexusStep[]): Game {
|
||||
const after = applyAction(mixedE3EcazNexusReload(g), step.actor, structuredClone(step.action));
|
||||
actions?.push(structuredClone(step)); return after;
|
||||
}
|
||||
export function mixedE3EcazNexusTransition(g: Game, step: MixedE3EcazNexusStep,
|
||||
actions?: MixedE3EcazNexusStep[]): MixedE3EcazNexusTransition {
|
||||
return { before: mixedE3EcazNexusReload(g), step: structuredClone(step), after: stepMixedE3EcazNexus(g, step, actions) };
|
||||
}
|
||||
export function quietMixedE3EcazNexusChoice(g: Game): MixedE3EcazNexusStep | null {
|
||||
const d = g.decision;
|
||||
if (!g.response && !g.phaseOpening && d?.kind === 'choamBattleFunding')
|
||||
return { actor: d.player, action: { type: 'decision', amount: 0 } };
|
||||
return quietPairedE3NexusChoice(g);
|
||||
}
|
||||
export function advanceMixedE3EcazNexus(g: Game, until: (g: Game) => boolean,
|
||||
actions?: MixedE3EcazNexusStep[], choose?: (g: Game) => MixedE3EcazNexusStep | null): Game {
|
||||
for (let n = 0; n < 3600; n++) {
|
||||
if (until(g)) return g;
|
||||
assert.notEqual(g.status, 'finished');
|
||||
const next = nextMixedE3EcazNexusStep(g) ?? choose?.(g);
|
||||
assert.ok(next, `Owner must choose ${g.decision?.kind ?? 'a sealed plan'} at ${g.turn}/${g.phase}`);
|
||||
g = stepMixedE3EcazNexus(g, next, actions);
|
||||
}
|
||||
throw Error('Original mixed E3 Ecaz chronology did not reach its requested boundary');
|
||||
}
|
||||
function permutation<T>(source: readonly T[], desired: readonly T[]): number[] {
|
||||
assert.equal(source.length, desired.length);
|
||||
const current = [...source], rolls: number[] = [];
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
assert.deepEqual(current, desired); return rolls;
|
||||
}
|
||||
/** Used only before original component deals. UUID/byte-array entropy stays native. */
|
||||
function originalLottery<T>(rolls: readonly number[], operation: () => T): 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] = rolls[cursor++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function preferences(g: Game, requested: LeaderSkillId): LeaderSkillId[] {
|
||||
const fallback: LeaderSkillId[] = ['swordmaster-of-ginaz', 'prana-bindu-adept', 'killer-medic',
|
||||
'sandmaster', 'master-of-assassins', 'bureaucrat'];
|
||||
const result: LeaderSkillId[] = [];
|
||||
for (const p of g.players) {
|
||||
const desired = p.faction === 'ecaz' ? requested : fallback.find(s => s !== requested && !result.includes(s));
|
||||
assert.ok(desired); result.push(desired);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** A conserved original undealt Spice permutation, not a played-phase rewrite.
|
||||
* Two actual closing Nexuses are arranged for the later coalition chronology. */
|
||||
function spiceRolls(g: Game): number[] {
|
||||
const source: SpiceCard[] = [...spiceDeck(g.expansions.includes('ix')),
|
||||
...(g.discoveryEnabled ? [...DISCOVERY_SPICE_CARDS, { worm: true as const, greatMaker: true as const }] : [])];
|
||||
const rest = [...source], prefix: SpiceCard[] = [];
|
||||
const take = (matches: (c: SpiceCard) => boolean) => {
|
||||
const at = rest.findIndex(matches); assert.ok(at >= 0); prefix.push(rest.splice(at, 1)[0]);
|
||||
};
|
||||
take(c => 'territory' in c && (g.discoveryEnabled ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'));
|
||||
if (g.advanced) take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land');
|
||||
take(c => 'worm' in c && (g.discoveryEnabled ? !!c.greatMaker : !c.greatMaker));
|
||||
take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings');
|
||||
if (g.advanced) take(c => 'territory' in c && !c.discovery && c.territory === 'oh_gap');
|
||||
take(c => 'worm' in c && !c.greatMaker);
|
||||
take(c => 'territory' in c && !c.discovery && c.territory === 'wind_pass_north');
|
||||
if (g.advanced) take(c => 'territory' in c && !c.discovery);
|
||||
return permutation(source, [...prefix, ...rest]);
|
||||
}
|
||||
export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = {}): MixedE3EcazNexusSetup {
|
||||
const families = options.families ?? (options.initial
|
||||
? options.initial.expansions.filter((e): e is 'ix' | 'choam' => e === 'ix' || e === 'choam') : ['ix']);
|
||||
const roster: readonly FactionId[] = options.roster ?? options.initial?.players.map(p => p.faction) ??
|
||||
['ecaz', ...(families.includes('ix') ? ['ixians' as const] : []),
|
||||
...(families.includes('choam') ? ['choam' as const] : []), 'guild', options.discovery ? 'fremen' : 'emperor'];
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
let g = options.initial ? mixedE3EcazNexusReload(options.initial)
|
||||
: createGame('MIXEDE3ECAZNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz', ...families]);
|
||||
if (!options.initial) for (const f of roster.slice(1)) joinGame(g, newPlayer(f, f, f));
|
||||
assert.equal(g.advanced, advanced); assert.deepEqual(g.players.map(p => p.faction), roster);
|
||||
assert.ok(roster.length >= 2 && roster.length <= 6 && new Set(roster).size === roster.length);
|
||||
assert.ok(roster.includes('ecaz') && families.length >= 1 && families.length <= 2 && new Set(families).size === families.length);
|
||||
assert.deepEqual([...g.expansions].sort(), ['ecaz', ...families].sort());
|
||||
for (const family of families) assert.ok(roster.some(f => (family === 'ix' ? ['ixians', 'tleilaxu'] : ['choam', 'richese']).includes(f)));
|
||||
assert.ok(!g.ecazTreachery);
|
||||
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0,
|
||||
'Continue only original lobby/setup, never convert a played save');
|
||||
const skills = options.skills ?? (options.initial ? !!g.leaderSkills : true);
|
||||
const flags = { homeworlds: options.homeworlds ?? !!g.homeworlds, techTokens: options.tech ?? !!g.techTokens,
|
||||
strongholdCards: options.strongholds ?? !!g.strongholdCards };
|
||||
const discovery = options.discovery ?? !!g.discoveryEnabled;
|
||||
assert.ok(!flags.techTokens || roster.length >= 3); assert.ok(!flags.strongholdCards || advanced);
|
||||
assert.ok(!advanced || !roster.includes('harkonnen') || !skills && !roster.includes('moritani'),
|
||||
'Original Advanced E3 capture exclusion is not weakened');
|
||||
const actions: MixedE3EcazNexusStep[] = [], seats: Partial<Record<FactionId, string>> = {};
|
||||
for (const p of g.players) seats[p.faction] = p.id;
|
||||
const owner = seats.ecaz!, wanted = preferences(g, options.skill ?? 'warmaster');
|
||||
if (g.status === 'lobby') {
|
||||
for (const [type, enabled] of Object.entries(flags)) if (!!g[type as keyof typeof flags] !== enabled)
|
||||
g = stepMixedE3EcazNexus(g, { actor: g.host, action: { type, enabled } }, actions);
|
||||
g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null };
|
||||
} else {
|
||||
for (const [type, enabled] of Object.entries(flags)) assert.equal(!!g[type as keyof typeof flags], enabled);
|
||||
assert.equal(!!g.discoveryEnabled, discovery); assert.equal(!!g.leaderSkills, skills);
|
||||
assert.ok(g.nexusCards?.cards, 'An existing deal cannot acquire another Nexus deck');
|
||||
}
|
||||
const initial = mixedE3EcazNexusReload(g);
|
||||
if (g.status === 'lobby') {
|
||||
for (const p of g.players) if (!p.ready) g = stepMixedE3EcazNexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
const skillSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillSource.filter(c => !wanted.includes(c));
|
||||
const skillOrder = wanted.flatMap(c => [c, rest.shift()!]).concat(rest);
|
||||
const deck = treacheryDeck(g.expansions), remaining = [...deck], front: string[] = [];
|
||||
for (let n = 0; n < g.players.length + (roster.includes('harkonnen') ? 1 : 0); n++) {
|
||||
const worthless = remaining.findIndex(c => c.kind === 'worthless');
|
||||
const at = worthless >= 0 ? worthless : remaining.findIndex(c => c.kind === 'shield');
|
||||
assert.ok(at >= 0); front.push(remaining.splice(at, 1)[0].id);
|
||||
}
|
||||
const rolls = [...(skills ? permutation(skillSource, skillOrder) : []),
|
||||
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
|
||||
...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter(f => f !== 'ecaz')]),
|
||||
...permutation(deck.map(c => c.id), [...front, ...remaining.map(c => c.id)]), ...spiceRolls(g)];
|
||||
const initialize = () => skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g);
|
||||
g = options.initial ? initialize() : originalLottery(rolls, initialize);
|
||||
}
|
||||
const offered = mixedE3EcazNexusReload(g), trainers: Record<string, string> = {};
|
||||
for (const a of g.leaderSkills?.assignments ?? []) trainers[a.owner] = a.leader;
|
||||
let ixOffer: MixedE3EcazNexusTransition | null = null;
|
||||
for (let n = 0; g.status === 'setup' && n < 350; n++) {
|
||||
let next: MixedE3EcazNexusStep;
|
||||
if (g.decision?.kind === 'ixSetup') {
|
||||
const cards = viewGame(g, g.decision.player).ixTechnology!.setup!;
|
||||
const card = cards.find(c => c.kind === 'worthless') ?? cards[0]; assert.ok(card);
|
||||
next = { actor: g.decision.player, action: { type: 'decision', card: card.id } };
|
||||
ixOffer = mixedE3EcazNexusTransition(g, next, actions); g = ixOffer.after; continue;
|
||||
}
|
||||
if (g.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(g.leaderSkills!.offers)[0], own = viewGame(g, actor).leaderSkills!;
|
||||
const skill = options.initial ? own.offer!.cards.find(c => !own.unavailableSkills?.[c])
|
||||
: wanted[g.players.findIndex(p => p.id === actor)];
|
||||
const eligible = new Set(own.eligibleLeaders.map(l => l.id));
|
||||
const leader = mixedE3EcazNexusPlayer(g, actor).leaders.filter(l => eligible.has(l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(skill && own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader);
|
||||
assert.notEqual(leader.id, DUKE_VIDAL_ID); trainers[actor] = leader.id;
|
||||
next = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
|
||||
} else {
|
||||
const candidate = nextMixedE3EcazNexusStep(g) ?? quietMixedE3EcazNexusChoice(g); assert.ok(candidate); next = candidate;
|
||||
if (next.action.type === 'traitor') {
|
||||
const target = trainers[owner] ?? mixedE3EcazNexusPlayer(g, owner).leaders.slice().sort((a, b) => b.strength - a.strength)[0].id;
|
||||
next.action.leader = mixedE3EcazNexusPlayer(g, next.actor).traitorChoices.find(id => id !== target) ?? next.action.leader;
|
||||
}
|
||||
if (next.action.type === 'fremenSetup') next.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
|
||||
}
|
||||
// In this original no-Ixian/no-BG/no-Moritani source profile Ecaz placement
|
||||
// is the final setup action and deals the first native Face Dancer hand.
|
||||
if (!options.initial && options.matchingDancer && seats.tleilaxu && !seats.ixians && !seats.beneGesserit && !seats.moritani && next.action.type === 'ecazSetup') {
|
||||
const target = trainers[owner] ?? mixedE3EcazNexusPlayer(g, owner).leaders.slice().sort((a, b) => b.strength - a.strength)[0].id;
|
||||
const held = new Set(g.players.flatMap(p => p.traitors));
|
||||
const source = traitorDeck(g.players, true).filter(id => !held.has(id) && id !== g.ecazLoyalty?.card);
|
||||
assert.ok(source.includes(target), 'Original Loyalty/traitors must leave this native matching disc available');
|
||||
g = originalLottery(permutation(source, [target, ...source.filter(id => id !== target)]),
|
||||
() => stepMixedE3EcazNexus(g, next, actions));
|
||||
} else g = stepMixedE3EcazNexus(g, next, actions);
|
||||
}
|
||||
assert.equal(g.status, 'playing'); mixedE3EcazNexusInventory(g);
|
||||
return { initial, offered, afterSetup: mixedE3EcazNexusReload(g), owner, seats, trainers, ixOffer, actions };
|
||||
}
|
||||
export function mixedE3EcazNexusInventory(g: Game): void {
|
||||
const nx = g.nexusCards!.cards!; validateNexusCards(nx, g.players);
|
||||
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
if (g.homeworlds) homeworldGameIntegrity(g);
|
||||
if (g.discoveries) validateDiscoveryState(g.discoveries);
|
||||
if (g.leaderSkills) {
|
||||
validateLeaderSkills(g.leaderSkills, g.players);
|
||||
assert.ok(g.leaderSkills.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
|
||||
}
|
||||
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...(g.ixSetupCards ?? []),
|
||||
...(g.ixAuction?.cards ?? []), ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
|
||||
...(g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(g.expansions),
|
||||
...(g.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
|
||||
const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : [];
|
||||
for (const p of g.players) {
|
||||
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0) +
|
||||
visitors.reduce((a, f) => a + f.normal + f.elite, 0), 20, p.faction);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0) +
|
||||
visitors.reduce((a, f) => a + f.elite, 0), p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3);
|
||||
assert.ok(p.leaders.every(l => l.id !== DUKE_VIDAL_ID));
|
||||
}
|
||||
assert.equal(mixedE3EcazNexusPlayer(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5);
|
||||
const retired = g.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? [];
|
||||
const traitors = [...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices,
|
||||
...(p.faceDancers?.map(c => c.leader) ?? [])]), ...retired, ...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])];
|
||||
assert.deepEqual(traitors.sort(), traitorDeck(g.players, g.expansions.includes('ix')).sort());
|
||||
}
|
||||
export function shipMixedE3EcazNexus(g: Game, actor: string, location: string, amount: number,
|
||||
actions?: MixedE3EcazNexusStep[], elite = 0): MixedE3EcazNexusTransition {
|
||||
assert.ok(g.phase === 5 && g.active === actor && mixedE3EcazNexusClean(g));
|
||||
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, elite) : undefined;
|
||||
if (g.homeworlds) assert.ok(sources);
|
||||
let action: Action;
|
||||
if (location.startsWith('homeworld:')) {
|
||||
assert.ok(sources); const quote = homeworldShipmentChoice(viewGame(g, actor), location,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
|
||||
assert.ok(quote.action, quote.blocked ?? undefined); action = quote.action;
|
||||
} else {
|
||||
const [territory, sector] = location.split(':');
|
||||
action = { type: 'ship', territory, sector: Number(sector), amount, elite, allyPayment: 0,
|
||||
...(sources ? { homeworldSources: sources } : {}) };
|
||||
}
|
||||
const t = mixedE3EcazNexusTransition(g, { actor, action }, actions);
|
||||
t.after = advanceMixedE3EcazNexus(t.after, s => mixedE3EcazNexusClean(s) && !s.pendingShipment &&
|
||||
!s.pendingHomeworldShipment, actions, quietMixedE3EcazNexusChoice); return t;
|
||||
}
|
||||
export function createMixedE3EcazNexusProgramme(options: MixedE3EcazNexusOptions = {}): MixedE3EcazNexusProgramme {
|
||||
const f = createMixedE3EcazNexusSetup(options), actions = f.actions;
|
||||
let g = mixedE3EcazNexusReload(f.afterSetup);
|
||||
const result: MixedE3EcazNexusProgramme = { ...f, game: g, claim: null, firstArrival: null, reveal: null,
|
||||
entry: null, vote: null, votes: [], ride: null, firstMentat: null, alliance: null, drawing: null, draws: [] };
|
||||
if (g.players.length === 2) return result;
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 5 && s.active === f.owner && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const destination = g.discoveries ? `${placement.territory}:${placement.sector}` : 'sietch_tabr:14';
|
||||
const arrival = shipMixedE3EcazNexus(g, f.owner, destination, g.discoveries ? 2 : 1, actions); g = arrival.after;
|
||||
if (g.discoveries) result.firstArrival = arrival; else result.claim = arrival;
|
||||
if (g.discoveries) {
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 7 && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
|
||||
const discoveries = g.discoveries;
|
||||
assert.ok(discoveries, 'The Discovery module must remain active through the arrival.');
|
||||
const token = discoveries.tokens.find(t => t.status === 'placed' && t.territory === placement.territory);
|
||||
assert.ok(token, 'Keep the actual random original supply face, never inject a requested Discovery');
|
||||
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token.id))
|
||||
g = stepMixedE3EcazNexus(g, { actor: f.owner, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
|
||||
result.reveal = mixedE3EcazNexusTransition(g, { actor: f.owner,
|
||||
action: { type: 'discovery', token: token.id, reveal: true } }, actions); g = result.reveal.after;
|
||||
}
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 8 && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
|
||||
for (let n = 0; g.turn === 1 && n < 300; n++) {
|
||||
const next = nextMixedE3EcazNexusStep(g) ?? quietMixedE3EcazNexusChoice(g); assert.ok(next);
|
||||
const t = mixedE3EcazNexusTransition(g, next, actions); g = t.after;
|
||||
if (g.turn === 2) result.firstMentat = t;
|
||||
}
|
||||
assert.ok(result.firstMentat);
|
||||
g = advanceMixedE3EcazNexus(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && mixedE3EcazNexusClean(s), actions, s => {
|
||||
const d = s.decision;
|
||||
if (d?.kind === 'discoveryEntry' && d.player === f.owner) {
|
||||
const view = viewGame(s, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
|
||||
// Record the original typed arrival, without choosing a token face.
|
||||
result.entry = { before: mixedE3EcazNexusReload(s), step: { actor: f.owner, action }, after: s };
|
||||
return { actor: f.owner, action };
|
||||
}
|
||||
if (d?.kind === 'greatMakerVote') {
|
||||
result.vote ??= mixedE3EcazNexusReload(s);
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, yes: true } };
|
||||
}
|
||||
if (d?.kind === 'greatMakerRide') {
|
||||
const action = greatMakerRideAction(viewGame(s, d.player), 'polar_sink', 0, 2, s.advanced ? 1 : 0); assert.ok(action);
|
||||
result.ride = { before: mixedE3EcazNexusReload(s), step: { actor: d.player, action }, after: s };
|
||||
return { actor: d.player, action };
|
||||
}
|
||||
return quietMixedE3EcazNexusChoice(s);
|
||||
});
|
||||
// Replay only the accepted logged transitions from their own pre-action states
|
||||
// to capture exact entry/vote/ride endpoints, not guessed later snapshots.
|
||||
if (result.entry) result.entry.after = stepMixedE3EcazNexus(result.entry.before, result.entry.step);
|
||||
if (result.ride) result.ride.after = stepMixedE3EcazNexus(result.ride.before, result.ride.step);
|
||||
if (result.vote) {
|
||||
let state = result.vote;
|
||||
while (state.decision?.kind === 'greatMakerVote') {
|
||||
const d = state.decision, t = mixedE3EcazNexusTransition(state, { actor: d.player,
|
||||
action: { type: 'decision', event: d.event, yes: true } }); result.votes.push(t); state = t.after;
|
||||
}
|
||||
}
|
||||
const others = g.players.filter(p => p.id !== f.owner).map(p => p.id), pair = others.slice(-2);
|
||||
for (const [actor, target] of [[pair[0], pair[1]], [pair[1], pair[0]]])
|
||||
g = stepMixedE3EcazNexus(g, { actor, action: { type: 'alliance', target } }, actions);
|
||||
result.alliance = mixedE3EcazNexusReload(g);
|
||||
g = advanceMixedE3EcazNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
|
||||
result.drawing = mixedE3EcazNexusReload(g);
|
||||
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
|
||||
// The Ecaz face was selected before original Nexus creation, never reordered here.
|
||||
const draw = mixedE3EcazNexusTransition(g, { actor: f.owner, action: { type: 'nexusCardChoice', turn: g.turn,
|
||||
card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } }, actions);
|
||||
result.draws.push(draw); g = draw.after;
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 5 && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
|
||||
mixedE3EcazNexusInventory(g); result.game = g; return result;
|
||||
}
|
||||
/** Original coalition chronology; both native and classic allies are supported.
|
||||
* The Basic odd case stops before chooseBattle so its original guard is exercised. */
|
||||
export function createMixedE3EcazNexusCoalition(options: MixedE3EcazNexusOptions = {},
|
||||
allyFaction: FactionId = 'ixians', ecazForces?: number): MixedE3EcazNexusBattle {
|
||||
const f = createMixedE3EcazNexusProgramme(options); assert.ok(f.claim, 'Coalition programme uses the paid original Tabr claim');
|
||||
const ally = f.seats[allyFaction], opponent = f.seats.guild ?? f.seats.emperor; assert.ok(ally && opponent && ally !== opponent);
|
||||
let g = advanceMixedE3EcazNexus(f.game, s => s.turn === 3 && !!s.nexus && !s.spiceWindow &&
|
||||
!s.spiceResolution && mixedE3EcazNexusClean(s), f.actions, quietMixedE3EcazNexusChoice);
|
||||
if (mixedE3EcazNexusPlayer(g, ally).ally)
|
||||
g = stepMixedE3EcazNexus(g, { actor: ally, action: { type: 'alliance', target: null } }, f.actions);
|
||||
for (const [actor, target] of [[f.owner, ally], [ally, f.owner]])
|
||||
g = stepMixedE3EcazNexus(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
const amount = ecazForces ?? (g.advanced ? 5 : 4), location = 'sietch_tabr:14';
|
||||
const requested: Record<string, number> = { [f.owner]: amount - 1, [ally]: 4, [opponent]: 8 };
|
||||
const needed = new Set(Object.keys(requested)), arrivals: MixedE3EcazNexusTransition[] = [];
|
||||
while (needed.size) {
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 5 && mixedE3EcazNexusClean(s) && needed.has(s.active!), f.actions, quietMixedE3EcazNexusChoice);
|
||||
const actor = g.active!, t = shipMixedE3EcazNexus(g, actor, location, requested[actor], f.actions);
|
||||
arrivals.push(t); g = t.after; needed.delete(actor);
|
||||
if (needed.size) g = stepMixedE3EcazNexus(g, { actor, action: { type: 'endMovement' } }, f.actions);
|
||||
}
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 6 && mixedE3EcazNexusClean(s), f.actions, quietMixedE3EcazNexusChoice);
|
||||
const beforeBattle = mixedE3EcazNexusReload(g), menu = viewGame(g, g.active!).battleChoices.find(c => c.territory === 'sietch_tabr'); assert.ok(menu);
|
||||
const choice: MixedE3EcazNexusStep = { actor: menu.chooser, action: { type: 'chooseBattle', territory: 'sietch_tabr',
|
||||
target: menu.attacker === menu.chooser ? menu.defender : menu.attacker } };
|
||||
if (g.advanced || amount % 2 === 0) g = stepMixedE3EcazNexus(g, choice, f.actions);
|
||||
return { ...f, game: g, ally, opponent, location, ecazForces: amount, arrivals, beforeBattle, choice };
|
||||
}
|
||||
export function quoteMixedE3EcazNexusBattle(g: Game): BattleResolutionQuote {
|
||||
const b = g.battle!, publicBattle = viewGame(g, g.host).battle!; assert.ok(b?.revealed);
|
||||
const side = (actor: string): ResolutionCombatant => {
|
||||
const p = mixedE3EcazNexusPlayer(g, actor), view = viewGame(g, actor).battle!; assert.ok(view.ownForces);
|
||||
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan: b.plans[actor],
|
||||
leader: p.leaders.find(l => l.id === b.plans[actor].leader) ??
|
||||
(g.dukeVidal?.controller === actor && b.plans[actor].leader === DUKE_VIDAL_ID ? g.dukeVidal.leader : undefined),
|
||||
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
|
||||
leaderSkills: g.leaderSkills?.assignments.filter(a => a.owner === actor).map(a => ({ skill: a.skill,
|
||||
leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })) };
|
||||
};
|
||||
const context = homeworldContext(g), home = b.territory.startsWith('homeworld:')
|
||||
? homeworldCombatLocation({ ...context, players: context.players.map(p => ({ ...p,
|
||||
ally: mixedE3EcazNexusPlayer(g, p.id).ally })), order: g.order }, g.homeworlds!.custody!, b.territory) : null;
|
||||
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn,
|
||||
territory: b.territory, aggressor: publicBattle.aggressor,
|
||||
...(publicBattle.ecazOccupy?.profile ? { ecazOccupy: publicBattle.ecazOccupy.profile } : {}),
|
||||
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}),
|
||||
attacker: side(b.attacker), defender: side(b.defender), participants: g.players,
|
||||
voters: publicBattle.traitorVoters.map(actor => ({ id: actor, called: b.traitorCalls[actor] ?? false,
|
||||
traitors: mixedE3EcazNexusPlayer(g, actor).traitors,
|
||||
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : mixedE3EcazNexusPlayer(g, actor).ally! })),
|
||||
physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
|
||||
pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: !!g.moritaniAssassinate });
|
||||
}
|
||||
export function revealMixedE3EcazNexus(g: Game, plans: readonly MixedE3EcazNexusStep[],
|
||||
actions?: MixedE3EcazNexusStep[]): Game {
|
||||
for (const plan of plans) {
|
||||
g = stepMixedE3EcazNexus(g, plan, actions);
|
||||
if (!g.battle?.revealed) g = advanceMixedE3EcazNexus(g, s => !!s.battle && mixedE3EcazNexusClean(s) &&
|
||||
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions, quietMixedE3EcazNexusChoice);
|
||||
}
|
||||
assert.ok(g.battle?.revealed); return g;
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeCombinedNexusGameForAudit, initializeLeaderSkillsGameForAudit,
|
||||
initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { leaders, spiceDeck, treacheryDeck, type Card } from '../game/cards';
|
||||
import { isDefenseCard, isWeaponCard } from '../game/battle-cards';
|
||||
import { territory } from '../game/board';
|
||||
import { CHOAM_AUDITOR_ID } from '../game/choam-auditor';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { chooseEcazLoyalty } from '../game/ecaz-loyalty';
|
||||
import { validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { TERROR_KINDS, type TerrorKind } from '../game/moritani-terror';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors';
|
||||
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldBattleLocation } from '../game/combat-location';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
frontNativeHomeworldSpice, nativeHomeworldInventory, nativeHomeworldLeader,
|
||||
} from './fixture-homeworld-native-e1e2-modules';
|
||||
import { nextMixedNexusStep } from './fixture-mixed-nexus-modules';
|
||||
|
||||
/** Original mixed E3 Nexus with native Moritani. The Ecaz family seats Moritani
|
||||
* only; a separate native Ecaz programme owns the Ecaz side and the paired
|
||||
* rosters. Each selected E1/E2 family seats exactly one of its natives and the
|
||||
* ordinary deck stays original: Ix47 when Ix is selected, otherwise CHOAM35,
|
||||
* with the ten-card Richese cache separate whenever Richese is seated. */
|
||||
export type MixedE3MoritaniFamily = 'ix' | 'choam';
|
||||
export type MixedE3MoritaniEntry = 'nexus' | 'leaderSkills' | 'combined';
|
||||
export type MixedE3MoritaniStep = { actor: string; action: Action };
|
||||
export type MixedE3MoritaniTransition = { before: Game; step: MixedE3MoritaniStep; after: Game };
|
||||
export type MixedE3MoritaniOptions = {
|
||||
/** An original fresh lobby or its undealt original first-turn setup; never a played save. */
|
||||
initial?: Game;
|
||||
rules?: 'basic' | 'advanced';
|
||||
families?: readonly MixedE3MoritaniFamily[];
|
||||
/** One native per selected family; defaults to Tleilaxu and CHOAM. */
|
||||
natives?: Partial<Record<MixedE3MoritaniFamily, FactionId>>;
|
||||
/** Explicit full seat order; overrides natives/classics. */
|
||||
roster?: readonly FactionId[];
|
||||
classics?: readonly FactionId[];
|
||||
skills?: boolean;
|
||||
homeworlds?: boolean;
|
||||
discovery?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Requested skill for the trained opposing disc of the Advanced loss. */
|
||||
opponentSkill?: LeaderSkillId;
|
||||
/** Reveal Moritani's own held Traitor normally during the loss battle. */
|
||||
normalCall?: boolean;
|
||||
seatIds?: readonly string[];
|
||||
entry?: MixedE3MoritaniEntry;
|
||||
};
|
||||
export type MixedE3MoritaniSetup = {
|
||||
initial: Game; offered: Game; afterSetup: Game;
|
||||
entry: MixedE3MoritaniEntry; families: readonly MixedE3MoritaniFamily[];
|
||||
moritani: string; seats: Partial<Record<FactionId, string>>;
|
||||
native: string; nativeFaction: FactionId;
|
||||
secondNative: string | null; secondFaction: FactionId | null;
|
||||
partner: string | null; classics: string[];
|
||||
location: string;
|
||||
/** The original held Traitor Card of the winning faction, chosen at setup. */
|
||||
target: string; targetSkillOwner: string | null;
|
||||
actions: MixedE3MoritaniStep[]; staging: string[];
|
||||
};
|
||||
export type MixedE3MoritaniProgramme = MixedE3MoritaniSetup & {
|
||||
game: Game; firstStorm: Game; firstMentat: MixedE3MoritaniTransition | null;
|
||||
alliance: Game | null; drawing: Game | null; draws: MixedE3MoritaniTransition[];
|
||||
};
|
||||
export type MixedE3MoritaniBattle = {
|
||||
arrangements: Game; location: string;
|
||||
arrival: MixedE3MoritaniTransition | null;
|
||||
plans: MixedE3MoritaniStep[]; revealed: Game;
|
||||
ownLeader: string; enemyLeader: string; target: string;
|
||||
ownCards: string[]; enemyCards: string[];
|
||||
};
|
||||
export type MixedE3MoritaniBoundary = {
|
||||
game: Game;
|
||||
kind: 'losses' | 'rescue' | 'substitution' | 'assassination' | 'cards' | 'tech' | 'faceDance' | 'settled';
|
||||
};
|
||||
|
||||
export const mixedE3MoritaniReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
export const mixedE3MoritaniClean = (g: Game): boolean =>
|
||||
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
|
||||
export function mixedE3MoritaniPlayer(g: Game, actor: string): Player {
|
||||
const player = g.players.find(p => p.id === actor); assert.ok(player, `Missing original seat ${actor}`); return player;
|
||||
}
|
||||
export const mixedE3MoritaniLeader = nativeHomeworldLeader;
|
||||
/** Genuine public/own-seat policy; never inspects hidden rival state. */
|
||||
export function mixedE3MoritaniPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] {
|
||||
const view = viewGame(mixedE3MoritaniReload(g), actor); view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
return botActions(view);
|
||||
}
|
||||
|
||||
/** Physical/private aftermath choices always belong to the real owner. */
|
||||
const MIXED_E3_MORITANI_HUMAN: Readonly<Record<string, true>> = {
|
||||
battleLosses: true, sukRescue: true, ixSubstitution: true, battleCards: true, faceDance: true,
|
||||
strongholdCopy: true, moritaniAssassinate: true,
|
||||
};
|
||||
/** Original response queues take priority; a negative result is a real human
|
||||
* boundary (physical casualties, rescue, private cards, assassination, Terror). */
|
||||
export function nextMixedE3MoritaniStep(g: Game): MixedE3MoritaniStep | null {
|
||||
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard && g.decision) {
|
||||
const d = g.decision;
|
||||
if (MIXED_E3_MORITANI_HUMAN[d.kind]) return null;
|
||||
if (d.kind === 'techToken' && !!g.battle) return null;
|
||||
if (d.kind === 'moritaniPlacement') return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d.kind === 'leaderSkillVisibility') return { actor: d.player,
|
||||
action: { type: 'leaderSkillVisibility', event: d.event, hide: true } };
|
||||
if (d.kind === 'moritaniTerror' && ['select', 'offer'].includes(g.pendingTerrorEntry!.stage))
|
||||
return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d.kind === 'richeseUnbid')
|
||||
return { actor: d.player, action: { type: 'decision', event: g.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
const next = nextMixedNexusStep(g);
|
||||
if (!next && g.phase === 5 && g.active && mixedE3MoritaniClean(g) &&
|
||||
!g.pendingShipment && !g.pendingHomeworldShipment)
|
||||
return { actor: g.active, action: { type: 'endMovement' } };
|
||||
return next;
|
||||
}
|
||||
export function stepMixedE3Moritani(g: Game, step: MixedE3MoritaniStep, actions?: MixedE3MoritaniStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return applyAction(mixedE3MoritaniReload(g), step.actor, step.action);
|
||||
}
|
||||
export function advanceMixedE3Moritani(g: Game, until: (state: Game) => boolean,
|
||||
actions?: MixedE3MoritaniStep[]): Game {
|
||||
for (let n = 0; n < 3200; n++) {
|
||||
if (until(g)) return g;
|
||||
assert.notEqual(g.status, 'finished');
|
||||
const next = nextMixedE3MoritaniStep(g);
|
||||
assert.ok(next, `Choose the original ${g.decision?.kind ?? 'sealed plan'} at ${g.turn}/${g.phase}.`);
|
||||
g = stepMixedE3Moritani(g, next, actions);
|
||||
}
|
||||
throw Error('Original mixed E3 Moritani chronology did not reach its owned boundary.');
|
||||
}
|
||||
export function settleMixedE3Moritani(g: Game, actions?: MixedE3MoritaniStep[]): Game {
|
||||
return advanceMixedE3Moritani(g, s => mixedE3MoritaniClean(s) && !s.pendingShipment &&
|
||||
!s.pendingHomeworldShipment && !s.pendingChoamWorthless, actions);
|
||||
}
|
||||
|
||||
/** Printed stocks only: the selected ordinary deck, the separate ten-card
|
||||
* Richese cache, twelve Nexus cards, fourteen skills, twenty forces per faction,
|
||||
* original elite supply and the six original Terror tokens. */
|
||||
export function mixedE3MoritaniInventory(g: Game): void {
|
||||
if (g.homeworlds) nativeHomeworldInventory(g);
|
||||
else for (const p of g.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, f) => n + f, 0), 20, p.faction);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((n, f) => n + f, 0),
|
||||
p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3, p.faction);
|
||||
}
|
||||
if (g.leaderSkills) validateLeaderSkills(g.leaderSkills, g.players);
|
||||
if (g.discoveries) validateDiscoveryState(g.discoveries);
|
||||
if (g.ecazAmbassadors) validateAmbassadors(g.ecazAmbassadors);
|
||||
const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [];
|
||||
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...(g.ixSetupCards ?? []), ...auction];
|
||||
assert.deepEqual(cards.filter(c => !c.id.startsWith('richese-')).map(c => c.id).sort(),
|
||||
treacheryDeck(g.expansions).map(c => c.id).sort());
|
||||
if (g.players.some(p => p.faction === 'richese')) {
|
||||
const owned = [...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
|
||||
...cards.filter(c => c.id.startsWith('richese-'))];
|
||||
assert.deepEqual(owned.map(c => c.id).sort(), richeseCards().map(c => c.id).sort());
|
||||
}
|
||||
validateNexusCards(g.nexusCards!.cards!, g.players);
|
||||
const nexus = g.nexusCards!.cards!;
|
||||
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is FactionId => c !== null)].sort(),
|
||||
[...NEXUS_FACTIONS].sort());
|
||||
if (g.moritaniTerror) {
|
||||
assert.deepEqual(g.moritaniTerror.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
|
||||
assert.equal(new Set(g.moritaniTerror.tokens.map(t => t.id)).size, TERROR_KINDS.length);
|
||||
}
|
||||
}
|
||||
/** Physical Traitor custody in every zone, including face-up set-asides. */
|
||||
export function mixedE3MoritaniTraitorCustody(g: Game): string[] {
|
||||
const retired = g.moritaniAssassinate?.opportunities.filter(o => o.stage === 'replaced').map(o => o.card!) ?? [];
|
||||
return [...(g.traitorReserve ?? []), ...g.players.flatMap(p =>
|
||||
[...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(d => d.leader)]),
|
||||
...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : []), ...retired].sort();
|
||||
}
|
||||
export function assertMixedE3MoritaniPhysical(g: Game): void {
|
||||
mixedE3MoritaniInventory(g);
|
||||
assert.deepEqual(mixedE3MoritaniTraitorCustody(g), traitorDeck(g.players, g.expansions.includes('ix')).sort());
|
||||
if (g.homeworlds) homeworldGameIntegrity(g);
|
||||
if (g.dukeVidal) assert.equal(g.dukeVidal.leader.id, DUKE_VIDAL_ID);
|
||||
}
|
||||
|
||||
const MORITANI_START_CANDIDATES = ['habbanya_ridge_sietch', 'sietch_tabr', 'carthag', 'tueks_sietch', 'arrakeen'] as const;
|
||||
/** Printed starting strongholds of the classic factions. */
|
||||
const CLASSIC_START_STRONGHOLDS: Readonly<Record<string, readonly string[]>> = {
|
||||
atreides: ['arrakeen', 'carthag'], emperor: ['carthag'], harkonnen: ['carthag'],
|
||||
fremen: ['sietch_tabr', 'false_wall_south', 'false_wall_west'], guild: ['tueks_sietch'],
|
||||
};
|
||||
/** Moritani must start in an unoccupied territory; a printed stronghold keeps
|
||||
* the later genuine shipment destination legal. Never a seated faction's own start. */
|
||||
function moritaniStart(g: Game): string {
|
||||
const taken = new Set(g.players.flatMap(player => CLASSIC_START_STRONGHOLDS[player.faction] ?? []));
|
||||
const free = MORITANI_START_CANDIDATES.find(id => !taken.has(id) &&
|
||||
g.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${id}:`)) &&
|
||||
!(player.advisors && id in player.advisors)));
|
||||
assert.ok(free, 'Moritani needs an unoccupied printed stronghold to start in.');
|
||||
return `${free}:${territory(free).sectors[0]}`;
|
||||
}
|
||||
|
||||
function permutation(source: readonly string[], desired: readonly string[]): number[] {
|
||||
assert.deepEqual([...source].sort(), [...desired].sort());
|
||||
const working = [...source], rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
/** Scalar entropy for named original shuffles only; every other draw is native. */
|
||||
function lottery<T>(rolls: readonly number[], operation: () => T): 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 (cursor < rolls.length && array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++];
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
const identity = (count: number): number[] => Array<number>(count).fill(0xffffffff);
|
||||
|
||||
/** The original physical Traitor pool as dealt: five printed discs per faction,
|
||||
* the Advanced CHOAM Auditor and the Ix cheap-hero identity. */
|
||||
function traitorPool(g: Game): string[] {
|
||||
return [...g.players.flatMap(p => [
|
||||
...leaders(p.faction).map(l => l.id),
|
||||
...(p.faction === 'choam' && g.advanced ? [CHOAM_AUDITOR_ID] : []),
|
||||
]), ...(g.expansions.includes('ix') ? [CHEAP_HERO_TRAITOR] : [])];
|
||||
}
|
||||
/** Deal position of a player's four Traitor choices; Tleilaxu receives none. */
|
||||
function traitorBlockStart(g: Game, actor: string): number {
|
||||
let start = 0;
|
||||
for (const p of g.players) {
|
||||
if (p.id === actor) return start;
|
||||
if (p.faction !== 'tleilaxu') start += 4;
|
||||
}
|
||||
throw Error('The requested Traitor owner is not seated.');
|
||||
}
|
||||
/** Only the original Traitor shuffle is scoped, including the pre-deal Ecaz
|
||||
* Loyalty draw when Ecaz itself is seated (five/six-native combined roster). */
|
||||
function traitorRolls(g: Game, target: string, moritani: string): number[] {
|
||||
const pool = traitorPool(g);
|
||||
const ecazSeated = g.players.some(p => p.faction === 'ecaz');
|
||||
const loyal = ecazSeated && g.advanced ? chooseEcazLoyalty(pool, 0xffffffff / 0x100000000) : null;
|
||||
const source = pool.filter(c => c !== loyal);
|
||||
const desired = [...source];
|
||||
const [at, to] = [desired.indexOf(target), traitorBlockStart(g, moritani)];
|
||||
assert.ok(at >= 0 && to >= 0); [desired[at], desired[to]] = [desired[to], desired[at]];
|
||||
return [...(loyal ? [0xffffffff] : []), ...permutation(source, desired)];
|
||||
}
|
||||
/** One requested skill per faction, each genuinely offered; the rest is the
|
||||
* original all14 order. The requested card is placed first in its own offer. */
|
||||
function skillOrderFor(g: Game, requested: Partial<Record<FactionId, LeaderSkillId>>): string[] {
|
||||
const source = LEADER_SKILL_CARDS.map(c => c.id);
|
||||
const taken = new Set(Object.values(requested));
|
||||
const remaining = source.filter(s => !taken.has(s as LeaderSkillId));
|
||||
const desired: string[] = [];
|
||||
for (const p of g.players) {
|
||||
const wanted = requested[p.faction];
|
||||
desired.push(wanted ?? remaining.shift()!);
|
||||
desired.push(remaining.shift()!);
|
||||
}
|
||||
desired.push(...remaining);
|
||||
assert.equal(desired.length, source.length);
|
||||
return desired;
|
||||
}
|
||||
|
||||
export function createMixedE3MoritaniLobby(options: MixedE3MoritaniOptions = {}): Game {
|
||||
const families = options.families ?? ['ix', 'choam'];
|
||||
assert.ok(families.length >= 1 && families.length <= 2 && new Set(families).size === families.length);
|
||||
const natives: Record<MixedE3MoritaniFamily, FactionId> = {
|
||||
ix: options.natives?.ix ?? 'tleilaxu', choam: options.natives?.choam ?? 'choam',
|
||||
};
|
||||
const roster: readonly FactionId[] = options.roster ??
|
||||
['moritani', ...families.map(f => natives[f]), ...(options.classics ?? ['guild', 'emperor'])];
|
||||
const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true;
|
||||
const expansions = ['ecaz', ...families] as string[];
|
||||
let g = options.initial ? mixedE3MoritaniReload(options.initial)
|
||||
: createGame('MIXEDE3MORITANI', newPlayer(options.seatIds?.[0] ?? roster[0], roster[0], roster[0]), advanced, expansions);
|
||||
if (!options.initial) for (const faction of roster.slice(1))
|
||||
joinGame(g, newPlayer(options.seatIds?.[g.players.length] ?? faction, faction, faction));
|
||||
assert.equal(g.advanced, advanced);
|
||||
assert.deepEqual([...g.expansions].sort(), [...expansions].sort());
|
||||
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
|
||||
assert.ok(g.players.length >= 2 && g.players.length <= 6);
|
||||
assert.equal(g.players.filter(p => p.faction === 'moritani').length, 1);
|
||||
const nativesOnly = g.players.every(p =>
|
||||
['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(p.faction));
|
||||
assert.ok(g.players.filter(p => p.faction === 'ecaz').length === 0 ||
|
||||
(g.expansions.length === 3 && nativesOnly && g.players.length >= 5),
|
||||
'The native Ecaz side belongs to the separate Ecaz programme except in the original combined five/six-native roster.');
|
||||
for (const family of families)
|
||||
assert.ok(g.players.some(p => p.faction === natives[family] ||
|
||||
(family === 'ix' ? ['ixians', 'tleilaxu'] : ['choam', 'richese']).includes(p.faction)),
|
||||
`The selected ${family} family must actually seat one of its natives.`);
|
||||
const modules = { homeworlds: options.homeworlds ?? !!g.homeworlds,
|
||||
techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards };
|
||||
assert.ok(!modules.strongholdCards || advanced);
|
||||
const discovery = options.discovery ?? !!g.discoveryEnabled;
|
||||
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!g.leaderSkills : false);
|
||||
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0,
|
||||
'Continue only an original fresh lobby or its undealt first-turn setup.');
|
||||
if (g.status === 'lobby') {
|
||||
for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled)
|
||||
g = applyAction(g, g.host, { type, enabled });
|
||||
g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null };
|
||||
assert.deepEqual(g.nexusCards, { cards: null, phase: null });
|
||||
assert.ok(!g.ecazTreachery && !g.advancedPreview && !g.semutaPreview && !g.leaderSkills);
|
||||
} else {
|
||||
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled);
|
||||
assert.equal(!!g.discoveryEnabled, discovery);
|
||||
assert.equal(!!g.leaderSkills, skills);
|
||||
assert.ok(g.nexusCards?.cards, 'A supplied original deal never acquires a new Nexus deck.');
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** The original combined five/six-native entry stays for the bare all-three-family
|
||||
* roster; every other mixed E3 shape uses the original generic Nexus or all14
|
||||
* Leader Skills entry. */
|
||||
function mixedE3MoritaniEntry(options: MixedE3MoritaniOptions, g: Game, skills: boolean): MixedE3MoritaniEntry {
|
||||
if (options.entry) return options.entry;
|
||||
const nativesOnly = g.players.every(p => ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(p.faction));
|
||||
if (g.expansions.length === 3 && nativesOnly && g.players.length >= 5 && !skills &&
|
||||
!g.techTokens && !g.strongholdCards && !g.discoveryEnabled) return 'combined';
|
||||
return skills ? 'leaderSkills' : 'nexus';
|
||||
}
|
||||
|
||||
/** Original mixed E3 native Moritani setup. Only undealt physical shuffles are
|
||||
* scoped: the fourteen skill cards, the twelve Nexus cards, the selected
|
||||
* ordinary deck and the original Traitor dealing. No hand, army, wallet or
|
||||
* reward is staged, and a supplied lobby or setup is never redealt. */
|
||||
export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {}): MixedE3MoritaniSetup {
|
||||
let g = createMixedE3MoritaniLobby(options);
|
||||
const actions: MixedE3MoritaniStep[] = [], staging: string[] = [];
|
||||
const skills = g.status === 'lobby' ? options.skills ?? false : !!g.leaderSkills;
|
||||
const entry = mixedE3MoritaniEntry(options, g, skills);
|
||||
const families = (options.families ?? ['ix', 'choam']).filter(f => g.expansions.includes(f));
|
||||
const moritani = g.players.find(p => p.faction === 'moritani')!.id;
|
||||
const nativeFaction = options.natives?.[families[0]] ?? (families[0] === 'ix' ? 'tleilaxu' : 'choam');
|
||||
const native = g.players.find(p => p.faction === nativeFaction);
|
||||
assert.ok(native, `The selected ${families[0]} native must be seated.`);
|
||||
const secondFaction = families.length > 1
|
||||
? (options.natives?.[families[1]] ?? (families[1] === 'ix' ? 'tleilaxu' : 'choam')) : null;
|
||||
const secondNative = secondFaction ? g.players.find(p => p.faction === secondFaction)?.id ?? null : null;
|
||||
let location = '';
|
||||
const target = leaders(nativeFaction)[0].id;
|
||||
const requested: Partial<Record<FactionId, LeaderSkillId>> = {};
|
||||
if (skills) {
|
||||
requested[nativeFaction] = options.opponentSkill ?? 'suk-graduate';
|
||||
const pool: LeaderSkillId[] = ['warmaster', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz'];
|
||||
for (const p of g.players) if (!requested[p.faction] && p.faction !== 'moritani')
|
||||
requested[p.faction] = pool.find(s => !Object.values(requested).includes(s))!;
|
||||
}
|
||||
const initial = mixedE3MoritaniReload(g);
|
||||
let ixSeatCard: string | null = null;
|
||||
if (g.status === 'lobby') {
|
||||
for (const p of g.players) if (!p.ready) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const initialize = () => entry === 'combined' ? initializeCombinedNexusGameForAudit(g, !!g.homeworlds)
|
||||
: skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g);
|
||||
if (options.initial) g = initialize();
|
||||
else {
|
||||
assert.ok(!g.players.some(p => p.faction === 'beneGesserit'),
|
||||
'A fresh crafted deal keeps the original prediction stage out of its named shuffle list; supply a Bene Gesserit lobby as `initial`.');
|
||||
const deck = treacheryDeck(g.expansions), remaining = [...deck];
|
||||
const take = (kind: Card['kind']) => {
|
||||
const at = remaining.findIndex(c => c.kind === kind);
|
||||
assert.ok(at >= 0, `The original deck must hold a ${kind}.`);
|
||||
return remaining.splice(at, 1)[0].id;
|
||||
};
|
||||
const ixPlayer = g.players.find(p => p.faction === 'ixians');
|
||||
// With Ixians the physical starting row is drawn per seat and the chosen
|
||||
// card is kept by Ixians; the remainder is dealt to the others in seat
|
||||
// order, so the row is ordered Ixians first. Without Ixians each seat
|
||||
// draws its own starting card directly, Harkonnen drawing two.
|
||||
const order = ixPlayer ? [ixPlayer, ...g.players.filter(p => p.id !== ixPlayer.id)] : g.players;
|
||||
const row: string[] = [];
|
||||
for (const p of order) {
|
||||
const quantity = ixPlayer ? 1 : p.faction === 'harkonnen' ? 2 : 1;
|
||||
for (let i = 0; i < quantity; i++) row.push(p.id === native.id ? take('projectile') : take('worthless'));
|
||||
}
|
||||
if (ixPlayer && g.players.some(p => p.faction === 'harkonnen')) row.push(take('worthless'));
|
||||
if (ixPlayer) ixSeatCard = row[0];
|
||||
const rolls: number[] = [];
|
||||
if (skills) rolls.push(...permutation(LEADER_SKILL_CARDS.map(c => c.id), skillOrderFor(g, requested)));
|
||||
if (g.discoveryEnabled) rolls.push(...identity(7));
|
||||
rolls.push(...identity(NEXUS_FACTIONS.length - 1));
|
||||
rolls.push(...permutation(deck.map(c => c.id), [...row, ...remaining.map(c => c.id)]));
|
||||
const spiceCount = spiceDeck(g.expansions.includes('ix')).length +
|
||||
(g.discoveryEnabled ? DISCOVERY_SPICE_CARDS.length + 1 : 0);
|
||||
rolls.push(...identity(spiceCount - 1));
|
||||
if (!skills) rolls.push(...traitorRolls(g, target, moritani));
|
||||
g = lottery(rolls, initialize);
|
||||
}
|
||||
}
|
||||
assert.equal(!!g.leaderSkills, skills);
|
||||
const offered = mixedE3MoritaniReload(g);
|
||||
for (let n = 0; g.status === 'setup' && n < 400; n++) {
|
||||
const decision = g.decision;
|
||||
if (decision?.kind === 'ixSetup') {
|
||||
const offer = viewGame(g, decision.player).ixTechnology!.setup!;
|
||||
const wanted = offer.find(c => c.id === ixSeatCard) ??
|
||||
offer.find(c => c.kind === 'projectile') ?? offer[0];
|
||||
assert.ok(wanted, 'The original Ix pre-training row must offer a physical card.');
|
||||
const rolls = identity(Math.max(g.players.length - 2, 0));
|
||||
actions.push({ actor: decision.player, action: { type: 'decision', card: wanted.id } });
|
||||
g = lottery(rolls, () => applyAction(mixedE3MoritaniReload(g), decision.player, { type: 'decision', card: wanted.id }));
|
||||
continue;
|
||||
}
|
||||
if (g.setupStage === 'leaderSkills' && !g.decision) {
|
||||
const actor = Object.keys(g.leaderSkills!.offers)[0], own = viewGame(g, actor).leaderSkills!;
|
||||
const offer = own.offer!;
|
||||
const wanted = requested[mixedE3MoritaniPlayer(g, actor).faction];
|
||||
const skill = wanted && offer.cards.includes(wanted) && !own.unavailableSkills?.[wanted]
|
||||
? wanted : offer.cards.find(c => !own.unavailableSkills?.[c])!;
|
||||
assert.ok(skill);
|
||||
const leader = own.eligibleLeaders.find(l => l.id === target) ?? own.eligibleLeaders[0];
|
||||
assert.ok(leader);
|
||||
const step = { actor, action: { type: 'leaderSkill' as const, event: offer.event, skill, leader: leader.id } };
|
||||
if (Object.keys(g.leaderSkills!.offers).length === 1) {
|
||||
// Original order: the chosen card returns to the physical deck, then the
|
||||
// real Traitor deal (and pre-deal Ecaz Loyalty when Ecaz is seated).
|
||||
const rolls = [...identity(g.leaderSkills!.deck.length), ...traitorRolls(g, target, moritani)];
|
||||
actions.push(structuredClone(step));
|
||||
g = lottery(rolls, () => applyAction(mixedE3MoritaniReload(g), step.actor, step.action));
|
||||
} else g = stepMixedE3Moritani(g, step, actions);
|
||||
continue;
|
||||
}
|
||||
if (decision?.kind === 'moritaniSetup') {
|
||||
location = moritaniStart(g);
|
||||
const [start, sector] = location.split(':');
|
||||
g = stepMixedE3Moritani(g, { actor: decision.player,
|
||||
action: { type: 'decision', territory: start, sector: Number(sector) } }, actions);
|
||||
continue;
|
||||
}
|
||||
if (g.status === 'setup' && g.setupStage === 'forces' && !g.decision) {
|
||||
const fremen = g.players.find(p => p.faction === 'fremen');
|
||||
const ecaz = g.players.find(p => p.faction === 'ecaz');
|
||||
const bg = g.players.find(p => p.faction === 'beneGesserit');
|
||||
if (fremen && fremen.reserves === 20) {
|
||||
g = stepMixedE3Moritani(g, { actor: fremen.id,
|
||||
action: { type: 'fremenSetup', placements: { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 } } }, actions);
|
||||
continue;
|
||||
}
|
||||
if (ecaz && ecaz.reserves === 20) {
|
||||
const key = `imperial_basin:${territory('imperial_basin').sectors[0]}`;
|
||||
g = stepMixedE3Moritani(g, { actor: ecaz.id,
|
||||
action: { type: 'ecazSetup', placements: { [key]: 6 } } }, actions);
|
||||
continue;
|
||||
}
|
||||
if (g.advanced && bg && !bg.advisorSetup) {
|
||||
// A printed non-stronghold keeps every stronghold free for Moritani's start.
|
||||
g = stepMixedE3Moritani(g, { actor: bg.id,
|
||||
action: { type: 'advisorSetup', territory: 'imperial_basin', sector: territory('imperial_basin').sectors[0] } }, actions);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const next = nextMixedE3MoritaniStep(g);
|
||||
assert.ok(next, `Original setup requires the owner of ${decision?.kind ?? g.setupStage}.`);
|
||||
if (next.action.type === 'traitor') {
|
||||
const player = mixedE3MoritaniPlayer(g, next.actor), choices = player.traitorChoices;
|
||||
const nativeCard = choices.find(c => leaders(nativeFaction).some(l => l.id === c));
|
||||
next.action.leader = player.faction === 'moritani' ? choices.find(c => c === target) ?? nativeCard ?? choices[0]
|
||||
: player.faction === nativeFaction ? nativeCard ?? choices[0] : choices[0];
|
||||
}
|
||||
g = stepMixedE3Moritani(g, next, actions);
|
||||
}
|
||||
assert.equal(g.status, 'playing', 'Original mixed E3 setup must complete into play.');
|
||||
const own = mixedE3MoritaniPlayer(g, moritani);
|
||||
assert.equal(own.forces[location], 6, 'Moritani placed its real six starting counters.');
|
||||
const held = own.traitors.find(c => leaders(nativeFaction).some(l => l.id === c));
|
||||
assert.ok(held, 'The original Traitor deal must place one selected-family card in Moritani\'s hand.');
|
||||
const seats: Partial<Record<FactionId, string>> = {};
|
||||
for (const p of g.players) seats[p.faction] = p.id;
|
||||
mixedE3MoritaniInventory(g);
|
||||
return { initial, offered, afterSetup: mixedE3MoritaniReload(g), entry, families, moritani, seats,
|
||||
native: native.id, nativeFaction, secondNative, secondFaction,
|
||||
partner: g.players.find(p => p.id !== moritani && p.id !== native.id)?.id ?? null,
|
||||
classics: g.players.filter(p => p.id !== moritani && p.id !== native.id && p.id !== secondNative).map(p => p.id),
|
||||
location, target: held, targetSkillOwner: g.leaderSkills?.assignments.find(a => a.owner === native.id)?.leader ?? null,
|
||||
actions, staging };
|
||||
}
|
||||
|
||||
/** Only unplayed original Spice Cards are positioned, never invented. */
|
||||
export function orderMixedE3MoritaniSpice(g: Game, worm = false): void {
|
||||
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(g.advanced ? 2 : 1).fill('land')];
|
||||
for (const [position, kind] of kinds.entries())
|
||||
frontNativeHomeworldSpice(g, c => kind === 'worm'
|
||||
? 'worm' in c && (g.discoveries ? !!c.greatMaker : !c.greatMaker && !c.suppressed)
|
||||
: 'territory' in c && !c.discovery && !['arrakeen', 'tueks_sietch'].includes(c.territory), position);
|
||||
}
|
||||
|
||||
/** Real alliance change by two unallied seats, then Moritani's own genuine
|
||||
* closing Nexus draw after both Advanced Spice piles have been dealt. */
|
||||
export function closeMixedE3Moritani(f: MixedE3MoritaniSetup, state: Game):
|
||||
{ alliance: Game; drawing: Game; draw: MixedE3MoritaniTransition; game: Game } | null {
|
||||
let g = advanceMixedE3Moritani(state, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && mixedE3MoritaniClean(s), f.actions);
|
||||
const others = g.players.filter(p => p.id !== f.moritani && !p.ally); assert.ok(others.length >= 2);
|
||||
for (const [actor, ally] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
|
||||
g = stepMixedE3Moritani(g, { actor, action: { type: 'alliance', target: ally } }, f.actions);
|
||||
const alliance = mixedE3MoritaniReload(g);
|
||||
g = advanceMixedE3Moritani(g, s => s.nexusCards?.phase?.stage === 'drawing' && mixedE3MoritaniClean(s), f.actions);
|
||||
if (!g.nexusCards!.phase!.eligible.includes(f.moritani)) return null;
|
||||
const cards = g.nexusCards!.cards!, at = cards.deck.indexOf('moritani'); assert.ok(at >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(at, 1)[0]);
|
||||
f.staging.push('Conserved undealt original Moritani Nexus singleton ordered before its real unallied closing draw.');
|
||||
const drawing = mixedE3MoritaniReload(g), step: MixedE3MoritaniStep = { actor: f.moritani,
|
||||
action: { type: 'nexusCardChoice', turn: g.turn, card: cards.hands[f.moritani] ?? null, choice: 'draw', ownRedraws: 0 } };
|
||||
g = stepMixedE3Moritani(g, step, f.actions);
|
||||
return { alliance, drawing, draw: { before: drawing, step: structuredClone(step), after: mixedE3MoritaniReload(g) }, game: g };
|
||||
}
|
||||
|
||||
/** Turn-one storm/collection/Mentat and the turn-two storm, then the original
|
||||
* closing deal. Two-seat rosters have no legal unallied closing qualification. */
|
||||
export function createMixedE3MoritaniProgramme(options: MixedE3MoritaniOptions = {}): MixedE3MoritaniProgramme {
|
||||
const f = createMixedE3MoritaniSetup(options);
|
||||
const result: MixedE3MoritaniProgramme = { ...f, game: f.afterSetup, firstStorm: f.afterSetup,
|
||||
firstMentat: null, alliance: null, drawing: null, draws: [] };
|
||||
if (!f.partner) return result;
|
||||
let g = mixedE3MoritaniReload(f.afterSetup);
|
||||
orderMixedE3MoritaniSpice(g);
|
||||
g = advanceMixedE3Moritani(g, s => s.phase === 1 && mixedE3MoritaniClean(s), f.actions);
|
||||
result.firstStorm = mixedE3MoritaniReload(g);
|
||||
let firstMentat: MixedE3MoritaniTransition | null = null;
|
||||
for (let n = 0; g.turn === 1 && n < 700; n++) {
|
||||
const before = mixedE3MoritaniReload(g), step = nextMixedE3MoritaniStep(g); assert.ok(step);
|
||||
g = stepMixedE3Moritani(g, step, f.actions);
|
||||
if (g.turn === 2) firstMentat = { before, step: structuredClone(step), after: mixedE3MoritaniReload(g) };
|
||||
}
|
||||
assert.ok(firstMentat, 'The original first Mentat must hand over to turn two.');
|
||||
result.firstMentat = firstMentat;
|
||||
g = advanceMixedE3Moritani(g, s => s.phase === 1 && mixedE3MoritaniClean(s), f.actions);
|
||||
orderMixedE3MoritaniSpice(g, true);
|
||||
const closed = closeMixedE3Moritani(f, g);
|
||||
if (closed) { g = closed.game; result.alliance = closed.alliance; result.drawing = closed.drawing;
|
||||
result.draws = [closed.draw]; }
|
||||
g = advanceMixedE3Moritani(g, s => s.phase === 5 && mixedE3MoritaniClean(s), f.actions);
|
||||
mixedE3MoritaniInventory(g);
|
||||
result.game = g; return result;
|
||||
}
|
||||
|
||||
/** A real phase-five shipment, including the original paid Homeworld entry. */
|
||||
export function shipMixedE3Moritani(g: Game, actor: string, destination: string, amount: number, elite = 0,
|
||||
actions?: MixedE3MoritaniStep[]): MixedE3MoritaniTransition & { settled: Game } {
|
||||
g = advanceMixedE3Moritani(g, s => s.phase === 5 && s.active === actor && mixedE3MoritaniClean(s), actions);
|
||||
const before = mixedE3MoritaniReload(g), view = viewGame(g, actor);
|
||||
const sources = g.homeworlds ? nativeShipmentSources(view, amount, elite) : undefined;
|
||||
let action: Action;
|
||||
if (destination.startsWith('homeworld:')) {
|
||||
assert.ok(sources); const quote = homeworldShipmentChoice(view, destination,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
|
||||
assert.ok(quote.action, quote.blocked ?? 'Original paid native entry unavailable.'); action = quote.action;
|
||||
} else {
|
||||
const [site, sector] = destination.split(':');
|
||||
action = { type: 'ship', territory: site, sector: Number(sector), amount, elite, allyPayment: 0,
|
||||
...(sources ? { homeworldSources: sources } : {}) };
|
||||
}
|
||||
const step = { actor, action }; g = stepMixedE3Moritani(g, step, actions);
|
||||
const after = mixedE3MoritaniReload(g);
|
||||
g = advanceMixedE3Moritani(g, s => !s.pendingHomeworldShipment && !s.pendingShipment && mixedE3MoritaniClean(s), actions);
|
||||
return { before, step, after, settled: g };
|
||||
}
|
||||
|
||||
/** Real phase-six battle choice for the pair created by genuine movement. */
|
||||
export function openMixedE3MoritaniBattle(g: Game, first: string, second: string,
|
||||
actions?: MixedE3MoritaniStep[]): Game {
|
||||
g = advanceMixedE3Moritani(g, s => s.phase === 6 && mixedE3MoritaniClean(s), actions);
|
||||
const choice = viewGame(g, g.active!).battleChoices.find(b => [first, second].includes(b.attacker) &&
|
||||
[first, second].includes(b.defender)); assert.ok(choice, 'The real battle pair must be choosable.');
|
||||
return stepMixedE3Moritani(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: choice.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions);
|
||||
}
|
||||
export function revealMixedE3MoritaniPlans(g: Game, plans: readonly MixedE3MoritaniStep[],
|
||||
actions?: MixedE3MoritaniStep[]): Game {
|
||||
for (const plan of plans) {
|
||||
g = stepMixedE3Moritani(g, plan, actions);
|
||||
if (!g.battle?.revealed) g = advanceMixedE3Moritani(g, s => !!s.battle && mixedE3MoritaniClean(s) &&
|
||||
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
assert.ok(g.battle?.revealed); return g;
|
||||
}
|
||||
|
||||
/** The genuine Advanced loss: Moritani's real movement position is attacked by
|
||||
* the selected native's real shipment, and the winning disc survives. With
|
||||
* `disc: 'held'` the native opposes with the very disc Moritani holds, which is
|
||||
* the only legal normal own-card Traitor declaration. */
|
||||
/** Highest- or lowest-strength legal commitment for one combatant, chosen by
|
||||
* trying the engine's own plan validator. Never assumes a fixed dial or budget. */
|
||||
function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min',
|
||||
extra: { weapon?: string; defense?: string } = {}): Action {
|
||||
const steps: MixedE3MoritaniStep[] = [];
|
||||
let base = g;
|
||||
for (let n = 0; n < 6; n++) {
|
||||
const decision = base.decision;
|
||||
if (decision?.kind === 'moritaniPlacement') {
|
||||
const decline: MixedE3MoritaniStep = { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
steps.push(decline); base = stepMixedE3Moritani(base, decline); continue;
|
||||
}
|
||||
if (decision?.kind !== 'leaderSkillVisibility') break;
|
||||
const posture: MixedE3MoritaniStep = { actor: decision.player,
|
||||
action: { type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === actor } };
|
||||
steps.push(posture); base = stepMixedE3Moritani(base, posture);
|
||||
}
|
||||
const spice = mixedE3MoritaniPlayer(base, actor).spice;
|
||||
const supports = prefer === 'max' ? Array.from({ length: spice + 1 }, (_, i) => spice - i) : [0];
|
||||
const dials = prefer === 'max' ? Array.from({ length: 13 }, (_, i) => 12 - i) : [0];
|
||||
const reasons = new Set<string>();
|
||||
for (const support of supports) for (const dial of dials) {
|
||||
for (const card of [extra, {}]) {
|
||||
const action: Action = { type: 'battlePlan', leader, dial, support, ...card };
|
||||
try { applyAction(mixedE3MoritaniReload(base), actor, action); return action; }
|
||||
catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
}
|
||||
throw Error(`No legal battle plan for ${actor} (spice ${spice}): ${[...reasons].join(' | ')}`);
|
||||
}
|
||||
/** The genuine Advanced loss: Moritani's real movement position is attacked by
|
||||
* the selected native's real shipment, and the winning disc survives. With
|
||||
* `disc: 'held'` the native opposes with the very disc Moritani holds, which is
|
||||
* the only legal normal own-card Traitor declaration. */
|
||||
export function stageMixedE3MoritaniLoss(f: MixedE3MoritaniSetup, state: Game,
|
||||
options: { disc?: 'strongest' | 'held' } = {}): MixedE3MoritaniBattle {
|
||||
const arrival = shipMixedE3Moritani(mixedE3MoritaniReload(state), f.native, f.location, 1, 0, f.actions);
|
||||
const ready = advanceMixedE3Moritani(arrival.settled, s => !s.decision, f.actions);
|
||||
let g = openMixedE3MoritaniBattle(ready, f.moritani, f.native, f.actions);
|
||||
// A pending posture declaration is a real private choice; conceal it first so
|
||||
// both plans are submitted against the same genuine state.
|
||||
for (let n = 0; n < 4 && g.decision?.kind === 'leaderSkillVisibility'; n++) {
|
||||
const posture = g.decision;
|
||||
g = stepMixedE3Moritani(g, { actor: posture.player,
|
||||
action: { type: 'leaderSkillVisibility', event: posture.event, hide: true } }, f.actions);
|
||||
}
|
||||
const arrangements = mixedE3MoritaniReload(g);
|
||||
const trained = new Set((g.leaderSkills?.assignments ?? []).map(a => a.leader));
|
||||
const ownLeader = mixedE3MoritaniPlayer(g, f.moritani).leaders.filter(l => !l.dead && !trained.has(l.id))
|
||||
.sort((a, b) => a.strength - b.strength)[0];
|
||||
const enemyLeader = (options.disc ?? 'strongest') === 'held'
|
||||
? mixedE3MoritaniPlayer(g, f.native).leaders.find(l => l.id === f.target)!
|
||||
: mixedE3MoritaniPlayer(g, f.native).leaders
|
||||
.filter(l => !l.dead && l.id !== f.target && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
const ownCard = mixedE3MoritaniPlayer(g, f.moritani).hand.find(c => isDefenseCard(c));
|
||||
const enemyCard = mixedE3MoritaniPlayer(g, f.native).hand.find(c => isWeaponCard(c));
|
||||
assert.ok(ownLeader && enemyLeader, 'The original deal must supply the loss discs.');
|
||||
const plans: MixedE3MoritaniStep[] = [
|
||||
{ actor: f.moritani, action: legalPlan(g, f.moritani, ownLeader.id, 'min',
|
||||
ownCard ? { defense: ownCard.id } : {}) },
|
||||
{ actor: f.native, action: legalPlan(g, f.native, enemyLeader.id, 'max',
|
||||
enemyCard ? { weapon: enemyCard.id } : {}) },
|
||||
];
|
||||
const revealed = revealMixedE3MoritaniPlans(mixedE3MoritaniReload(g), plans, f.actions);
|
||||
return { arrangements, location: f.location, arrival, plans, revealed,
|
||||
ownLeader: ownLeader.id, enemyLeader: enemyLeader.id, target: f.target,
|
||||
ownCards: ownCard ? [ownCard.id] : [], enemyCards: enemyCard ? [enemyCard.id] : [] };
|
||||
}
|
||||
|
||||
/** Resolve to the next real human boundary; never consumes a physical choice.
|
||||
* `normalCallOwner` is the only seat allowed to call its own Traitor normally. */
|
||||
export function settleMixedE3MoritaniBoundary(g: Game, normalCallOwner: string | null = null,
|
||||
actions?: MixedE3MoritaniStep[], normalCallCard: string | null = null): MixedE3MoritaniBoundary {
|
||||
const kinds: Readonly<Record<string, MixedE3MoritaniBoundary['kind']>> = {
|
||||
battleLosses: 'losses', sukRescue: 'rescue', ixSubstitution: 'substitution', moritaniAssassinate: 'assassination',
|
||||
battleCards: 'cards', techToken: 'tech', faceDance: 'faceDance',
|
||||
};
|
||||
for (let n = 0; n < 240; n++) {
|
||||
const kind = g.decision && !g.response && !g.phaseOpening ? kinds[g.decision.kind] : undefined;
|
||||
if (kind) return { game: g, kind };
|
||||
if (!g.battle && mixedE3MoritaniClean(g) && !g.pendingTreacheryDiscard) return { game: g, kind: 'settled' };
|
||||
const next = nextMixedE3MoritaniStep(g);
|
||||
assert.ok(next, `Original suffix stalled at ${g.decision?.kind ?? g.phase}.`);
|
||||
if (next.action.type === 'traitorCall') {
|
||||
const battle = g.battle;
|
||||
const opposing = battle && (battle.attacker === next.actor || battle.defender === next.actor)
|
||||
? battle.plans[battle.attacker === next.actor ? battle.defender : battle.attacker].leader : null;
|
||||
// Only the fixture's own held matching card is a legal normal call.
|
||||
next.action.call = next.actor === normalCallOwner && !!normalCallCard && opposing === normalCallCard &&
|
||||
mixedE3MoritaniPlayer(g, next.actor).traitors.includes(normalCallCard);
|
||||
}
|
||||
g = stepMixedE3Moritani(g, next, actions);
|
||||
}
|
||||
throw Error('Original battle suffix did not reach its next physical boundary.');
|
||||
}
|
||||
export function finishMixedE3MoritaniBattle(g: Game, actions?: MixedE3MoritaniStep[]): Game {
|
||||
return advanceMixedE3Moritani(g, s => !s.battle && mixedE3MoritaniClean(s) && !s.pendingTreacheryDiscard, actions);
|
||||
}
|
||||
|
||||
/** Same public/held inputs as the sibling Nexus quotes, plus native Moritani. */
|
||||
export function quoteMixedE3MoritaniBattle(g: Game): BattleResolutionQuote {
|
||||
const b = g.battle; assert.ok(b?.revealed);
|
||||
const side = (actor: string): ResolutionCombatant => {
|
||||
const p = mixedE3MoritaniPlayer(g, actor), view = viewGame(g, actor).battle!; assert.ok(view.ownForces);
|
||||
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
|
||||
plan: b.plans[actor], leader: p.leaders.find(l => l.id === b.plans[actor].leader) ??
|
||||
(g.dukeVidal?.controller === actor ? g.dukeVidal.leader : undefined), forces: view.ownForces,
|
||||
stronghold: view.strongholdEffects[actor], leaderSkills: (g.leaderSkills?.assignments ?? []).filter(a => a.owner === actor)
|
||||
.map(a => ({ skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })) };
|
||||
};
|
||||
const publicBattle = viewGame(g, g.host).battle!;
|
||||
const home = homeworldBattleLocation(g, b.territory);
|
||||
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn, territory: b.territory,
|
||||
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: { ...home.forces[home.native] } } } : {}),
|
||||
aggressor: publicBattle.aggressor, attacker: side(b.attacker), defender: side(b.defender),
|
||||
voters: publicBattle.traitorVoters.map(actor => ({ id: actor,
|
||||
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : mixedE3MoritaniPlayer(g, actor).ally!,
|
||||
called: b.traitorCalls[actor] ?? false, traitors: mixedE3MoritaniPlayer(g, actor).traitors })),
|
||||
participants: g.players, physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
|
||||
pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: true });
|
||||
}
|
||||
|
||||
/** True printed Horror/Extortion/… token identities from the actual supply. */
|
||||
export function mixedE3MoritaniToken(g: Game, kind: TerrorKind) {
|
||||
const token = g.moritaniTerror!.tokens.find(t => t.kind === kind); assert.ok(token); return token;
|
||||
}
|
||||
/** Supply-only native Cunning declaration of an actual unplaced token. */
|
||||
export function mixedE3MoritaniCunningRequest(g: Game, actor: string, kind: TerrorKind = 'robbery',
|
||||
site = 'red_chasm'): Action {
|
||||
const offer = viewGame(g, actor).nexusMoritani; assert.ok(offer);
|
||||
return { type: 'decision', token: mixedE3MoritaniToken(g, kind).id, territory: site, nexus: offer.event };
|
||||
}
|
||||
/** Native Terror arrival on a real shipment; the token identity stays private. */
|
||||
export function mixedE3MoritaniTerrorReveal(g: Game, actor: string): Game {
|
||||
return stepMixedE3Moritani(stepMixedE3Moritani(g, { actor, action: { type: 'decision', reveal: true } }),
|
||||
{ actor, action: { type: 'decision', choice: 'spice' } });
|
||||
}
|
||||
export function assertMixedE3MoritaniRejects(g: Game, actor: string, action: Action): void {
|
||||
const before = mixedE3MoritaniReload(g);
|
||||
assert.throws(() => applyAction(g, actor, action), 'Rejected consumers must not spend original stock.');
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
@@ -420,7 +420,7 @@ void test('all four bots respect the projected count cap for both Ambassador rou
|
||||
}
|
||||
});
|
||||
|
||||
void test('movement preflight permits the low two-force Intrusion arrival and keeps unsupported three-force ordering immutable', () => {
|
||||
void test('movement preflight commits each Intrusion arrival and defers its Terror entry behind the pending Intrusion', () => {
|
||||
for (const reserves of [7, 8]) {
|
||||
const g = fixture(reserves, 'emperor', true);
|
||||
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
|
||||
@@ -444,13 +444,23 @@ void test('movement preflight permits the low two-force Intrusion arrival and ke
|
||||
assert.equal(result.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(player(result, 'e').forces['arrakeen:10'], 2);
|
||||
homeworldGameIntegrity(result);
|
||||
} else reject(g, 'e', move, /Terror combined/);
|
||||
reject(
|
||||
g,
|
||||
'e',
|
||||
{ ...move, forces: { 'imperial_basin:10': 3 } },
|
||||
/Terror combined/,
|
||||
);
|
||||
} else {
|
||||
const result = applyAction(g, 'e', move);
|
||||
assert.equal(result.decision?.kind, 'intrusion');
|
||||
assert.equal(result.pendingArrivalReaction?.[0]?.amount, 2,
|
||||
'The committed entry waits for the Intrusion choice.');
|
||||
assert.equal(result.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(player(result, 'e').forces['arrakeen:10'], 2);
|
||||
assert.equal(player(result, 'e').forces['imperial_basin:10'], 1);
|
||||
homeworldGameIntegrity(result);
|
||||
}
|
||||
const three = applyAction(g, 'e', { ...move, forces: { 'imperial_basin:10': 3 } });
|
||||
assert.equal(three.decision?.kind, 'intrusion');
|
||||
assert.equal(three.pendingArrivalReaction?.[0]?.amount, 3,
|
||||
'The whole committed group is one deferred entry.');
|
||||
assert.equal(three.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(player(three, 'e').forces['arrakeen:10'], 3);
|
||||
homeworldGameIntegrity(three);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -336,26 +336,33 @@ void test('the Ix prototype keeps public gates and rejects unsupported or nonemp
|
||||
unsupported.players.forEach((p) => (p.ready = true));
|
||||
rejectInitializer(unsupported, /base, Ixian and Tleilaxu/);
|
||||
|
||||
const moduleFactories: Array<[string, (g: Game) => Game]> = [
|
||||
const moduleFactories: Array<[string, (g: Game) => Game, RegExp]> = [
|
||||
[
|
||||
'tech',
|
||||
(g) => applyAction(g, g.host, { type: 'techTokens', enabled: true }),
|
||||
/Tech Tokens require a fresh supported three-through-six-seat lobby/,
|
||||
],
|
||||
[
|
||||
'homeworld',
|
||||
(g) => applyAction(g, g.host, { type: 'homeworlds', enabled: true }),
|
||||
/without optional modules/,
|
||||
],
|
||||
[
|
||||
'stronghold',
|
||||
(g) => applyAction(g, g.host, { type: 'strongholdCards', enabled: true }),
|
||||
/Stronghold Cards require a fresh supported Advanced lobby/,
|
||||
],
|
||||
[
|
||||
'nexus',
|
||||
(g) => ({ ...g, nexusCards: { cards: null, phase: null } }),
|
||||
/without optional modules/,
|
||||
],
|
||||
['nexus', (g) => ({ ...g, nexusCards: { cards: null, phase: null } })],
|
||||
];
|
||||
for (const [name, enable] of moduleFactories) {
|
||||
for (const [name, enable, rejection] of moduleFactories) {
|
||||
let g = enable(lobby(name === 'stronghold'));
|
||||
for (const p of g.players)
|
||||
if (!p.ready) g = applyAction(g, p.id, { type: 'ready' });
|
||||
rejectInitializer(g, /without optional modules/);
|
||||
rejectInitializer(g, rejection);
|
||||
}
|
||||
|
||||
const occupied = lobby();
|
||||
|
||||
@@ -114,7 +114,7 @@ void test('all fourteen physical skills reach genuine Ix card selection, Traitor
|
||||
assert.equal(player(offers, 't').faceDancers, undefined);
|
||||
});
|
||||
|
||||
void test('genuine ready Ix lobbies keep public starts, Advanced and additional module profiles closed', () => {
|
||||
void test('genuine ready Ix lobbies keep public starts closed and initialize the supported native module profiles', () => {
|
||||
const lobby = () => {
|
||||
let g = createGame('IXGATED', newPlayer('i', 'Ixians', 'ixians'), false, ['ix']);
|
||||
joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
@@ -123,19 +123,27 @@ void test('genuine ready Ix lobbies keep public starts, Advanced and additional
|
||||
return g;
|
||||
};
|
||||
reject(lobby(), 'i', {type: 'start'});
|
||||
// The supported native Advanced, Discovery, Tech Token and Homeworld
|
||||
// profiles now initialize for a fresh Ix lobby without mutating it.
|
||||
for (const change of [
|
||||
(g: Game) => { g.advanced = true; },
|
||||
(g: Game) => { g.discoveryEnabled = true; },
|
||||
(g: Game) => { g.techTokens = createTechTokens(); },
|
||||
(g: Game) => { g.expansions.push('choam'); },
|
||||
(g: Game) => { g.homeworlds = {custody: null}; },
|
||||
]) {
|
||||
const g = lobby();
|
||||
change(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(g));
|
||||
assert.deepEqual(g, before);
|
||||
assert.ok(initializeLeaderSkillsGameForAudit(g).leaderSkills,
|
||||
'The supported native module profile initializes.');
|
||||
assert.deepEqual(g, before, 'The authoritative lobby is never mutated.');
|
||||
}
|
||||
// A combined CHOAM expansion still rejects.
|
||||
const combined = lobby();
|
||||
combined.expansions.push('choam');
|
||||
const before = structuredClone(combined);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(combined));
|
||||
assert.deepEqual(combined, before);
|
||||
});
|
||||
|
||||
void test('Planetologist gives selected suboids range two independently and cyborg range three through saved native permission', () => {
|
||||
|
||||
@@ -261,11 +261,15 @@ void test('BG intrusion uses current opposing fighters, requires Advanced rules
|
||||
quoteCompletedMovementArrival({ ...input, players: advisors }).intrusion,
|
||||
false,
|
||||
);
|
||||
for (const tokens of [{ ambassadors: [ambassador] }, { terror: [terror] }])
|
||||
assert.throws(
|
||||
() => quoteCompletedMovementArrival({ ...input, players, ...tokens }),
|
||||
/another arrival reaction/,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteCompletedMovementArrival({ ...input, players, ambassadors: [ambassador] }),
|
||||
/Ambassadors combined/,
|
||||
);
|
||||
// A Terror entry behind the pending Intrusion is now committed and deferred.
|
||||
assert.equal(
|
||||
quoteCompletedMovementArrival({ ...input, players, terror: [terror] }).reaction,
|
||||
'terror',
|
||||
);
|
||||
});
|
||||
|
||||
void test('mover advisor stance is the declared resulting stance, not its stale destination map', () => {
|
||||
@@ -295,14 +299,13 @@ void test('mover advisor stance is the declared resulting stance, not its stale
|
||||
}).reaction,
|
||||
null,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteCompletedMovementArrival({
|
||||
...base,
|
||||
terror: [terror],
|
||||
order: { ...base.order, advisors: true, wantsFighters: true },
|
||||
}),
|
||||
/Terror combined/,
|
||||
assert.equal(
|
||||
quoteCompletedMovementArrival({
|
||||
...base,
|
||||
terror: [terror],
|
||||
order: { ...base.order, advisors: true, wantsFighters: true },
|
||||
}).reaction,
|
||||
'terror',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -348,14 +351,14 @@ void test('current controls and the move-created fighter response are checked wi
|
||||
/Ambassadors combined/,
|
||||
);
|
||||
for (const flag of ['response', 'decision'] as const)
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteCompletedMovementArrival({
|
||||
...input,
|
||||
terror: [terror],
|
||||
controls: { ...input.controls, [flag]: true },
|
||||
}),
|
||||
/Terror combined/,
|
||||
assert.equal(
|
||||
quoteCompletedMovementArrival({
|
||||
...input,
|
||||
terror: [terror],
|
||||
controls: { ...input.controls, [flag]: true },
|
||||
}).reaction,
|
||||
'terror',
|
||||
`A committed ${flag} arrival defers its Terror entry instead of rejecting it.`,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, RuleError, viewGame } from '../game/engine';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import {
|
||||
advanceMixedE3EcazNexus as advance, createMixedE3EcazNexusCoalition as coalition,
|
||||
createMixedE3EcazNexusProgramme as programme, createMixedE3EcazNexusSetup as setup,
|
||||
mixedE3EcazNexusClean as clean, mixedE3EcazNexusInventory as inventory,
|
||||
mixedE3EcazNexusPlayer as player, mixedE3EcazNexusPolicy as policy,
|
||||
mixedE3EcazNexusReload as reload, nextMixedE3EcazNexusStep as next,
|
||||
quietMixedE3EcazNexusChoice as quiet, quoteMixedE3EcazNexusBattle as quote,
|
||||
revealMixedE3EcazNexus as reveal, shipMixedE3EcazNexus as ship,
|
||||
stepMixedE3EcazNexus as step,
|
||||
} from './fixture-mixed-e3-ecaz-nexus';
|
||||
import type { MixedE3EcazNexusBattle, MixedE3EcazNexusStep } from './fixture-mixed-e3-ecaz-nexus';
|
||||
|
||||
function reject(g: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, actor, action), RuleError);
|
||||
assert.deepEqual(g, before, 'Rejected physical contributions cannot debit purses, move forces or consume discs');
|
||||
}
|
||||
function policies(g: Game, actor: string, effect: (after: Game, action: Action) => void): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const action = policy(g, actor, difficulty)[0]; assert.ok(action, `${difficulty} requires this genuine owned window`);
|
||||
const after = step(g, { actor, action }); effect(after, action); inventory(after);
|
||||
}
|
||||
}
|
||||
/** Test-owned choices, not scheduler defaults. Every casualty/reward boundary
|
||||
* is retained for a human using the reusable next-step controller. */
|
||||
function settlementChoice(g: Game): MixedE3EcazNexusStep | null {
|
||||
if (g.response || g.phaseOpening) return null;
|
||||
const d = g.decision; if (!d) return null;
|
||||
if (d.kind === 'battleLosses') return { actor: d.player, action: { type: 'decision', choice: 0 } };
|
||||
if (d.kind === 'battleCards') return { actor: d.player, action: { type: 'decision', discard: [] } };
|
||||
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
|
||||
if (d.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
|
||||
if (d.kind === 'leaderSkillVisibility') return { actor: d.player,
|
||||
action: { type: 'leaderSkillVisibility', event: d.event, hide: true } };
|
||||
if (d.kind === 'ixSubstitution') return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d.kind === 'strongholdCopy') return { actor: d.player,
|
||||
action: { type: 'decision', event: d.event, stronghold: d.choices[0] } };
|
||||
if (d.kind === 'sukRescue') {
|
||||
const maximum = Math.max(...d.options.map(o => o.normal + o.elite));
|
||||
const choice = d.options.findIndex(o => o.normal + o.elite === maximum);
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, choice } };
|
||||
}
|
||||
if (d.kind === 'moritaniRetention') return { actor: d.player, action: { type: 'decision', keep: null,
|
||||
...(d.source === 'nexus' ? { event: d.event } : {}) } };
|
||||
return quiet(g);
|
||||
}
|
||||
function settle(g: Game, until: (g: Game) => boolean = s => !s.battle && clean(s), exercise = false): Game {
|
||||
for (let n = 0; n < 700; n++) {
|
||||
if (!g.response && !g.phaseOpening && until(g)) return g;
|
||||
const d = !g.response && !g.phaseOpening ? g.decision : null;
|
||||
if (exercise && d?.kind === 'battleLosses') policies(g, d.player, (after, action) => {
|
||||
const option = d.options[Number(action.choice)], owner = d.forceOwner ?? d.player; assert.ok(option);
|
||||
assert.equal(player(after, owner).tanks, player(g, owner).tanks + option.normal + option.elite);
|
||||
});
|
||||
if (exercise && d?.kind === 'battleCards') policies(g, d.player, (after, action) => {
|
||||
for (const id of action.discard as string[]) {
|
||||
assert.ok(!player(after, d.player).hand.some(c => c.id === id));
|
||||
assert.equal(after.discard.filter(c => c.id === id).length, 1);
|
||||
}
|
||||
});
|
||||
if (exercise && d?.kind === 'techToken') policies(g, d.player, (after, action) => {
|
||||
const token = action.token as keyof NonNullable<Game['techTokens']>; assert.ok(d.choices.includes(token));
|
||||
assert.equal(after.techTokens![token].owner, d.player);
|
||||
assert.equal(player(after, d.player).spice, player(g, d.player).spice);
|
||||
});
|
||||
const control = next(g) ?? settlementChoice(g); assert.ok(control, `Test owner must choose ${g.decision?.kind}`);
|
||||
g = step(g, control);
|
||||
}
|
||||
throw Error('Original mixed Ecaz aftermath did not reach its physical boundary');
|
||||
}
|
||||
function chooseLead(f: MixedE3EcazNexusBattle, lead: string): Game {
|
||||
const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead');
|
||||
const chosen = step(f.game, { actor: f.owner, action: { type: 'decision', event: d.event, lead } });
|
||||
return advance(chosen, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
|
||||
undefined, s => s.decision?.kind === 'leaderSkillVisibility'
|
||||
? { actor: s.decision.player, action: { type: 'leaderSkillVisibility', event: s.decision.event, hide: s.decision.player === f.owner } }
|
||||
: quiet(s));
|
||||
}
|
||||
/** Highest- or lowest-strength legal commitment for one combatant, chosen by
|
||||
* trying the engine's own plan validator. Any pending posture declaration for
|
||||
* this seat is answered first, because a skilled disc must be hidden before use. */
|
||||
function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min',
|
||||
extra: { weapon?: string; defense?: string } = {}): { steps: MixedE3EcazNexusStep[]; action: Action } {
|
||||
const steps: MixedE3EcazNexusStep[] = [];
|
||||
let base = g;
|
||||
for (let n = 0; n < 6; n++) {
|
||||
const decision = base.decision;
|
||||
if (decision?.kind !== 'leaderSkillVisibility') break;
|
||||
const posture: MixedE3EcazNexusStep = { actor: decision.player,
|
||||
action: { type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === actor } };
|
||||
steps.push(posture); base = step(base, posture);
|
||||
}
|
||||
const spice = player(base, actor).spice;
|
||||
const supports = prefer === 'max' ? Array.from({ length: spice + 1 }, (_, i) => spice - i) : [0];
|
||||
const dials = prefer === 'max' ? Array.from({ length: 13 }, (_, i) => 12 - i) : [0];
|
||||
const reasons = new Set<string>();
|
||||
for (const support of supports) for (const dial of dials) {
|
||||
for (const card of [extra, {}]) {
|
||||
const action: Action = { type: 'battlePlan', leader, dial, support, ...card };
|
||||
try { applyAction(reload(base), actor, action); return { steps: [...steps, { actor, action }], action }; }
|
||||
catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
}
|
||||
throw Error(`No legal battle plan for ${actor} (spice ${spice}): ${[...reasons].join(' | ')}`);
|
||||
}
|
||||
/** The seat's own trained disc first (its skill needs the hidden posture), then
|
||||
* the strongest available discs; the first combination the engine accepts wins. */
|
||||
function legalLeadPlan(g: Game, actor: string, prefer: 'max' | 'min',
|
||||
extra: { weapon?: string; defense?: string } = {}): { steps: MixedE3EcazNexusStep[]; action: Action } {
|
||||
const trained = new Set((g.leaderSkills?.assignments ?? []).filter(a => a.owner === actor).map(a => a.leader));
|
||||
const candidates = player(g, actor).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => (Number(trained.has(b.id)) - Number(trained.has(a.id))) || b.strength - a.strength);
|
||||
const reasons = new Set<string>();
|
||||
for (const leader of candidates) {
|
||||
try { return legalPlan(g, actor, leader.id, prefer, extra); }
|
||||
catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
throw Error(`No legal battle plan for ${actor}: ${[...reasons].join(' | ')}`);
|
||||
}
|
||||
function coalitionPlans(f: MixedE3EcazNexusBattle, g: Game, lead: string): MixedE3EcazNexusStep[] {
|
||||
const worthless = player(g, lead).hand.find(c => c.kind === 'worthless');
|
||||
const first = legalLeadPlan(g, lead, 'max', worthless ? { defense: worthless.id } : {});
|
||||
let after = g; for (const plan of first.steps) after = step(after, plan);
|
||||
const second = legalLeadPlan(after, f.opponent, 'min');
|
||||
return [...first.steps, ...second.steps];
|
||||
}
|
||||
/** The chosen battle plan of one seat inside a scheduled step list. */
|
||||
function plannedStep(plans: readonly MixedE3EcazNexusStep[], actor: string): Action {
|
||||
const step = plans.find(candidate => candidate.actor === actor && candidate.action.type === 'battlePlan');
|
||||
assert.ok(step, `${actor} must have a scheduled battle plan`);
|
||||
return step.action;
|
||||
}
|
||||
|
||||
void test('minimal original two-seat E3/E1 training and three-seat E3/E2 no-training closing preserve native discs, cache and paid counters', () => {
|
||||
const lobby = createGame('MIXEDORIGINAL2', newPlayer('human-ecaz', 'Human Ecaz', 'ecaz'), false, ['ecaz', 'ix']);
|
||||
joinGame(lobby, newPlayer('human-tl', 'Human Tleilaxu', 'tleilaxu'));
|
||||
const two = setup({ initial: lobby, skills: true, homeworlds: true });
|
||||
assert.equal(player(two.afterSetup, two.owner).leaders.length, 5);
|
||||
assert.equal(two.afterSetup.leaderSkills!.assignments.length, 2);
|
||||
assert.ok(two.afterSetup.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
|
||||
assert.equal(two.afterSetup.dukeVidal!.controller, null);
|
||||
const held = player(two.afterSetup, two.owner).hand.map(c => c.id);
|
||||
assert.equal(held.length, 1); inventory(two.afterSetup);
|
||||
const supplied = setup({ initial: two.offered, skills: true, homeworlds: true });
|
||||
assert.deepEqual(player(supplied.afterSetup, supplied.owner).hand.map(c => c.id), held);
|
||||
assert.equal(player(supplied.afterSetup, supplied.owner).reserves, 14);
|
||||
assert.equal(Object.values(player(supplied.afterSetup, supplied.owner).forces).reduce((n, f) => n + f, 0), 6);
|
||||
const three = programme({ families: ['choam'], roster: ['ecaz', 'richese', 'guild'], advanced: false, skills: false, tech: true });
|
||||
assert.equal(three.game.leaderSkills, undefined);
|
||||
assert.equal(three.game.richeseCache!.length + three.game.richeseRemoved!.length +
|
||||
[...three.game.deck, ...three.game.discard, ...three.game.players.flatMap(p => p.hand)].filter(c => c.id.startsWith('richese-')).length, 10);
|
||||
assert.equal(three.game.nexusCards!.cards!.hands[three.owner], 'ecaz');
|
||||
assert.equal(player(three.alliance!, three.seats.richese!).ally, three.seats.guild);
|
||||
assert.ok(player(three.claim!.after, three.owner).spice < player(three.claim!.before, three.owner).spice);
|
||||
inventory(three.game);
|
||||
});
|
||||
|
||||
void test('all-three-family six-native original setup and real alliance closing retain all14, native20, separate Duke and original rewards', () => {
|
||||
const f = programme({ families: ['ix', 'choam'], roster: ['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'],
|
||||
advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true });
|
||||
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 6);
|
||||
assert.equal(f.afterSetup.dukeVidal!.leader.id, DUKE_VIDAL_ID);
|
||||
assert.equal(player(f.afterSetup, f.owner).leaders.length, 5);
|
||||
assert.ok(f.afterSetup.ecazOccupyPreview && f.afterSetup.moritaniAssassinatePreview);
|
||||
assert.equal(player(f.afterSetup, f.seats.tleilaxu!).faceDancers!.length, 3);
|
||||
assert.equal(f.afterSetup.richeseCache!.length, 10);
|
||||
assert.ok(f.ixOffer); assert.ok(player(f.ixOffer.after, f.seats.ixians!).hand.some(c => c.id === f.ixOffer!.step.action.card));
|
||||
assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.equal(player(f.alliance!, f.seats.choam!).ally, f.seats.richese);
|
||||
assert.equal(f.firstMentat!.after.strongholdCards!.owners.sietch_tabr, f.owner);
|
||||
assert.equal(player(f.claim!.after, f.owner).reserves, player(f.claim!.before, f.owner).reserves - 1);
|
||||
inventory(f.game);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced selected Warmaster/HW/Tech/held Tabr' : 'Basic even-FORCE/no-Skills'} native mixed Ecaz coalition keeps its chosen lead, labelled support and physical payer/casualty ownership`, () => {
|
||||
const f = coalition({ advanced, skills: advanced, homeworlds: advanced, tech: true, strongholds: advanced });
|
||||
const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead'); assert.equal(next(f.game), null);
|
||||
reject(f.game, f.ally, { type: 'decision', event: d.event, lead: f.owner });
|
||||
reject(f.game, f.owner, { type: 'decision', event: d.event, lead: f.opponent });
|
||||
for (const t of f.arrivals) {
|
||||
const count = Number(t.step.action.amount), actor = t.step.actor;
|
||||
assert.equal(player(t.after, actor).reserves, player(t.before, actor).reserves - count);
|
||||
assert.equal(player(t.after, actor).forces[f.location], (player(t.before, actor).forces[f.location] ?? 0) + count);
|
||||
assert.ok(player(t.after, actor).spice < player(t.before, actor).spice);
|
||||
}
|
||||
policies(f.game, f.owner, (after, action) => {
|
||||
assert.ok([f.owner, f.ally].includes(String(action.lead)));
|
||||
assert.equal(player(after, f.owner).spice, player(f.game, f.owner).spice);
|
||||
assert.equal(player(after, f.ally).spice, player(f.game, f.ally).spice);
|
||||
});
|
||||
for (const lead of [f.owner, f.ally]) {
|
||||
const plansWindow = chooseLead(f, lead), plans = coalitionPlans(f, plansWindow, lead);
|
||||
if (advanced) {
|
||||
const used = plannedStep(plans, lead).leader;
|
||||
const trainedDisc = !!plansWindow.leaderSkills?.assignments.some(a => a.owner === lead && a.leader === used);
|
||||
assert.equal(plansWindow.battle!.leaderSkillHidden?.[lead] ?? false, trainedDisc,
|
||||
'A skilled disc is hidden exactly when the plan uses it');
|
||||
}
|
||||
policies(plansWindow, lead, (offered) => {
|
||||
assert.equal(player(offered, lead).spice, player(plansWindow, lead).spice, 'A sealed dial does not prepay support');
|
||||
const revealed = reveal(offered, plans.filter(plan => plan.actor === f.opponent)), q = quote(revealed), finished = settle(revealed);
|
||||
assert.equal(finished.lastBattleContext!.winner, q.winner);
|
||||
for (const payment of q.payments) {
|
||||
const heldIncome = q.strongholdIncome.filter(r => r.player === payment.player).reduce((n, r) => n + r.amount, 0);
|
||||
const collection = finished.phase === 7 ? quoteSpiceCollection(finished).receipts.find(r => r.player === payment.player)?.strongholds ?? 0 : 0;
|
||||
const income = (q.bounty?.player === payment.player ? q.bounty.amount : 0) +
|
||||
(q.choamIncome?.owner === payment.player ? q.choamIncome.amount : 0);
|
||||
assert.equal(player(finished, payment.player).spice, player(revealed, payment.player).spice - payment.ownPayment + heldIncome + collection + income);
|
||||
}
|
||||
inventory(finished);
|
||||
});
|
||||
const revealed = reveal(plansWindow, plans), q = quote(revealed), side = revealed.battle!.attacker === lead ? 'attacker' : 'defender';
|
||||
assert.equal(q.winner, lead);
|
||||
assert.deepEqual((q.fixedLosses ?? []).map(loss => loss.owner), [f.owner],
|
||||
'Only Ecaz’s printed fixed dial produces the coalition’s fixed losses.');
|
||||
assert.equal(q.casualties!.owner, f.ally);
|
||||
assert.equal(q.payments.find(p => p.player === lead)?.ownPayment ?? 0,
|
||||
advanced ? Number(plannedStep(plans, lead).support) : 0,
|
||||
'The chosen variable support is funded by its own payer; Basic has no support');
|
||||
assert.equal(q.leaderSkillBonuses[side].applied.some(a => a.skill === 'warmaster'), advanced && lead === f.owner);
|
||||
assert.ok(q.strongholdIncome.every(income => income.player === f.owner),
|
||||
'Only the Ecaz holder can receive the held Tabr income; an ally lead never borrows it');
|
||||
const done = settle(revealed, undefined, true), lostToken = ownedTech(revealed.techTokens, f.opponent)[0]; assert.ok(lostToken);
|
||||
assert.equal(done.techTokens![lostToken].owner, lead);
|
||||
const ecazFixed = (q.fixedLosses ?? []).reduce((n, loss) => n + loss.normal + loss.elite, 0);
|
||||
assert.equal(player(done, f.owner).tanks - player(plansWindow, f.owner).tanks, ecazFixed);
|
||||
assert.equal(player(done, f.ally).tanks - player(plansWindow, f.ally).tanks, 4);
|
||||
assert.equal(player(done, f.opponent).tanks - player(plansWindow, f.opponent).tanks, 8);
|
||||
inventory(done);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic odd mixed Occupy rejects an actual paid three-force contribution before lead selection or reward', () => {
|
||||
const f = coalition({ advanced: false, skills: false, tech: true }, 'ixians', 3);
|
||||
assert.equal(player(f.beforeBattle, f.owner).forces[f.location], 3);
|
||||
reject(f.beforeBattle, f.choice.actor, f.choice.action);
|
||||
assert.equal(f.beforeBattle.battle, null); inventory(f.beforeBattle);
|
||||
});
|
||||
|
||||
void test('E3/E2 classic-led coalition never borrows the hidden native Warmaster or Ecaz-held battle income', () => {
|
||||
const f = coalition({ families: ['choam'], roster: ['ecaz', 'choam', 'guild', 'emperor'],
|
||||
advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true }, 'emperor');
|
||||
const plansWindow = chooseLead(f, f.ally), revealed = reveal(plansWindow, coalitionPlans(f, plansWindow, f.ally));
|
||||
const q = quote(revealed), side = revealed.battle!.attacker === f.ally ? 'attacker' : 'defender';
|
||||
assert.equal(q.winner, f.ally);
|
||||
assert.ok(!q.leaderSkillBonuses[side].applied.some(a => a.skill === 'warmaster'));
|
||||
assert.deepEqual(q.strongholdIncome, []);
|
||||
const done = settle(revealed);
|
||||
assert.equal(player(done, f.owner).spice, player(plansWindow, f.owner).spice);
|
||||
assert.equal(done.techTokens![ownedTech(revealed.techTokens, f.opponent)[0]].owner, f.ally);
|
||||
inventory(done);
|
||||
});
|
||||
|
||||
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'no-Skills'} original all-three-family paid foreign arrival → quiet living Duke Cunning → source-local end-turn expiry`, () => {
|
||||
const f = programme({ families: ['ix', 'choam'], roster: ['ecaz', 'ixians', 'choam', 'guild', 'emperor'],
|
||||
skills, advanced: true, homeworlds: true, tech: true, strongholds: true });
|
||||
let g = advance(f.game, s => s.phase === 5 && s.active === f.owner && clean(s), undefined, quiet);
|
||||
const t = ship(g, f.owner, 'homeworld:emperor', 1);
|
||||
const sources = nativeShipmentSources(viewGame(t.before, f.owner), 1, 0); assert.ok(sources);
|
||||
const invoice = homeworldShipmentChoice(viewGame(t.before, f.owner), 'homeworld:emperor',
|
||||
Object.fromEntries(Object.entries(sources).filter(([, v]) => v.normal + v.elite > 0)));
|
||||
assert.equal(player(t.after, f.owner).spice, player(t.before, f.owner).spice - invoice.cost);
|
||||
assert.equal(combatArmy(t.after, f.owner, 'homeworld:emperor').normal, 1);
|
||||
assert.equal(combatArmy(t.after, f.seats.emperor!, 'homeworld:emperor').normal,
|
||||
combatArmy(t.before, f.seats.emperor!, 'homeworld:emperor').normal);
|
||||
g = advance(t.after, s => s.phase === 6 && clean(s) && !s.battle, undefined, quiet);
|
||||
const action = nexusEcazDukeAction(viewGame(g, f.owner)); assert.ok(action);
|
||||
const trainer = g.leaderSkills?.assignments.find(a => a.owner === f.owner), purse = player(g, f.owner).spice;
|
||||
policies(g, f.owner, after => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
|
||||
assert.equal(after.dukeVidal!.controller, f.owner); assert.equal(after.dukeVidal!.source, 'ecazNexus');
|
||||
assert.equal(player(after, f.owner).spice, purse);
|
||||
});
|
||||
reject(g, f.seats.choam!, action);
|
||||
const spent = step(g, { actor: f.owner, action }); reject(spent, f.owner, action);
|
||||
// The original emperor-native battle is resolved rather than skipped or rewritten.
|
||||
let opened = advance(spent, s => s.phase === 6 && clean(s) && !!s.active, undefined, quiet);
|
||||
const menu = viewGame(opened, opened.active!).battleChoices.find(c => c.territory === 'homeworld:emperor'); assert.ok(menu);
|
||||
opened = step(opened, { actor: menu.chooser, action: { type: 'chooseBattle', territory: menu.territory,
|
||||
target: menu.attacker === menu.chooser ? menu.defender : menu.attacker } });
|
||||
const plansWindow = advance(opened, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, undefined, quiet);
|
||||
assert.ok(!plansWindow.battle!.ecazOccupy, 'Foreign native home never inherits Arrakis Occupy');
|
||||
const ownerDisc = player(plansWindow, f.owner).leaders.filter(l => !l.dead && l.id !== trainer?.leader).sort((a, b) => a.strength - b.strength)[0];
|
||||
const nativeDisc = player(plansWindow, f.seats.emperor!).leaders.filter(l => !l.dead &&
|
||||
!plansWindow.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
const revealed = reveal(plansWindow, [{ actor: f.owner, action: { type: 'battlePlan', leader: ownerDisc.id, dial: 0, support: 0 } },
|
||||
{ actor: f.seats.emperor!, action: { type: 'battlePlan', leader: nativeDisc.id, dial: 2, support: 2 } }]);
|
||||
const q = quote(revealed); assert.equal(q.winner, f.seats.emperor);
|
||||
assert.equal(q.payments.find(p => p.player === f.seats.emperor)!.ownPayment, 2);
|
||||
const done = settle(revealed), later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
|
||||
assert.equal(later.dukeVidal!.controller, null); assert.equal(later.dukeVidal!.source, null);
|
||||
assert.equal(later.dukeVidal!.leader.dead, false);
|
||||
if (trainer) assert.ok(later.leaderSkills!.assignments.some(a => a.owner === trainer.owner && a.leader === trainer.leader && a.skill === trainer.skill),
|
||||
'Temporary Duke expiry never retires the ordinary native trainer');
|
||||
inventory(later);
|
||||
});
|
||||
|
||||
void test('original E3/E1 Discovery uses its actual random face, typed arrival, all Maker votes and ride before both-pile alliance-change closing draw', () => {
|
||||
const f = programme({ roster: ['ecaz', 'ixians', 'guild', 'fremen'], advanced: true, skills: true,
|
||||
discovery: true, homeworlds: true, tech: true, strongholds: true });
|
||||
assert.ok(f.firstArrival && f.reveal && f.vote && f.ride && f.alliance && f.drawing);
|
||||
const token = f.reveal.before.discoveries!.tokens.find(t => t.id === f.reveal!.step.action.token)!;
|
||||
assert.equal(f.reveal.after.discoveries!.tokens.find(t => t.id === token.id)!.face, token.face);
|
||||
assert.equal(player(f.firstArrival.after, f.owner).reserves, player(f.firstArrival.before, f.owner).reserves - 2);
|
||||
assert.ok(player(f.firstArrival.after, f.owner).spice < player(f.firstArrival.before, f.owner).spice);
|
||||
assert.equal(f.votes.length, 4);
|
||||
for (const vote of f.votes) policies(vote.before, vote.step.actor, after => {
|
||||
assert.equal(player(after, vote.step.actor).spice, player(vote.before, vote.step.actor).spice);
|
||||
assert.deepEqual(combatArmy(after, f.owner, 'gara_kulon'), combatArmy(vote.before, f.owner, 'gara_kulon'));
|
||||
});
|
||||
assert.equal(f.ride.step.actor, f.seats.fremen);
|
||||
assert.equal(player(f.ride.after, f.seats.fremen!).forces['polar_sink:0'],
|
||||
(player(f.ride.before, f.seats.fremen!).forces['polar_sink:0'] ?? 0) + 2);
|
||||
assert.equal(player(f.ride.after, f.seats.fremen!).elites!.forces['polar_sink:0'],
|
||||
(player(f.ride.before, f.seats.fremen!).elites!.forces['polar_sink:0'] ?? 0) + 1);
|
||||
if (f.entry) {
|
||||
const own = viewGame(f.entry.before, f.owner), typed = discoveryEntryMoveAction(own, own.discoveryEntry!.sources); assert.ok(typed);
|
||||
policies(f.entry.before, f.owner, after => {
|
||||
assert.equal(player(after, f.owner).spice, player(f.entry!.before, f.owner).spice);
|
||||
assert.equal(player(after, f.owner).reserves, player(f.entry!.before, f.owner).reserves);
|
||||
});
|
||||
// The engine transfers the signed arrival into the revealed face's own
|
||||
// sector-0 group (`place(p, arrival.destination, 0, …)`); the destination is
|
||||
// the token face verbatim, never a renamed territory.
|
||||
const arrived = player(f.entry.after, f.owner).forces[`${token.face}:0`];
|
||||
assert.equal(arrived, 2, 'The entered forces land under the revealed face key');
|
||||
assert.equal((player(f.entry.before, f.owner).forces[`${token.face}:0`] ?? 0) + 2, arrived,
|
||||
'The entry conserves the moving forces at the exact face key');
|
||||
assert.equal(player(f.entry.after, f.owner).forces[`${token.face}:0`],
|
||||
player(f.entry.before, f.owner).forces[`gara_kulon:8`] ?? 0,
|
||||
'The same two forces leave their Discovery arrival sector');
|
||||
assert.ok(!f.entry.after.pendingAmbassador && !f.entry.after.pendingTerrorEntry);
|
||||
}
|
||||
assert.ok(f.drawing.spiceDiscard[0].some(c => 'territory' in c && c.territory === 'rock_outcroppings'));
|
||||
assert.ok(f.drawing.spiceDiscard[1].some(c => 'territory' in c && c.territory === 'oh_gap'));
|
||||
assert.equal(player(f.alliance, f.seats.guild!).ally, f.seats.fremen);
|
||||
policies(f.draws[0].before, f.owner, after => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.ok(!after.nexusCards!.cards!.deck.includes('ecaz'));
|
||||
});
|
||||
inventory(f.game);
|
||||
});
|
||||
|
||||
void test('native Ecaz winner cleanup/Tech precedes an original Face Dance reveal without moving either seat’s training card', () => {
|
||||
const f = coalition({ roster: ['ecaz', 'tleilaxu', 'guild', 'emperor'], advanced: true, skills: true,
|
||||
homeworlds: true, tech: true, strongholds: true }, 'emperor');
|
||||
const tl = f.seats.tleilaxu!, trainer = f.trainers[f.owner];
|
||||
assert.ok(player(f.afterSetup, tl).faceDancers!.some(c => !c.revealed));
|
||||
const plansWindow = chooseLead(f, f.owner), held = player(plansWindow, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(held);
|
||||
// The Face Dance always targets the winner's own winning disc, so keep that
|
||||
// disc untrained: otherwise a genuine reveal would legitimately retire the
|
||||
// seat's training card and this case could not assert a stationary card.
|
||||
const trained = new Set((plansWindow.leaderSkills?.assignments ?? []).filter(a => a.owner === f.owner).map(a => a.leader));
|
||||
const winnerDisc = player(plansWindow, f.owner).leaders.filter(l => !l.dead && !l.usedAt && !trained.has(l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0]; assert.ok(winnerDisc, 'The winner needs an untrained disc for the Face Dance');
|
||||
const first = legalPlan(plansWindow, f.owner, winnerDisc.id, 'max', { defense: held.id });
|
||||
let afterWinner = plansWindow; for (const plan of first.steps) afterWinner = step(afterWinner, plan);
|
||||
const second = legalLeadPlan(afterWinner, f.opponent, 'min');
|
||||
const plans = [...first.steps, ...second.steps];
|
||||
const plan = plans.find(candidate => candidate.actor === f.owner && candidate.action.type === 'battlePlan');
|
||||
assert.ok(plan); plan.action.defense = held.id;
|
||||
const revealed = reveal(plansWindow, plans), q = quote(revealed); assert.equal(q.winner, f.owner);
|
||||
const lostTokens = ownedTech(revealed.techTokens, f.opponent);
|
||||
const dance = settle(revealed, s => s.decision?.kind === 'faceDance', true);
|
||||
assert.equal(dance.battle, null, 'Original battle cleanup is complete before Face Dance');
|
||||
for (const token of lostTokens) assert.equal(dance.techTokens![token].owner, f.owner);
|
||||
const decision = dance.decision; assert.ok(decision?.kind === 'faceDance');
|
||||
assert.equal(decision.player, tl); assert.equal(decision.winner, f.owner);
|
||||
const identity = decision.identity;
|
||||
const dancerCard = identity ? player(dance, tl).faceDancers!.find(dancer => dancer.leader === identity && !dancer.revealed) : undefined;
|
||||
const survivor = combatArmy(dance, f.owner, f.location.split(':')[0]);
|
||||
const nativeTraining = dance.leaderSkills!.assignments.find(a => a.owner === tl)!;
|
||||
policies(dance, tl, (after, action) => {
|
||||
const replaced = !!action.reveal;
|
||||
if (replaced) {
|
||||
const count = Object.values((action.sources ?? {}) as Record<string, number>).reduce((n, f) => n + f, 0);
|
||||
assert.equal(player(after, f.owner).leaders.find(l => l.id === identity)!.dead, true);
|
||||
assert.equal(player(after, tl).faceDancers!.find(dancer => dancer.leader === identity)!.revealed, true);
|
||||
assert.deepEqual(combatArmy(after, f.owner, f.location.split(':')[0]), { normal: 0, elite: 0 });
|
||||
assert.equal(combatArmy(after, tl, f.location.split(':')[0]).normal, count);
|
||||
} else {
|
||||
assert.deepEqual(combatArmy(after, f.owner, f.location.split(':')[0]), survivor);
|
||||
}
|
||||
assert.ok(after.leaderSkills!.assignments.some(a => a.owner === tl && a.leader === nativeTraining.leader && a.skill === nativeTraining.skill));
|
||||
assert.ok(after.leaderSkills!.assignments.some(a => a.owner === f.owner && a.leader === trainer && a.skill === 'warmaster'),
|
||||
'Neither seat’s training card moves through an original Face Dance');
|
||||
for (const token of lostTokens) assert.equal(after.techTokens![token].owner, f.owner);
|
||||
});
|
||||
assert.ok(player(dance, f.owner).hand.some(c => c.id === held.id), 'Kept winner card remains owned until its actual cleanup choice');
|
||||
if (dancerCard) {
|
||||
const replaced = step(dance, { actor: tl, action: { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 14 } });
|
||||
assert.equal(player(replaced, f.owner).leaders.find(l => l.id === identity)!.dead, true);
|
||||
assert.equal(combatArmy(replaced, tl, f.location.split(':')[0]).normal, 1);
|
||||
assert.ok(!player(replaced, tl).faceDancers!.some(dancer => dancer.leader === identity && !dancer.revealed));
|
||||
assert.equal(player(replaced, tl).spice, player(dance, tl).spice);
|
||||
inventory(replaced);
|
||||
} else {
|
||||
assert.ok(!identity || !player(dance, tl).faceDancers!.some(dancer => dancer.leader === identity && !dancer.revealed),
|
||||
'A reveal is offered only for a held unrevealed Face Dancer');
|
||||
const declined = step(dance, { actor: tl, action: { type: 'decision', reveal: false } });
|
||||
assert.deepEqual(combatArmy(declined, f.owner, f.location.split(':')[0]), survivor);
|
||||
assert.ok(declined.leaderSkills!.assignments.some(a => a.owner === f.owner && a.leader === trainer));
|
||||
inventory(declined);
|
||||
}
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced selected Warmaster/HW/Tech/held Tabr' : 'Basic even-FORCE/no-Skills'} native mixed Ecaz coalition keeps its chosen lead, labelled support and physical payer/casualty ownership`, () => {
|
||||
const f = coalition({ roster: ['ecaz', 'tleilaxu', 'guild', 'emperor'], advanced, skills: advanced,
|
||||
homeworlds: advanced, tech: true, strongholds: advanced }, 'tleilaxu');
|
||||
const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead');
|
||||
assert.equal(next(f.game), null, 'The coalition lead is always an owner decision.');
|
||||
reject(f.game, f.ally, { type: 'decision', event: d.event, lead: f.owner });
|
||||
reject(f.game, f.owner, { type: 'decision', event: d.event, lead: f.opponent });
|
||||
for (const arrival of f.arrivals) {
|
||||
const count = Number(arrival.step.action.amount), actor = arrival.step.actor;
|
||||
assert.equal(player(arrival.after, actor).reserves, player(arrival.before, actor).reserves - count);
|
||||
assert.equal(player(arrival.after, actor).forces[f.location],
|
||||
(player(arrival.before, actor).forces[f.location] ?? 0) + count);
|
||||
assert.ok(player(arrival.after, actor).spice < player(arrival.before, actor).spice);
|
||||
}
|
||||
for (const lead of [f.owner, f.ally]) {
|
||||
const plansWindow = chooseLead(f, lead), plans = coalitionPlans(f, plansWindow, lead);
|
||||
policies(plansWindow, lead, (offered) => {
|
||||
assert.equal(player(offered, lead).spice, player(plansWindow, lead).spice,
|
||||
'A sealed dial does not prepay support');
|
||||
const revealed = reveal(offered, [plans[1]]), finished = settle(revealed);
|
||||
assert.equal(finished.lastBattleContext!.winner, quote(revealed).winner);
|
||||
inventory(finished);
|
||||
});
|
||||
const revealed = reveal(plansWindow, plans), q = quote(revealed);
|
||||
const side = revealed.battle!.attacker === lead ? 'attacker' : 'defender';
|
||||
const leadPlan = plannedStep(plans, lead);
|
||||
assert.equal(q.winner, lead);
|
||||
assert.deepEqual((q.fixedLosses ?? []).map(loss => loss.owner), [f.owner],
|
||||
'Only Ecaz’s printed fixed dial produces the coalition’s fixed losses.');
|
||||
assert.equal(q.casualties!.owner, f.ally);
|
||||
assert.equal(q.payments.find(payment => payment.player === lead)?.ownPayment ?? 0,
|
||||
advanced ? Number(leadPlan.support) : 0,
|
||||
'The chosen variable support is funded by its own payer; Basic has no support');
|
||||
assert.equal(q.leaderSkillBonuses[side].applied.some(applied => applied.skill === 'warmaster'),
|
||||
advanced && lead === f.owner);
|
||||
assert.equal(q.scores![side], Number(leadPlan.dial) + q.leaderStrengths[side] + q.leaderSkillBonuses[side].bonus,
|
||||
'An Arrakis coalition cannot borrow either participating faction’s native Homeworld strength');
|
||||
assert.ok(q.strongholdIncome.every(income => income.player === f.owner),
|
||||
'Only the Ecaz holder can receive the held Tabr income; an ally lead never borrows it');
|
||||
const done = settle(revealed, undefined, true);
|
||||
const lostToken = ownedTech(revealed.techTokens, f.opponent)[0]; assert.ok(lostToken);
|
||||
assert.equal(done.techTokens![lostToken].owner, lead);
|
||||
const committed = (owner: string) => player(plansWindow, owner).forces[f.location] ?? 0;
|
||||
assert.equal(player(done, f.opponent).tanks - player(plansWindow, f.opponent).tanks, committed(f.opponent),
|
||||
'The losing side loses every committed counter');
|
||||
const winnerTanks = [f.owner, f.ally]
|
||||
.reduce((n, owner) => n + (player(done, owner).tanks - player(plansWindow, owner).tanks), 0);
|
||||
assert.ok(winnerTanks >= 1 && winnerTanks <= committed(f.owner) + committed(f.ally),
|
||||
'The winning coalition loses only its own dialed counters');
|
||||
inventory(done);
|
||||
}
|
||||
});
|
||||
|
||||
for (const skilled of [false, true]) void test(`original Ecaz native ${skilled ? 'hidden trained' : 'unused face-up normal'} Suk saves only the actual winning dialed casualties at the same home`, () => {
|
||||
const f = programme({ roster: ['ecaz', 'ixians', 'guild', 'emperor'], advanced: true, skills: true,
|
||||
skill: 'suk-graduate', homeworlds: true, tech: true, strongholds: true });
|
||||
const ix = f.seats.ixians!;
|
||||
let g = advance(f.game, s => s.phase === 5 && s.active === ix && clean(s), undefined, quiet);
|
||||
const arrival = ship(g, ix, 'homeworld:ecaz', 2, undefined, 1); g = arrival.after;
|
||||
assert.deepEqual(combatArmy(g, ix, 'homeworld:ecaz'), { normal: 1, elite: 1 });
|
||||
g = advance(g, s => s.phase === 6 && clean(s), undefined, quiet);
|
||||
const menu = viewGame(g, g.active!).battleChoices.find(c => c.territory === 'homeworld:ecaz'); assert.ok(menu);
|
||||
g = step(g, { actor: menu.chooser, action: { type: 'chooseBattle', territory: menu.territory,
|
||||
target: menu.attacker === menu.chooser ? menu.defender : menu.attacker } });
|
||||
const plansWindow = advance(g, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
|
||||
undefined, s => s.decision?.kind === 'leaderSkillVisibility'
|
||||
? { actor: s.decision.player, action: { type: 'leaderSkillVisibility', event: s.decision.event,
|
||||
hide: s.decision.player === f.owner && skilled } } : quiet(s));
|
||||
const trainer = f.trainers[f.owner];
|
||||
const nativeDisc = skilled ? player(plansWindow, f.owner).leaders.find(l => l.id === trainer)!
|
||||
: player(plansWindow, f.owner).leaders.filter(l => !l.dead && l.id !== trainer).sort((a, b) => b.strength - a.strength)[0];
|
||||
const visitorDisc = player(plansWindow, ix).leaders.filter(l => !l.dead &&
|
||||
!plansWindow.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
|
||||
const ownerPlan = legalPlan(plansWindow, f.owner, nativeDisc.id, 'max');
|
||||
let afterOwner = plansWindow; for (const plan of ownerPlan.steps) afterOwner = step(afterOwner, plan);
|
||||
const visitorPlan = legalPlan(afterOwner, ix, visitorDisc.id, 'max');
|
||||
const revealed = reveal(plansWindow, [...ownerPlan.steps, ...visitorPlan.steps]), q = quote(revealed);
|
||||
assert.equal(q.winner, f.owner);
|
||||
assert.equal(q.payments.find(payment => payment.player === f.owner)?.ownPayment ?? 0, Number(ownerPlan.action.support),
|
||||
'Native free strength does not fund dialed support');
|
||||
assert.equal(q.sukGraduate?.mode, skilled ? 'skilled' : 'normal',
|
||||
'Only the hidden trained disc rescues in the skilled band; the unused face-up disc stays ordinary');
|
||||
// The winner's own dialed casualties are the only counters a Suk may save.
|
||||
const losses = q.casualties!.options[0]!;
|
||||
const lossTotal = losses.normal + losses.elite;
|
||||
assert.ok(lossTotal >= 1, 'The winner must have real dialed casualties for the rescue window.');
|
||||
// The hidden trained disc exposes an owner rescue decision. The unused
|
||||
// face-up disc has exactly one legal rescue, which the engine applies itself,
|
||||
// so the boundary is either that decision or the settled post-rescue state.
|
||||
const rescue = settle(revealed, s => s.decision?.kind === 'sukRescue' || (!s.battle && clean(s)));
|
||||
const d = rescue.decision;
|
||||
assert.equal(player(rescue, f.owner).spice, player(revealed, f.owner).spice - Number(ownerPlan.action.support));
|
||||
assert.equal(combatArmy(rescue, ix, 'homeworld:ecaz').normal + combatArmy(rescue, ix, 'homeworld:ecaz').elite, 0);
|
||||
assert.equal(player(rescue, ix).tanks, player(revealed, ix).tanks + 2,
|
||||
'Both shipped visitor counters are real Tanks losses');
|
||||
const homeArmy = combatArmy(revealed, f.owner, 'homeworld:ecaz').normal;
|
||||
const maximum = d?.kind === 'sukRescue'
|
||||
? Math.max(...d.options.map(option => option.normal + option.elite))
|
||||
: 1;
|
||||
if (d?.kind === 'sukRescue') {
|
||||
assert.equal(d.player, f.owner);
|
||||
assert.equal(next(rescue), null, 'The rescue is an owner decision.');
|
||||
assert.ok(maximum <= 3, 'The selected hidden band saves up to three counters');
|
||||
assert.ok(d.options.some(option => option.kept), 'A skilled rescue keeps one physical counter in its sector');
|
||||
const choice = d.options.findIndex(option => option.normal + option.elite === maximum);
|
||||
const action: Action = { type: 'decision', event: d.event, choice };
|
||||
reject(rescue, ix, action);
|
||||
policies(rescue, f.owner, (after, proposal) => {
|
||||
const saved = d.options[Number(proposal.choice)]; assert.ok(saved);
|
||||
const savedTotal = saved.normal + saved.elite;
|
||||
assert.equal(player(after, f.owner).tanks, player(revealed, f.owner).tanks + lossTotal - savedTotal,
|
||||
'Only the unrescued dialed casualties reach the winner’s Tanks');
|
||||
assert.equal(combatArmy(after, f.owner, 'homeworld:ecaz').normal, homeArmy - lossTotal + savedTotal,
|
||||
'The saved counters stay at the same home');
|
||||
assert.equal(player(after, f.owner).spice, player(rescue, f.owner).spice);
|
||||
assert.equal(player(after, ix).tanks, player(rescue, ix).tanks, 'Loser never receives the winner-only rescue');
|
||||
});
|
||||
const settled = step(rescue, { actor: f.owner, action }), done = settle(settled, s => !s.battle && clean(s));
|
||||
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + lossTotal - maximum,
|
||||
'Exactly the unrescued dialed counters reach the winner’s Tanks');
|
||||
assert.equal(combatArmy(done, f.owner, 'homeworld:ecaz').normal, homeArmy - lossTotal + maximum,
|
||||
'The saved counters stay at the same home');
|
||||
assert.equal(player(done, ix).tanks, player(rescue, ix).tanks, 'Loser never receives the winner-only rescue');
|
||||
inventory(done);
|
||||
} else {
|
||||
assert.equal(maximum, 1, 'The unused face-up band saves exactly one counter');
|
||||
assert.equal(player(rescue, f.owner).tanks, player(revealed, f.owner).tanks + lossTotal - 1,
|
||||
'Exactly the unrescued dialed counters reach the winner’s Tanks');
|
||||
assert.equal(combatArmy(rescue, f.owner, 'homeworld:ecaz').normal, homeArmy - lossTotal + 1,
|
||||
'The single saved counter stays at the same home');
|
||||
inventory(rescue);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { leaders } from '../game/cards';
|
||||
import { territory } from '../game/board';
|
||||
import { TERROR_KINDS } from '../game/moritani-terror';
|
||||
import {
|
||||
advanceMixedE3Moritani as advance, assertMixedE3MoritaniPhysical as physical,
|
||||
assertMixedE3MoritaniRejects as rejects, createMixedE3MoritaniProgramme as programme,
|
||||
createMixedE3MoritaniSetup as setup, mixedE3MoritaniClean as clean,
|
||||
mixedE3MoritaniCunningRequest as cunning, mixedE3MoritaniInventory as inventory,
|
||||
mixedE3MoritaniPlayer as player, mixedE3MoritaniPolicy as policy, mixedE3MoritaniReload as reload,
|
||||
mixedE3MoritaniToken as token, nextMixedE3MoritaniStep as next, quoteMixedE3MoritaniBattle as quote,
|
||||
settleMixedE3MoritaniBoundary as boundary,
|
||||
stageMixedE3MoritaniLoss as loss, stepMixedE3Moritani as step,
|
||||
type MixedE3MoritaniBoundary, type MixedE3MoritaniOptions,
|
||||
} from './fixture-mixed-e3-moritani-nexus';
|
||||
|
||||
/** Explicit test-author physical choices; the reusable controller stops here. */
|
||||
function consume(g: Game, kind: MixedE3MoritaniBoundary['kind']): Game {
|
||||
const d = g.decision; assert.ok(d, `The ${kind} boundary needs its real owner decision.`);
|
||||
if (kind === 'losses' && d.kind === 'battleLosses') {
|
||||
const elite = Math.max(...d.options.map(o => o.elite));
|
||||
return step(g, { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === elite) } });
|
||||
}
|
||||
if (kind === 'rescue' && d.kind === 'sukRescue')
|
||||
return step(g, { actor: d.player, action: { type: 'decision', event: d.event, choice: 0 } });
|
||||
if (kind === 'cards' && d.kind === 'battleCards')
|
||||
return step(g, { actor: d.player, action: { type: 'decision', discard: [] } });
|
||||
if (kind === 'tech' && d.kind === 'techToken')
|
||||
return step(g, { actor: d.player, action: { type: 'decision', token: d.choices[0] } });
|
||||
if (kind === 'faceDance' && d.kind === 'faceDance')
|
||||
return step(g, { actor: d.player, action: { type: 'decision', reveal: false } });
|
||||
throw Error(`The test must explicitly consume the ${d.kind} decision.`);
|
||||
}
|
||||
|
||||
/** Every physical counter a seat owns: Tanks, reserves and board sectors. */
|
||||
function totalCounters(g: Game, id: string): number {
|
||||
const seat = player(g, id);
|
||||
return seat.tanks + seat.reserves +
|
||||
Object.values(seat.forces).reduce((n, amount) => n + amount, 0);
|
||||
}
|
||||
|
||||
/** Walk the real aftermath in order, consuming each boundary exactly once and
|
||||
* leaving the private assassination choice to `reveal`. */
|
||||
function aftermath(g: Game, normalCallOwner: string | null, reveal?: (g: Game) => Game,
|
||||
callCard: string | null = null): {
|
||||
game: Game; order: MixedE3MoritaniBoundary['kind'][]; revealed: Game | null;
|
||||
} {
|
||||
const order: MixedE3MoritaniBoundary['kind'][] = [];
|
||||
let revealed: Game | null = null;
|
||||
for (let n = 0; n < 16; n++) {
|
||||
const b = boundary(g, normalCallOwner, undefined, callCard); order.push(b.kind); g = b.game;
|
||||
if (b.kind === 'settled') return { game: g, order, revealed };
|
||||
if (b.kind === 'assassination') {
|
||||
const d = b.game.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
||||
g = reveal ? reveal(b.game) : step(b.game, { actor: d.player,
|
||||
action: { type: 'decision', event: d.event, decline: true } });
|
||||
revealed = g; continue;
|
||||
}
|
||||
g = consume(g, b.kind);
|
||||
}
|
||||
throw Error('The original mixed E3 aftermath did not settle.');
|
||||
}
|
||||
|
||||
void test('original mixed E3 native Moritani admission conserves decks, marks, tokens and forces across every selected band', () => {
|
||||
const bands: MixedE3MoritaniOptions[] = [
|
||||
{ rules: 'basic', families: ['ix'], skills: false },
|
||||
{ rules: 'advanced', families: ['ix'], skills: false },
|
||||
{ rules: 'advanced', families: ['choam'], skills: true },
|
||||
{ rules: 'advanced', families: ['ix', 'choam'], skills: false, tech: true },
|
||||
{ rules: 'advanced', families: ['ix', 'choam'], skills: true, strongholds: true },
|
||||
{ rules: 'advanced', families: ['ix', 'choam'], skills: false, homeworlds: true, discovery: true },
|
||||
];
|
||||
for (const options of bands) {
|
||||
const f = setup(options);
|
||||
const advanced = options.rules !== 'basic';
|
||||
assert.equal(f.afterSetup.status, 'playing');
|
||||
assert.equal(f.afterSetup.advanced, advanced);
|
||||
assert.deepEqual([...f.afterSetup.expansions].sort(), ['ecaz', ...(options.families ?? [])].sort());
|
||||
assert.equal(!!f.afterSetup.leaderSkills, !!options.skills);
|
||||
assert.equal(!!f.afterSetup.techTokens, !!options.tech);
|
||||
assert.equal(!!f.afterSetup.strongholdCards, !!options.strongholds);
|
||||
assert.equal(!!f.afterSetup.discoveries, !!options.discovery);
|
||||
assert.equal(!!f.afterSetup.homeworlds, !!options.homeworlds);
|
||||
// The Advanced native Moritani mark belongs to the mixed E3 envelope only.
|
||||
assert.equal(!!f.afterSetup.moritaniAssassinate, advanced);
|
||||
assert.deepEqual(f.afterSetup.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
|
||||
assert.equal(player(f.afterSetup, f.moritani).reserves, 14);
|
||||
assert.equal(player(f.afterSetup, f.moritani).forces[f.location], 6);
|
||||
assert.ok(player(f.afterSetup, f.moritani).traitors.includes(f.target),
|
||||
'The revealed Traitor is the card the original setup deal actually gave Moritani.');
|
||||
assert.ok(leaders(f.nativeFaction).some(l => l.id === f.target));
|
||||
if (options.families?.includes('ix'))
|
||||
assert.ok(!f.afterSetup.ixSetupCards, 'The Ixians pre-training row is consumed exactly once.');
|
||||
physical(f.afterSetup);
|
||||
// A supplied original setup keeps its own hands and offers and is never redealt.
|
||||
const continued = setup({ ...options, initial: reload(f.offered) });
|
||||
assert.deepEqual(continued.afterSetup.players.map(p => ({ id: p.id, hand: p.hand })),
|
||||
f.afterSetup.players.map(p => ({ id: p.id, hand: p.hand })));
|
||||
assert.deepEqual(continued.afterSetup.nexusCards, f.afterSetup.nexusCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('two-through-six original seats, the combined five/six-native roster and the Harkonnen guards stay original', () => {
|
||||
const two = setup({ families: ['ix'], roster: ['moritani', 'tleilaxu'] });
|
||||
assert.equal(two.afterSetup.players.length, 2); assert.equal(two.partner, null);
|
||||
assert.equal(two.afterSetup.nexusCards!.cards!.deck.length, 12);
|
||||
physical(two.afterSetup);
|
||||
for (const count of [3, 4, 5, 6] as const) {
|
||||
const classics = (['guild', 'emperor', 'atreides', 'fremen'] as FactionId[]).slice(0, count - 2);
|
||||
const f = setup({ families: ['ix'], roster: ['moritani', 'tleilaxu', ...classics] });
|
||||
assert.equal(f.afterSetup.players.length, count); physical(f.afterSetup);
|
||||
}
|
||||
// The bare all-three-family roster keeps the original combined five/six-native entry.
|
||||
const five = setup({ families: ['ix', 'choam'], roster: ['moritani', 'ecaz', 'ixians', 'tleilaxu', 'choam'] });
|
||||
assert.equal(five.entry, 'combined'); assert.equal(five.afterSetup.players.length, 5);
|
||||
assert.equal(five.afterSetup.expansions.length, 3); physical(five.afterSetup);
|
||||
const six = setup({ families: ['ix', 'choam'],
|
||||
roster: ['moritani', 'ecaz', 'ixians', 'tleilaxu', 'choam', 'richese'] });
|
||||
assert.equal(six.entry, 'combined'); assert.equal(six.afterSetup.richeseCache!.length, 10);
|
||||
assert.ok(six.afterSetup.expansions.includes('ix'));
|
||||
physical(six.afterSetup);
|
||||
// Basic Harkonnen remains a legal classic seat; Advanced retains its guard.
|
||||
const basic = setup({ rules: 'basic', families: ['ix'], roster: ['moritani', 'tleilaxu', 'harkonnen'] });
|
||||
assert.equal(player(basic.afterSetup, basic.seats.harkonnen!).traitors.length, 4);
|
||||
assert.equal(basic.afterSetup.moritaniAssassinate, undefined);
|
||||
assert.throws(() => setup({ rules: 'advanced', families: ['ix'], roster: ['moritani', 'tleilaxu', 'harkonnen'] }));
|
||||
assert.throws(() => setup({ rules: 'basic', families: ['ix'], strongholds: true }));
|
||||
});
|
||||
|
||||
void test('genuine turn-one/two chronology reaches a real alliance change, both Advanced Spice piles and Moritani own closing Nexus draw', () => {
|
||||
const f = programme({ rules: 'advanced', families: ['ix'], skills: false });
|
||||
assert.ok(f.firstMentat && f.alliance && f.drawing);
|
||||
assert.equal(f.firstMentat.before.turn, 1); assert.equal(f.firstMentat.after.turn, 2);
|
||||
const allies = f.alliance.players.filter(p => p.ally);
|
||||
assert.equal(allies.length, 2);
|
||||
assert.equal(allies[0].ally, allies[1].id); assert.equal(allies[1].ally, allies[0].id);
|
||||
assert.equal(player(f.alliance, f.moritani).ally, null, 'Moritani stays unallied to keep its closing qualification.');
|
||||
assert.equal(f.drawing.phase, 1);
|
||||
assert.ok(f.drawing.spiceDiscard.every(pile => pile.length > 0), 'Both original Spice piles precede the closing draw.');
|
||||
const draw = f.draws[0];
|
||||
assert.equal(draw.after.nexusCards!.cards!.hands[f.moritani], 'moritani');
|
||||
assert.equal(draw.before.nexusCards!.cards!.hands[f.moritani], null);
|
||||
assert.equal(player(draw.before, f.moritani).spice, player(draw.after, f.moritani).spice,
|
||||
'The original Nexus draw invents no bank income.');
|
||||
rejects(draw.after, f.moritani, draw.step.action);
|
||||
inventory(f.game);
|
||||
});
|
||||
|
||||
for (const band of [
|
||||
{ name: 'native E1 Ixians', natives: { ix: 'ixians' } as const, families: ['ix'] as const, skills: false },
|
||||
{ name: 'native E2 CHOAM', natives: { choam: 'choam' } as const, families: ['choam'] as const, skills: false },
|
||||
{ name: 'native E1 Tleilaxu with all14 skills', natives: { ix: 'tleilaxu' } as const, families: ['ix'] as const, skills: true },
|
||||
] as const) void test(`Advanced Moritani loss to ${band.name} reveals a different original held faction Traitor, pays its printed bounty and replaces it once`, () => {
|
||||
const f = programme({ rules: 'advanced', families: [...band.families], natives: { ...band.natives }, skills: band.skills });
|
||||
assert.ok(f.firstMentat && f.alliance);
|
||||
const battle = loss(f, f.game);
|
||||
assert.equal(battle.target, f.target);
|
||||
assert.notEqual(battle.enemyLeader, f.target, 'The opposed disc is always a different leader.');
|
||||
assert.equal(quote(battle.revealed).winner, f.native, 'The selected native wins the real battle.');
|
||||
const revealedState = battle.revealed;
|
||||
const targetLeader = player(revealedState, f.native).leaders.find(l => l.id === f.target)!;
|
||||
assert.equal(targetLeader.dead, false);
|
||||
let chosen: Game | undefined;
|
||||
const walk = aftermath(revealedState, band.skills ? null : f.moritani, boundaryGame => {
|
||||
const d = boundaryGame.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
||||
const action: Action = { type: 'decision', event: d.event, card: f.target };
|
||||
rejects(boundaryGame, f.native, action);
|
||||
rejects(boundaryGame, f.moritani, { ...action, event: 'stale-assassination' });
|
||||
rejects(boundaryGame, f.moritani, { ...action, card: battle.enemyLeader });
|
||||
const at = player(boundaryGame, f.native), own = player(boundaryGame, f.moritani);
|
||||
chosen = step(boundaryGame, { actor: d.player, action });
|
||||
assert.equal(player(chosen, f.native).leaders.find(l => l.id === f.target)!.dead, true);
|
||||
assert.equal(player(chosen, f.native).leaders.find(l => l.id === f.target)!.deaths, targetLeader.deaths + 1);
|
||||
assert.equal(player(chosen, f.moritani).spice, own.spice + targetLeader.strength,
|
||||
'The printed bounty is paid once from the bank.');
|
||||
assert.equal(player(chosen, f.native).spice, at.spice);
|
||||
assert.equal(totalCounters(chosen, f.native), totalCounters(boundaryGame, f.native),
|
||||
'The assassination conserves the native’s physical counters.');
|
||||
assert.equal(totalCounters(chosen, f.moritani), totalCounters(boundaryGame, f.moritani),
|
||||
'The assassination conserves Moritani’s physical counters.');
|
||||
rejects(chosen, f.moritani, action);
|
||||
if (band.skills) {
|
||||
const skill = f.afterSetup.leaderSkills!.assignments.find(a => a.leader === f.target)!.skill;
|
||||
assert.equal(chosen.leaderSkills!.assignments.some(a => a.leader === f.target), false);
|
||||
assert.equal(chosen.leaderSkills!.deck.filter(c => c === skill).length, 1,
|
||||
'A different trained disc returns its physical skill exactly once.');
|
||||
}
|
||||
return chosen;
|
||||
});
|
||||
assert.ok(chosen, 'The Advanced native loss must open its private reveal choice.');
|
||||
if (band.skills) {
|
||||
assert.ok(walk.order.includes('assassination'), walk.order.join(','));
|
||||
assert.ok(walk.order.indexOf('assassination') < walk.order.indexOf('cards'), walk.order.join(','));
|
||||
} else assert.equal(walk.order[0], 'assassination', walk.order.join(','));
|
||||
const settled = advance(walk.game, s => s.decision?.kind === 'moritaniPlacement', f.actions);
|
||||
const receipt = settled.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.card, f.target);
|
||||
const reserve = chosen.traitorReserve!;
|
||||
assert.equal(receipt.replacement, reserve[0]);
|
||||
assert.equal(settled.traitorReserve!.length, reserve.length - 1, 'Exactly one private Mentat replacement.');
|
||||
assert.ok(player(settled, f.moritani).traitors.includes(reserve[0]));
|
||||
assert.equal(settled.traitorReserve!.includes(f.target), false, 'The revealed card stays a public set-aside.');
|
||||
assert.equal(Object.hasOwn(viewGame(settled, f.native).moritaniAssassinate!.history.at(-1)!, 'replacement'), false,
|
||||
'The public history never carries the private Mentat replacement.');
|
||||
physical(settled);
|
||||
});
|
||||
|
||||
void test('a normal own-card Traitor reveal forfeits the Advanced advantage for the rest of the game across later real battles', () => {
|
||||
const f = programme({ rules: 'advanced', families: ['ix'], natives: { ix: 'ixians' }, skills: false });
|
||||
assert.ok(f.firstMentat);
|
||||
const first = loss(f, f.game, { disc: 'held' });
|
||||
const won = aftermath(first.revealed, f.moritani, undefined, first.target);
|
||||
assert.equal(won.order.includes('assassination'), false, won.order.join(','));
|
||||
const g = won.game;
|
||||
assert.equal(g.lastBattleContext!.result, 'traitor');
|
||||
assert.equal(g.lastBattleContext!.winner, f.moritani);
|
||||
assert.equal(g.moritaniAssassinate!.normalTraitorCall, true);
|
||||
assert.equal(g.moritaniAssassinateCallEvents!.length, 1);
|
||||
assert.deepEqual(g.moritaniAssassinate!.opportunities, []);
|
||||
assert.ok(viewGame(g, f.moritani).moritaniAssassinate!.blocked?.includes('forfeited'));
|
||||
// A later genuine loss to the same faction still grants nothing. The disc
|
||||
// already died to the normal Traitor call, so compare its death count.
|
||||
const second = loss(f, g);
|
||||
const targetDeaths = player(g, f.native).leaders.find(l => l.id === second.target)!.deaths;
|
||||
const later = aftermath(second.revealed, null);
|
||||
assert.equal(later.order.includes('assassination'), false, later.order.join(','));
|
||||
assert.deepEqual(later.game.moritaniAssassinate!.opportunities, []);
|
||||
assert.equal(later.game.moritaniAssassinate!.normalTraitorCall, true);
|
||||
assert.equal(player(later.game, f.native).leaders.find(l => l.id === second.target)!.deaths, targetDeaths,
|
||||
'The later battle grants no second assassination.');
|
||||
physical(later.game);
|
||||
});
|
||||
|
||||
void test('supply-only native Cunning and the real Terror arrival retain the physical token, leader and bounty', () => {
|
||||
const f = programme({ rules: 'advanced', families: ['ix'], natives: { ix: 'ixians' }, skills: false });
|
||||
assert.ok(f.firstMentat && f.alliance);
|
||||
// The native Cunning is offered only inside its own quiet window.
|
||||
let g = advance(reload(f.game), s => viewGame(s, f.moritani).nexusMoritani?.blocked === null, f.actions);
|
||||
const request = cunning(g, f.moritani, 'robbery', 'red_chasm');
|
||||
rejects(g, f.native, request);
|
||||
rejects(g, f.moritani, { ...request, nexus: 'stale-cunning' });
|
||||
rejects(g, f.moritani, { type: 'decision', token: token(g, 'robbery').id, territory: 'red_chasm' });
|
||||
const before = reload(g);
|
||||
g = advance(step(g, { actor: f.moritani, action: request }),
|
||||
s => clean(s) && s.moritaniTerror!.tokens.some(t => t.kind === 'robbery' && t.status === 'placed'), f.actions);
|
||||
assert.equal(token(g, 'robbery').location, 'red_chasm');
|
||||
assert.equal(g.moritaniTerror!.placementTurn, g.turn);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.moritani], null, 'The Nexus singleton is the whole supply-only cost.');
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(c => c === 'moritani').length, 1);
|
||||
assert.equal(player(g, f.moritani).spice, player(before, f.moritani).spice, 'No bank income is invented.');
|
||||
assert.deepEqual(player(g, f.moritani).leaders, player(before, f.moritani).leaders);
|
||||
// A real enemy shipment into the declared territory opens the private arrival.
|
||||
g = advance(g, s => s.phase === 5 && s.active === f.native && clean(s), f.actions);
|
||||
g = step(g, { actor: f.native, action: { type: 'ship', territory: 'red_chasm',
|
||||
sector: territory('red_chasm').sectors[0], amount: 3, elite: 0, allyPayment: 0 } }, f.actions);
|
||||
g = advance(g, s => s.pendingTerrorEntry?.stage === 'offer', f.actions);
|
||||
assert.equal(viewGame(g, f.moritani).terrorEntry!.kind, 'robbery');
|
||||
assert.equal(viewGame(g, f.native).terrorEntry!.kind, undefined, 'The token face stays owner-private.');
|
||||
const revealed = step(step(g, { actor: f.moritani, action: { type: 'decision', reveal: true } }),
|
||||
{ actor: f.moritani, action: { type: 'decision', choice: 'spice' } });
|
||||
assert.equal(revealed.pendingTerrorEntry ?? null, null);
|
||||
assert.notEqual(token(revealed, 'robbery').status, 'available');
|
||||
assert.deepEqual(player(revealed, f.moritani).leaders.map(l => l.id), player(g, f.moritani).leaders.map(l => l.id));
|
||||
const robbed = player(g, f.native).spice;
|
||||
assert.equal(player(revealed, f.moritani).spice, player(g, f.moritani).spice + Math.ceil(robbed / 2),
|
||||
'Printed Robbery transfers exactly half the entrant spice, rounded up, once.');
|
||||
assert.equal(player(revealed, f.native).spice, robbed - Math.ceil(robbed / 2));
|
||||
inventory(revealed);
|
||||
});
|
||||
|
||||
void test('all four original policies consume the real private assassination window and rejected actions never move stock', () => {
|
||||
const f = programme({ rules: 'advanced', families: ['ix'], natives: { ix: 'ixians' }, skills: false });
|
||||
assert.ok(f.firstMentat);
|
||||
const battle = loss(f, f.game);
|
||||
let g = battle.revealed;
|
||||
for (let n = 0; n < 16; n++) {
|
||||
const b = boundary(g, f.moritani); g = b.game;
|
||||
if (b.kind === 'settled') throw Error('The Advanced loss must grant an assassination opportunity.');
|
||||
if (b.kind === 'assassination') break;
|
||||
g = consume(g, b.kind);
|
||||
}
|
||||
const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
||||
const original = reload(g);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const action = policy(g, f.moritani, difficulty).find(a => a.type === 'decision');
|
||||
assert.ok(action, `${difficulty} must consume the actual assassination window.`);
|
||||
rejects(g, f.native, action);
|
||||
const after = step(reload(g), { actor: f.moritani, action });
|
||||
assert.ok(['revealed', 'declined'].includes(after.moritaniAssassinate!.opportunities.at(-1)!.stage));
|
||||
assert.deepEqual(g, original, 'A rejected rival action cannot spend the original opportunity.');
|
||||
}
|
||||
assert.equal(next(g) ?? null, null, 'The private choice is never auto-consumed by the controller.');
|
||||
});
|
||||
@@ -262,18 +262,31 @@ void test('normal movement triggers after moving forces once, while an internal
|
||||
assert.equal(player(shifted, f.target).forces['imperial_basin:11'], 2);
|
||||
});
|
||||
|
||||
void test('overlapping Guild income and Bene Gesserit arrival reactions reject the entire shipment atomically', () => {
|
||||
for (const faction of ['guild', 'beneGesserit'] as const) {
|
||||
const initial = fixture();
|
||||
initial.players.push(newPlayer('x', 'Arrival reaction', faction));
|
||||
initial.order.push('x');
|
||||
rejected(
|
||||
initial,
|
||||
'e',
|
||||
shipment,
|
||||
/Terror combined with another arrival reaction/,
|
||||
);
|
||||
}
|
||||
void test('overlapping Guild income and Bene Gesserit arrival reactions defer the Terror entry to the committed shipment', () => {
|
||||
// A seated Guild takes its real income; the committed entry then opens.
|
||||
const guild = fixture();
|
||||
guild.players.push(newPlayer('x', 'Arrival reaction', 'guild'));
|
||||
guild.order.push('x');
|
||||
const paid = applyAction(guild, 'e', shipment);
|
||||
assert.equal(paid.decision?.kind, 'moritaniTerror');
|
||||
assert.equal(paid.pendingArrivalReaction ?? null, null);
|
||||
assert.deepEqual(player(paid, 'e').forces, { 'arrakeen:10': 2 });
|
||||
assert.equal(player(paid, 'e').spice, 18, 'The real shipment price is paid once.');
|
||||
|
||||
// A seated BG advisor choice settles first; the entry waits for it.
|
||||
const advisor = fixture();
|
||||
advisor.players.push(newPlayer('x', 'Arrival reaction', 'beneGesserit'));
|
||||
advisor.order.push('x');
|
||||
const deferred = applyAction(advisor, 'e', shipment);
|
||||
assert.equal(deferred.decision?.kind, 'advisor');
|
||||
assert.equal(deferred.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(deferred.pendingArrivalReaction?.[0]?.entrant, 'e');
|
||||
assert.deepEqual(player(deferred, 'e').forces, { 'arrakeen:10': 2 });
|
||||
const declined = applyAction(deferred, 'x', { type: 'decision', accept: false });
|
||||
assert.equal(declined.decision?.kind, 'moritaniTerror', 'The deferred entry opens once the advisor choice settles.');
|
||||
assert.equal(declined.pendingArrivalReaction ?? null, null);
|
||||
|
||||
// Advanced BG fighters raise the Intrusion choice first.
|
||||
const intrusion = fixture();
|
||||
intrusion.advanced = true;
|
||||
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
|
||||
@@ -281,12 +294,13 @@ void test('overlapping Guild income and Bene Gesserit arrival reactions reject t
|
||||
bg.reserves = 0;
|
||||
intrusion.players.push(bg);
|
||||
intrusion.order.push('b');
|
||||
rejected(
|
||||
intrusion,
|
||||
'e',
|
||||
shipment,
|
||||
/Terror combined with another arrival reaction/,
|
||||
);
|
||||
const waiting = applyAction(intrusion, 'e', shipment);
|
||||
assert.equal(waiting.decision?.kind, 'intrusion');
|
||||
assert.equal(waiting.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(waiting.pendingArrivalReaction?.[0]?.entrant, 'e');
|
||||
const answered = applyAction(waiting, 'b', { type: 'decision', accept: false });
|
||||
assert.equal(answered.decision?.kind, 'moritaniTerror', 'The deferred entry opens after the Intrusion choice.');
|
||||
assert.equal(answered.pendingArrivalReaction ?? null, null);
|
||||
});
|
||||
|
||||
void test('Guild cross-shipment settles once and returning forces to reserves has no entry trigger', () => {
|
||||
|
||||
@@ -34,10 +34,17 @@ void test('every canonical skill can be dealt and publicly assigned to Basic Mor
|
||||
});
|
||||
|
||||
void test('Moritani skill starts retain immutable Ecaz, optional-module and public-start rejection', () => {
|
||||
// Native Discovery Leader Skills are now admitted for this fresh lobby.
|
||||
const discovery = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
|
||||
joinGame(discovery, newPlayer('a', 'Atreides', 'atreides'));
|
||||
discovery.players.forEach(p => { p.ready = true; });
|
||||
discovery.discoveryEnabled = true;
|
||||
assert.ok(initializeLeaderSkillsGameForAudit(discovery).leaderSkills);
|
||||
for (const change of [
|
||||
(g: Game) => { g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
|
||||
(g: Game) => { g.expansions.push('choam'); },
|
||||
(g: Game) => { g.discoveryEnabled = true; },
|
||||
(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']);
|
||||
|
||||
@@ -231,7 +231,10 @@ void test('Richese can fund its buyer from escrow with no unpledged spice, with
|
||||
assert.equal(f.game.aid[f.target].amount,2);
|
||||
const done = use ? applyAction(reload(f.game),f.holder,{type:'richeseBetrayalUse',event:f.event}) : passes(reload(f.game));
|
||||
assert.equal(player(done,f.buyer).spice,player(f.game,f.buyer).spice - 4);
|
||||
assert.equal(player(done,f.target).spice,use ? 0 : 6);
|
||||
// The authorized Advanced source sends Richese's own contribution to the
|
||||
// Emperor or, with no Emperor seated, to the Bank: the seller receives only
|
||||
// the buyer's own payment. Basic keeps the full sale credit.
|
||||
assert.equal(player(done,f.target).spice,use ? 0 : source === 'cache' ? 6 : 4);
|
||||
assert.equal(done.aid[f.target].amount,0);
|
||||
assert.deepEqual(census(done),census(f.game));
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(done)),done);
|
||||
|
||||
@@ -754,7 +754,7 @@ void test('ending without flight escrow never emits an empty Ornithopter discard
|
||||
assert.equal(done.active, 'q');
|
||||
});
|
||||
|
||||
void test('a final flight with competing BG intrusion and Terror rejects atomically before saving any retirement', () => {
|
||||
void test('a final flight with competing BG intrusion and Terror commits its retirement and defers the Terror entry', () => {
|
||||
const initial = fixture('richese', true);
|
||||
initial.players[1] = newPlayer('q', 'Moritani', 'moritani');
|
||||
initial.players[2] = newPlayer('e', 'Bene Gesserit', 'beneGesserit');
|
||||
@@ -769,15 +769,24 @@ void test('a final flight with competing BG intrusion and Terror rejects atomica
|
||||
);
|
||||
const before = structuredClone(initial),
|
||||
action = first({ ornithopter: 'range3' });
|
||||
assert.throws(() => applyAction(initial, 'p', action), /Terror combined/);
|
||||
assert.throws(() => inner(initial, 'p', action), /Terror combined/);
|
||||
assert.deepEqual(initial, before);
|
||||
assert.ok(player(initial, 'p').hand.some((c) => c.id === ORNI));
|
||||
assert.equal(initial.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(initial.ornithopter ?? null, null);
|
||||
assert.equal(player(initial, 'p').moved, 0);
|
||||
assert.equal(player(initial, 'p').forces[ORIGIN], 3);
|
||||
inventory(initial);
|
||||
const flown = applyAction(initial, 'p', action);
|
||||
assert.deepEqual(initial, before, 'The authoritative input is not mutated.');
|
||||
assert.equal(flown.decision?.kind, 'intrusion');
|
||||
assert.equal(flown.pendingArrivalReaction?.[0]?.entrant, 'p');
|
||||
assert.equal(flown.pendingArrivalReaction?.[0]?.territory, 'arrakeen');
|
||||
assert.equal(flown.pendingArrivalReaction?.[0]?.amount, 1);
|
||||
assert.equal(flown.pendingTerrorEntry ?? null, null,
|
||||
'The Terror entry waits for the pending Intrusion choice.');
|
||||
assert.equal(player(flown, 'p').moved, 1);
|
||||
assert.deepEqual(player(flown, 'p').forces, { 'imperial_basin:10': 2, 'arrakeen:10': 1 });
|
||||
assert.equal(flown.ornithopter ?? null, null);
|
||||
inventory(flown);
|
||||
// The same committed flight saves its real physical retirement continuation.
|
||||
const saved = inner(initial, 'p', action);
|
||||
assert.equal(saved.pendingTreacheryDiscard?.continuation?.kind, 'ornithopterDiscard');
|
||||
assert.equal(player(saved, 'p').moved, 1);
|
||||
assert.deepEqual(player(saved, 'p').forces, { 'imperial_basin:10': 2, 'arrakeen:10': 1 });
|
||||
inventory(saved);
|
||||
});
|
||||
|
||||
void test('automatic Fremen faction-speed allowance drains the final group frame through the response loop', () => {
|
||||
|
||||
@@ -329,9 +329,28 @@ void test('unsupported simultaneous arrivals fail before consent is committed an
|
||||
);
|
||||
hold(g, 'e', 'Karama');
|
||||
g = offer(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => accept(g), /combined with another arrival/);
|
||||
assert.deepEqual(g, before);
|
||||
// The consent commits its real shipment; the committed Terror entry waits
|
||||
// for the arrival's own reaction instead of failing the whole consent.
|
||||
const committed = accept(g);
|
||||
// The consent commits its shipment and opens the No-Field response instead of
|
||||
// rejecting the whole consent because a Terror token shares the destination.
|
||||
assert.equal(committed.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(committed.players[1].forces, {}, 'No force moves before the response settles.');
|
||||
conserve(committed);
|
||||
// Once the response settles, the committed arrival still offers its Terror entry.
|
||||
let arrived = committed;
|
||||
for (let i = 0; arrived.response && i < 8; i++) {
|
||||
const passer = arrived.players.find(p => {
|
||||
const controls = viewGame(arrived, p.id).responseControls;
|
||||
return controls && controls.cancelCards.length && !controls.hasPassed;
|
||||
});
|
||||
assert.ok(passer, 'The No-Field response keeps a legal passer.');
|
||||
arrived = send(arrived, passer!.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.ok((arrived.pendingArrivalReaction?.length ?? 0) > 0 ||
|
||||
arrived.pendingTerrorEntry !== null || arrived.decision?.kind === 'moritaniTerror',
|
||||
'The committed Terror entry is offered or waits behind the arrival reaction.');
|
||||
conserve(arrived);
|
||||
const automated = structuredClone(g);
|
||||
automated.players[1].bot = 'Medium';
|
||||
const declined = runBots(automated, 1);
|
||||
|
||||
@@ -333,18 +333,20 @@ void test('simultaneous entry reactions fail atomically and stale CHOAM continua
|
||||
'arrakeen',
|
||||
1,
|
||||
);
|
||||
reject(
|
||||
g,
|
||||
'r',
|
||||
{
|
||||
type: 'ship',
|
||||
noField: token(g, 5),
|
||||
event: g.players[0].noFieldEvent,
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
},
|
||||
/Terror combined/,
|
||||
);
|
||||
const committed = applyAction(g, 'r', {
|
||||
type: 'ship',
|
||||
noField: token(g, 5),
|
||||
event: g.players[0].noFieldEvent,
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
});
|
||||
// The committed shipment and its Terror entry both survive; the entry waits
|
||||
// for the arrival's own reaction instead of rejecting the shipment.
|
||||
assert.equal(committed.players[0].noField!.deployed!.tokenId, token(g, 5));
|
||||
assert.equal(committed.players[0].shipped, true);
|
||||
assert.ok((committed.pendingArrivalReaction?.length ?? 0) > 0 ||
|
||||
committed.pendingTerrorEntry !== null || committed.decision?.kind === 'moritaniTerror',
|
||||
'The committed Terror entry is offered or waits behind the arrival reaction.');
|
||||
let moving = marker(fixture(), 3, 'imperial_basin', 10);
|
||||
moving.players[1] = newPlayer('a', 'CHOAM', 'choam');
|
||||
moving.players[1].forces = { 'arrakeen:10': 1 };
|
||||
|
||||
@@ -62,8 +62,16 @@ void test('all fourteen skills admit genuine Basic Tleilaxu setup while public,
|
||||
assert.equal(basicTleilaxuLeaderSkillsProfile(viewGame(game, 'a')), true);
|
||||
stable(game);
|
||||
}
|
||||
// Native E1 Discovery Leader Skills are now admitted for this fresh lobby;
|
||||
// unsupported Discovery overlays still reject.
|
||||
const discovery = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
|
||||
joinGame(discovery,newPlayer('e','Emperor','emperor'));discovery.players.forEach(p => {p.ready = true;});
|
||||
discovery.discoveryEnabled = true;
|
||||
assert.equal(basicTleilaxuLeaderSkillsProfile(initializeLeaderSkillsGameForAudit(discovery)), true);
|
||||
for (const mutate of [(g: Game) => {g.players[1] = newPlayer('x','Richese','richese');},
|
||||
(g: Game) => {g.discoveryEnabled = true;}, (g: Game) => {g.expansions.push('choam');}]) {
|
||||
(g: Game) => {g.expansions.push('choam');},
|
||||
(g: Game) => {g.discoveryEnabled = true; g.players[1] = newPlayer('x','Richese','richese');},
|
||||
(g: Game) => {g.discoveryEnabled = true; g.expansions.push('choam');}]) {
|
||||
const g = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
|
||||
joinGame(g,newPlayer('e','Emperor','emperor'));g.players.forEach(p => {p.ready = true;});mutate(g);
|
||||
const before = JSON.stringify(g);assert.throws(() => initializeLeaderSkillsGameForAudit(g));assert.equal(JSON.stringify(g),before);
|
||||
|
||||
Reference in new issue
Block a user