Unify registered native E3 Leader Skills capability
This commit is contained in:
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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user