Unify registered native E3 Leader Skills capability

This commit is contained in:
Quintine committed 2026-10-18 01:17:13 +11:00
1 parent c265f0638e
commit 60d1bd3b59
21 files changed
+506 -125

No files matched your search

@@ -5,6 +5,7 @@ import {
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { treacheryDeck, type Card, type SpiceCard } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
@@ -72,7 +73,8 @@ export function nativeHomeworldInventory(game: Game): void {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? [])];
const ordinary = cards.filter(c => !c.id.startsWith('richese-'));
assert.deepEqual(ordinary.map(c => c.id).sort(), treacheryDeck(game.expansions).map(c => c.id).sort());
const expected = [...treacheryDeck(game.expansions), ...(game.ecazTreachery ? ecazTreacheryCards() : [])];
assert.deepEqual(ordinary.map(c => c.id).sort(), expected.map(c => c.id).sort());
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
for (const p of game.players) {
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
+61 -23
View File
@@ -6,7 +6,9 @@ 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 { ecazTreacheryCards } from '../game/ecaz-cards';
import type { SpiceCard } from '../game/cards';
import type { HarassWithdrawSelection } from '../game/harass-withdraw';
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';
@@ -41,6 +43,10 @@ export interface MixedE3EcazNexusOptions {
families?: readonly ('ix' | 'choam')[];
roster?: readonly FactionId[];
advanced?: boolean;
/** False selects the original all14 path without a Nexus Cards component. */
nexus?: boolean;
/** Original independent three-card variant; only the no-Nexus all14 path. */
independent?: boolean;
skills?: boolean;
homeworlds?: boolean;
discovery?: boolean;
@@ -84,7 +90,7 @@ export function nextMixedE3EcazNexusStep(g: Game): MixedE3EcazNexusStep | null {
if (g.decision && ['ecazBattleLead', 'leaderSkillVisibility', 'moritaniPlacement',
'moritaniAssassinate', 'moritaniTerror', 'ecazPlacement', 'ecazAmbassador', 'battleLosses',
'battleCards', 'techToken', 'sukRescue', 'ixSubstitution', 'faceDance', 'strongholdCopy',
'discoveryEntry', 'greatMakerVote', 'greatMakerRide', 'homeworldDefense',
'discoveryEntry', 'greatMakerVote', 'greatMakerRide', 'homeworldDefense', 'harassWithdraw',
'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 } };
@@ -186,6 +192,7 @@ export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = {
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 nexus = options.nexus ?? true, independent = options.independent ?? false;
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]);
@@ -194,14 +201,18 @@ export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = {
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);
if (nexus) for (const family of families)
assert.ok(roster.some(f => (family === 'ix' ? ['ixians', 'tleilaxu'] : ['choam', 'richese']).includes(f)));
assert.equal(!!g.ecazTreachery, options.initial?.status === 'setup' ? independent : false);
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(nexus || skills, 'The no-Nexus route is the original all14 module, not bare mixed Worlds');
assert.ok(!independent || !nexus, 'Independent cards retain their no-Nexus all14 scope');
assert.ok(nexus || !discovery, 'Existing Discovery/Nexus composition stays separate');
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');
@@ -211,18 +222,27 @@ export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = {
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 };
g.discoveryEnabled = discovery;
if (nexus) g.nexusCards ??= { cards: null, phase: null };
else {
assert.equal(g.nexusCards, undefined, 'Never erase a selected or played Nexus component');
if (!independent) g = stepMixedE3EcazNexus(g, { actor: g.host,
action: { type: 'moduleOption', module: 'leaderSkills', enabled: true } }, actions);
}
} 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');
if (nexus) assert.ok(g.nexusCards?.cards, 'An existing deal cannot acquire another Nexus deck');
else assert.equal(g.nexusCards, undefined);
}
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);
if (independent) g.ecazTreachery = true;
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[] = [];
const deck = [...treacheryDeck(g.expansions), ...(independent ? ecazTreacheryCards() : [])],
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');
@@ -230,9 +250,12 @@ export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = {
}
const rolls = [...(skills ? permutation(skillSource, skillOrder) : []),
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter(f => f !== 'ecaz')]),
...(nexus ? 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);
const initialize = () => !nexus && !independent
? stepMixedE3EcazNexus(g, { actor: g.host,
action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } }, actions)
: skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g);
g = options.initial ? initialize() : originalLottery(rolls, initialize);
}
const offered = mixedE3EcazNexusReload(g), trainers: Record<string, string> = {};
@@ -279,8 +302,11 @@ export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = {
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());
const nx = g.nexusCards?.cards;
if (nx) {
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) {
@@ -291,6 +317,7 @@ export function mixedE3EcazNexusInventory(g: Game): void {
...(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.ecazTreachery ? ecazTreacheryCards() : []),
...(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) {
@@ -389,13 +416,15 @@ export function createMixedE3EcazNexusProgramme(options: MixedE3EcazNexusOptions
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;
if (g.nexusCards) {
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;
}
@@ -427,16 +456,25 @@ export function createMixedE3EcazNexusCoalition(options: MixedE3EcazNexusOptions
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 {
export function quoteMixedE3EcazNexusBattle(g: Game, returns?: HarassWithdrawSelection): 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),
const p = mixedE3EcazNexusPlayer(g, actor), view = viewGame(g, actor).battle!, plan = b.plans[actor];
assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan,
leader: p.leaders.find(l => l.id === plan.leader) ??
(g.dukeVidal?.controller === actor && plan.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 })) };
leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })),
...(view.reinforcements && (plan.weapon === 'ecaz-reinforcements' || plan.defense === 'ecaz-reinforcements')
? { reinforcementsReserves: {
normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0 } } : {}),
...(view.harassWithdraw && (plan.weapon === 'ecaz-harass-withdraw' || plan.defense === 'ecaz-harass-withdraw')
? { harassWithdraw: { blocked: null, forces: view.harassWithdraw.forces,
locations: view.harassWithdraw.locations,
...(view.harassWithdraw.occupy ? { occupy: view.harassWithdraw.occupy } : {}) },
...(returns && Object.keys(returns).length ? { harassSelection: returns } : {}) } : {}) };
};
const context = homeworldContext(g), home = b.territory.startsWith('homeworld:')
? homeworldCombatLocation({ ...context, players: context.players.map(p => ({ ...p,
+55 -21
View File
@@ -6,6 +6,8 @@ import {
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { leaders, spiceDeck, treacheryDeck, type Card } from '../game/cards';
import { ecazTreacheryCards, type EcazTreacheryCardId } from '../game/ecaz-cards';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { isDefenseCard, isWeaponCard } from '../game/battle-cards';
import { territory } from '../game/board';
import { CHOAM_AUDITOR_ID } from '../game/choam-auditor';
@@ -23,6 +25,7 @@ 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 type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote';
import type { Difficulty } from '../game/bot-profiles';
import { botActions } from '../game/bots';
@@ -51,6 +54,10 @@ export type MixedE3MoritaniOptions = {
roster?: readonly FactionId[];
classics?: readonly FactionId[];
skills?: boolean;
/** Original Nexus remains selected unless explicitly disabled for all14. */
nexus?: boolean;
/** Independent three-card variant; only the no-Nexus all14 route selects it. */
independent?: boolean;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
@@ -58,7 +65,7 @@ export type MixedE3MoritaniOptions = {
/** Requested skill for the trained opposing disc of the Advanced loss. */
opponentSkill?: LeaderSkillId;
/** Select original starting-deal entropy; never replace a played hand. */
startingCards?: Partial<Record<FactionId, readonly Card['kind'][]>>;
startingCards?: Partial<Record<FactionId, readonly (Card['kind'] | EcazTreacheryCardId)[]>>;
/** Desired physical opposing-faction card in the original undealt Traitor offer. */
traitorTarget?: string;
/** Original undealt reserve prefix, before any Face Dancer reshuffle. */
@@ -174,16 +181,18 @@ export function mixedE3MoritaniInventory(g: Game): void {
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());
[...treacheryDeck(g.expansions), ...(g.ecazTreachery ? ecazTreacheryCards() : [])].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.nexusCards) {
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);
@@ -335,19 +344,27 @@ export function createMixedE3MoritaniLobby(options: MixedE3MoritaniOptions = {})
assert.ok(!modules.strongholdCards || advanced);
const discovery = options.discovery ?? !!g.discoveryEnabled;
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!g.leaderSkills : false);
const nexus = options.nexus ?? true, independent = options.independent ?? false;
assert.ok(nexus || skills, 'The no-Nexus mixed route requires the original all14 entry.');
assert.ok(!independent || !nexus && skills, 'Independent cards belong to the no-Nexus all14 route.');
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);
g.discoveryEnabled = discovery;
if (nexus) {
g.nexusCards ??= { cards: null, phase: null };
assert.deepEqual(g.nexusCards, { cards: null, phase: null });
} else assert.ok(!g.nexusCards, 'Never erase an already selected Nexus module.');
assert.ok(!g.advancedPreview && !g.semutaPreview && !g.leaderSkills && !g.ecazTreachery,
'A fresh lobby selects neither Skills nor independent cards before its real initializer.');
} 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.');
assert.equal(!!g.nexusCards?.cards, nexus, 'A supplied original deal keeps its selected Nexus deck or real absence.');
assert.equal(!!g.ecazTreachery, independent);
}
return g;
}
@@ -371,7 +388,9 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {})
let g = createMixedE3MoritaniLobby(options);
const actions: MixedE3MoritaniStep[] = [], staging: string[] = [];
const skills = g.status === 'lobby' ? options.skills ?? false : !!g.leaderSkills;
const independent = options.independent ?? false;
const entry = mixedE3MoritaniEntry(options, g, skills);
assert.ok(options.nexus !== false || entry === 'leaderSkills', 'The no-Nexus route uses the all14 initializer.');
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');
@@ -393,15 +412,17 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {})
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' });
// The real initializer is the only selector of the independent variant.
if (independent) { assert.ok(!g.ecazTreachery); g.ecazTreachery = true; }
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);
const deck = [...treacheryDeck(g.expansions), ...(g.ecazTreachery ? ecazTreacheryCards() : [])], remaining = [...deck];
const take = (kind: Card['kind'] | EcazTreacheryCardId) => {
const at = remaining.findIndex(c => c.id === kind || c.kind === kind);
assert.ok(at >= 0, `The original deck must hold a ${kind}.`);
return remaining.splice(at, 1)[0].id;
};
@@ -422,7 +443,7 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {})
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));
if (g.nexusCards) 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);
@@ -570,7 +591,7 @@ export function createMixedE3MoritaniProgramme(options: MixedE3MoritaniOptions =
result.firstMentat = firstMentat;
g = advanceMixedE3Moritani(g, s => s.phase === 1 && mixedE3MoritaniClean(s), f.actions);
orderMixedE3MoritaniSpice(g, true);
const closed = closeMixedE3Moritani(f, g, options.nexusCard);
const closed = g.nexusCards ? closeMixedE3Moritani(f, g, options.nexusCard) : null;
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);
@@ -626,7 +647,7 @@ export function revealMixedE3MoritaniPlans(g: Game, plans: readonly MixedE3Morit
/** 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 {
extra: { weapon?: string; defense?: string; reinforcementsSources?: NativeReserveSelections } = {}): Action {
const spice = mixedE3MoritaniPlayer(g, 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];
@@ -645,7 +666,7 @@ function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min'
* `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 {
options: { disc?: 'strongest' | 'held'; enemyCard?: string } = {}): 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);
@@ -662,14 +683,25 @@ export function stageMixedE3MoritaniLoss(f: MixedE3MoritaniSetup, state: Game,
: 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));
const enemyCard = options.enemyCard
? mixedE3MoritaniPlayer(g, f.native).hand.find(c => c.id === options.enemyCard)
: mixedE3MoritaniPlayer(g, f.native).hand.find(c => isWeaponCard(c));
assert.ok(!options.enemyCard || enemyCard, 'An explicitly selected card must be in the native’s original hand.');
const feeSources = enemyCard?.id === REINFORCEMENTS_CARD && g.homeworlds
? viewGame(g, f.native).battle?.reinforcements?.homeworldSources?.[0] : undefined;
if (enemyCard?.id === REINFORCEMENTS_CARD && g.homeworlds)
assert.ok(feeSources, 'The native must pay from its actual offered World source.');
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 } : {}) },
enemyCard ? { weapon: enemyCard.id, ...(feeSources ? { reinforcementsSources: structuredClone(feeSources) } : {}) } : {}) },
];
if (options.enemyCard) {
const plan = plans[1].action; assert.ok(plan.type === 'battlePlan');
assert.equal(plan.weapon, options.enemyCard, 'Never silently drop the selected consumer card.');
}
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,
@@ -715,7 +747,9 @@ export function quoteMixedE3MoritaniBattle(g: Game): BattleResolutionQuote {
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 })) };
.map(a => ({ skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })),
...(view.reinforcements ? { reinforcementsReserves: {
normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0 } } : {}) };
};
const publicBattle = viewGame(g, g.host).battle!;
const home = homeworldBattleLocation(g, b.territory);
@@ -726,7 +760,7 @@ export function quoteMixedE3MoritaniBattle(g: Game): BattleResolutionQuote {
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 });
pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: !!g.nexusCards });
}
/** True printed Horror/Extortion/… token identities from the actual supply. */
+77 -3
View File
@@ -11,6 +11,8 @@ 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 { defaultHarassWithdrawAllocation } from '../game/harass-withdraw';
import { stageEcazIndependentSkillsCard } from './fixture-ecaz-independent-skills';
import {
advanceMixedE3EcazNexus as advance, createMixedE3EcazNexusCoalition as coalition,
createMixedE3EcazNexusProgramme as programme, createMixedE3EcazNexusSetup as setup,
@@ -187,7 +189,7 @@ void test('all-three-family six-native original setup and real alliance closing
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);
const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead');
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) {
@@ -462,7 +464,6 @@ for (const advanced of [false, true]) void test(`${advanced ? 'Advanced selected
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) {
@@ -559,7 +560,6 @@ for (const skilled of [false, true]) void test(`original Ecaz native ${skilled ?
: 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);
@@ -591,3 +591,77 @@ for (const skilled of [false, true]) void test(`original Ecaz native ${skilled ?
inventory(rescue);
}
});
void test('no-Nexus Basic all14 Ecaz/Ix coalition settles real allied counters and a Tech reward without independent cards', () => {
const f = coalition({ nexus: false, advanced: false, skills: true, tech: true,
roster: ['ecaz', 'ixians', 'guild'] });
const ready = chooseLead(f, f.owner), revealed = reveal(ready, coalitionPlans(f, ready, f.owner));
const q = quote(revealed), done = settle(revealed);
assert.equal(q.winner, f.owner);
assert.deepEqual(q.fixedLosses, [{ owner: f.owner, normal: 2, elite: 0 }]);
assert.equal(q.casualties!.owner, f.ally);
assert.equal(player(done, f.owner).tanks - player(ready, f.owner).tanks, 2);
assert.equal(player(done, f.ally).tanks - player(ready, f.ally).tanks, 4);
assert.equal(player(done, f.opponent).tanks - player(ready, f.opponent).tanks, 8);
assert.equal(done.techTokens![ownedTech(revealed.techTokens, f.opponent)[0]].owner, f.owner);
inventory(done);
});
void test('no-Nexus mixed E3/E1 all14 World coalition earns Tabr and spends its own R/H sources before committed-only trained Suk rescue', () => {
const f = coalition({ nexus: false, independent: true, advanced: true, skills: true,
roster: ['ecaz', 'moritani', 'ixians', 'guild'], skill: 'suk-graduate',
homeworlds: true, tech: true, strongholds: true }, 'moritani');
assert.equal(f.firstMentat!.after.strongholdCards!.owners.sietch_tabr, f.owner,
'The holder earned this card from the original paid turn-one claim');
const ready = chooseLead(f, f.owner), staging: string[] = [];
const weapon = stageEcazIndependentSkillsCard(ready, f.owner, 'ecaz-reinforcements', staging);
const defense = stageEcazIndependentSkillsCard(ready, f.owner, 'ecaz-harass-withdraw', staging);
inventory(ready);
const own = viewGame(ready, f.owner).battle!, profile = own.ecazOccupy!.profile!;
const sources = own.reinforcements?.homeworldSources?.[0]; assert.ok(sources);
const lead: MixedE3EcazNexusStep = { actor: f.owner, action: { type: 'battlePlan',
leader: f.trainers[f.owner], dial: profile.fixedEcazDial + 2, support: 2, weapon, defense,
reinforcementsSources: structuredClone(sources) } };
const defender = player(ready, f.opponent).leaders.filter(l => !l.dead && !l.usedAt &&
!ready.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(defender);
const revealed = reveal(ready, [lead, { actor: f.opponent,
action: { type: 'battlePlan', leader: defender.id, dial: 1, support: 1 } }]);
const allocation = viewGame(revealed, f.owner).battle?.harassAllocation;
const returns = allocation ? defaultHarassWithdrawAllocation(allocation.context, allocation.dial, allocation.support) : {};
const q = quote(revealed, returns);
assert.equal(q.winner, f.owner);
assert.deepEqual(q.fixedLosses, [{ owner: f.owner, normal: 3, elite: 0 }]);
assert.equal(q.casualties!.owner, f.ally);
assert.equal(q.sukGraduate?.mode, 'skilled');
assert.deepEqual(q.reinforcements, [{ player: f.owner, card: 'ecaz-reinforcements', normal: 3, elite: 0 }]);
assert.deepEqual(q.strongholdIncome, [{ player: f.owner, amount: 1 }],
'The earned Tabr card supplies an actual receipt from the opposing one-force dial');
let g = revealed;
if (g.decision?.kind === 'harassWithdraw') g = step(g, { actor: f.owner,
action: { type: 'decision', event: g.decision.event, returns } });
const rescue = settle(g, s => s.decision?.kind === 'sukRescue');
const d = rescue.decision; assert.ok(d?.kind === 'sukRescue');
assert.equal(d.player, f.owner);
assert.ok(d.options.every(option => option.normal + option.elite <= 3),
'Suk cannot rescue the allied variable commitment, card fee or returned complement');
const choice = d.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept);
assert.ok(choice >= 0);
const done = settle(step(rescue, { actor: f.owner, action: { type: 'decision', event: d.event, choice } }));
const collection = done.phase === 7
? quoteSpiceCollection(done).receipts.find(receipt => receipt.player === f.owner)?.strongholds ?? 0 : 0;
assert.equal(player(done, f.owner).spice, player(revealed, f.owner).spice - 2 + 1 + collection,
'Own variable support is paid and the earned Tabr receipt reaches the real purse');
assert.equal(player(done, f.owner).reserves, player(ready, f.owner).reserves - 3 + 2 + 2,
'The own three-reserve fee precedes two Harass returns, then two rescued counters return to reserves');
assert.equal(player(done, f.owner).tanks, player(ready, f.owner).tanks + 3,
'Only the non-rescuable own Reinforcements fee reaches Ecaz Tanks');
assert.equal(player(done, f.owner).forces[f.location], 1, 'Only the selected kept Suk counter stays');
assert.equal(player(done, f.ally).tanks, player(ready, f.ally).tanks + 2);
assert.equal(player(done, f.ally).forces[f.location], 2);
for (const card of [weapon, defense]) {
assert.equal(done.discard.filter(c => c.id === card).length, 1);
assert.ok(!player(done, f.owner).hand.some(c => c.id === card));
}
inventory(done);
});
+93 -2
View File
@@ -4,6 +4,10 @@ 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 { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { territory } from '../game/board';
import { TERROR_KINDS } from '../game/moritani-terror';
import {
@@ -12,7 +16,7 @@ import {
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,
mixedE3MoritaniToken as token, quoteMixedE3MoritaniBattle as quote,
settleMixedE3MoritaniBoundary as boundary,
stageMixedE3MoritaniLoss as loss, stepMixedE3Moritani as step,
type MixedE3MoritaniBoundary, type MixedE3MoritaniOptions,
@@ -295,5 +299,92 @@ void test('all four original policies consume the real private assassination win
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.');
});
void test('no-Nexus all14 mixed Advanced Moritani loss against a native CHOAM keeps its World-sourced Reinforcements fee, technique return, bounty and single Mentat replacement', () => {
const options: MixedE3MoritaniOptions = { rules: 'advanced', families: ['choam'], natives: { choam: 'choam' },
skills: true, nexus: false, independent: true, homeworlds: true, tech: true, strongholds: true,
roster: ['moritani', 'choam', 'richese', 'guild'], startingCards: { choam: [REINFORCEMENTS_CARD] } };
const f = programme(options);
assert.equal(f.afterSetup.leaderSkills!.assignments.some(a => a.leader === DUKE_VIDAL_ID), false);
assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.native)!.leader, f.target,
'The trained disc the loss will assassinate is the real held Traitor target.');
physical(f.afterSetup);
assert.deepEqual(f.afterSetup.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort(),
'The printed Terror supply stays physical without Nexus.');
const battle = loss(f, f.game, { enemyCard: REINFORCEMENTS_CARD });
assert.equal(quote(battle.revealed).winner, f.native);
const plan = battle.plans[1].action; assert.ok(plan.type === 'battlePlan');
assert.deepEqual(plan.reinforcementsSources, { 'homeworld:choam': { normal: 3, elite: 0 } },
'The fee is declared from the payer’s own native World, exactly as offered.');
const native = (state: Game) => homeworldForceGroups(homeworldContext(state), state.homeworlds!.custody!)
.find(home => home.native === f.native)!.forces[f.native] ?? { normal: 0, elite: 0 };
const arrangements = battle.arrangements;
let chosen: Game | undefined;
const walk = aftermath(battle.revealed, null, 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, { type: 'decision', event: d.event, 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,
at.leaders.find(l => l.id === f.target)!.deaths + 1);
assert.equal(player(chosen, f.moritani).spice, own.spice + at.leaders.find(l => l.id === f.target)!.strength,
'The printed bounty is paid once from the bank.');
assert.equal(player(chosen, f.native).spice, at.spice);
assert.equal(player(chosen, f.native).reserves, player(arrangements, f.native).reserves - 3,
'The card user pays the printed three-reserve fee before any rescue.');
assert.equal(player(chosen, f.moritani).reserves, player(arrangements, f.moritani).reserves,
'The defeated opponent pays nothing towards the winner’s card fee.');
assert.equal(native(chosen).normal, native(arrangements).normal - 3,
'Exactly three counters leave the payer’s own native World reserve.');
assert.equal(player(chosen, f.native).tanks, player(arrangements, f.native).tanks +
3 + (player(arrangements, f.native).forces[f.location] ?? 0),
'The fee and the payer’s surviving committed counter are the only Tanks arrivals.');
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,
'The assassinated trained disc returns its physical skill exactly once.');
assert.equal(totalCounters(chosen, f.native), totalCounters(boundaryGame, f.native));
assert.equal(totalCounters(chosen, f.moritani), totalCounters(boundaryGame, f.moritani));
return chosen;
});
assert.ok(chosen, 'The Advanced native loss must open its private reveal choice.');
assert.ok(walk.order.includes('assassination'), walk.order.join(','));
assert.equal(walk.order.at(-1), 'settled');
const feeTanks = player(chosen, f.native).tanks;
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);
assert.equal(receipt.replacement, chosen.traitorReserve![0]);
assert.equal(settled.traitorReserve!.length, chosen.traitorReserve!.length - 1,
'Exactly one private Mentat replacement.');
assert.ok(player(settled, f.moritani).traitors.includes(receipt.replacement!));
assert.equal(player(settled, f.native).tanks, feeTanks,
'The already-paid fee stays in the payer’s Tanks through the settled battle.');
assert.equal(Object.hasOwn(viewGame(settled, f.native).moritaniAssassinate!.history.at(-1)!, 'replacement'), false);
physical(settled);
});
void test('the same no-Nexus mixed roster without independent cards keeps a real Basic continuation and grants no Advanced assassination window', () => {
const options: MixedE3MoritaniOptions = { rules: 'basic', families: ['choam'], natives: { choam: 'choam' },
skills: true, nexus: false, roster: ['moritani', 'choam', 'guild'] };
const f = programme(options);
assert.equal([...f.afterSetup.deck, ...f.afterSetup.discard,
...f.afterSetup.players.flatMap(p => p.hand)].some(c => c.id.startsWith('ecaz-')), false,
'Unselected independent cards stay out of the physical pool.');
physical(f.afterSetup);
const battle = loss(f, f.game);
assert.equal(quote(battle.revealed).winner, f.native);
const walk = aftermath(battle.revealed, null);
assert.equal(walk.order.at(-1), 'settled');
assert.equal(walk.order.includes('assassination'), false, walk.order.join(','));
assert.equal(walk.game.moritaniAssassinate, undefined);
assert.equal(walk.game.traitorReserve!.length, battle.revealed.traitorReserve!.length,
'No Traitor is consumed or replaced without the Advanced window.');
physical(walk.game);
});