Compose selected E1/E2 independent cards with Nexus rules
Retain selected decks and original optional modules through normal setup, distinguish native Ixian Betrayal from absent-faction replacement, route explicit CHOAM fuel before printed effects, and preserve native No-Field invoices and pair continuation. Connect canonical native cancellation stock and existing human/minimal AI consumers. Validation: 194 affected tests across 19 files, types/lint/build, bounded native invoice review and seven finite original-action/rendered-control programmes. Runtime ec5ad6c18996d8d958ec0d01132ef3e3ea6d23d36188902eca9a63d4db2efbf8; documentation-only proof overlay a5892004937434429916496497c07a3838a324b4a5d64d4a0eb804ab5212d5bd. Other envelopes, full combinations, comprehensive assurance and deployed acceptance remain open. No saved-room/provider changes.
This commit is contained in:
1 parent
999edb67e6
commit
8422cf1f2d
20 files changed
+1413
-97
No files matched your search
@@ -22,6 +22,7 @@ export type IndependentCardModulesOptions = {
|
||||
/** False keeps the original native deck for revealed-card comparisons. */
|
||||
independent?: boolean;
|
||||
homeworlds?: boolean;
|
||||
nexus?: boolean;
|
||||
};
|
||||
|
||||
/** Select only the original undealt fourteen-card lottery, never assignments. */
|
||||
@@ -56,6 +57,7 @@ export function createIndependentCardModulesFixture(options: IndependentCardModu
|
||||
advanced, options.expansions ?? []);
|
||||
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
if (options.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
if (options.nexus) game = applyAction(game, game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
|
||||
if (options.tech ?? true) game.techTokens = createTechTokens();
|
||||
if (options.strongholds) game.strongholdCards = createStrongholdCards();
|
||||
const steps: IndependentCardModulesStep[] = [];
|
||||
@@ -106,6 +108,13 @@ export function createIndependentCardModulesFixture(options: IndependentCardModu
|
||||
|
||||
/** Reuse original native choices, including all phase/module windows. */
|
||||
export function nextIndependentCardModulesStep(game: Game): IndependentCardModulesStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.decision && game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const phase = game.nexusCards.phase;
|
||||
const actor = phase.eligible.find(id => !phase.done.includes(id));
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import {
|
||||
createIndependentCardModulesFixture, type IndependentCardModulesOptions,
|
||||
type IndependentCardModulesStep,
|
||||
} from './fixture-independent-card-modules';
|
||||
import {
|
||||
applyClassicDiscoveryNexusStep, nextClassicDiscoveryNexusStep, withClassicDiscoveryNexusLottery,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type SelectedIndependentNexusBattleStep = IndependentCardModulesStep;
|
||||
export type SelectedIndependentNexusBattleOptions = Pick<IndependentCardModulesOptions,
|
||||
'advanced' | 'expansions' | 'homeworlds' | 'skills' | 'tech' | 'strongholds'> & {
|
||||
program?: 'mixed-return' | 'substitution' | 'faceDance';
|
||||
};
|
||||
export const selectedIndependentNexusPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const selectedIndependentNexusClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard &&
|
||||
!game.pendingGuildBetrayal && !game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
|
||||
|
||||
/** Original native windows only. Sealed plans and the selected typed Harass
|
||||
* allocation remain explicit, and a Face Dance is declined unless requested. */
|
||||
export function nextSelectedIndependentNexusBattleStep(game: Game): SelectedIndependentNexusBattleStep | null {
|
||||
if (game.pendingGuildBetrayal) {
|
||||
const frame = game.pendingGuildBetrayal;
|
||||
const actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'guildBetrayalPass', event: frame.invoice.event } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
if (game.decision?.kind === 'harassWithdraw') return null;
|
||||
if (game.pendingNexusIxianBetrayal) {
|
||||
const offer = game.pendingNexusIxianBetrayal;
|
||||
const actor = offer.required.find(id => !offer.passed.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusIxianBetrayalPass', event: offer.source.event } };
|
||||
}
|
||||
}
|
||||
// The nine-Suboid first shipment legitimately spends the final nine spice
|
||||
// after its paid opening bid. Basic has no later stronghold income: claim
|
||||
// the real next-turn charity before acknowledgement closes that phase.
|
||||
if (selectedIndependentNexusClean(game) && game.phase === 2) {
|
||||
const view = viewGame(game, 'ixians');
|
||||
const own = view.players.find(player => player.id === 'ixians')!;
|
||||
if (!own.charityClaimed && !view.charity.incomePending && view.charity.amount > 0)
|
||||
return { actor: 'ixians', action: { type: 'charity' } };
|
||||
}
|
||||
return nextClassicDiscoveryNexusStep(game);
|
||||
}
|
||||
export function actSelectedIndependentNexusBattle(game: Game, step: SelectedIndependentNexusBattleStep,
|
||||
actions?: SelectedIndependentNexusBattleStep[]): Game {
|
||||
return applyClassicDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
export function advanceSelectedIndependentNexusBattle(game: Game, until: (state: Game) => boolean,
|
||||
actions: SelectedIndependentNexusBattleStep[] = []): Game {
|
||||
for (let count = 0; count < 2200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'Original selected-family game ended before its requested rule window');
|
||||
const step = nextSelectedIndependentNexusBattleStep(game);
|
||||
assert.ok(step, `A real plan/allocation is required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSelectedIndependentNexusBattle(game, step, actions);
|
||||
}
|
||||
throw Error('Selected independent Nexus programme did not reach its native boundary');
|
||||
}
|
||||
|
||||
/** Reorder only the still-unopened physical Treachery deck. The Ixian native
|
||||
* pool returns a different card, then the owner buys the real desired lot. */
|
||||
function purchaseOriginalCard(game: Game, desired: string, actions: SelectedIndependentNexusBattleStep[], staging: string[]) {
|
||||
assert.equal(game.phase, 2);
|
||||
const at = game.deck.findIndex(card => card.id === desired);
|
||||
assert.ok(at >= 0, 'The targeted purchase must still be an undealt original card');
|
||||
game.deck.unshift(game.deck.splice(at, 1)[0]);
|
||||
staging.push(`Order unopened ${desired} before the original turn-${game.turn} Ixian auction pool; acquire it only through the printed auction and payment.`);
|
||||
let bid: { before: Game; step: SelectedIndependentNexusBattleStep; after: Game } | undefined;
|
||||
for (let count = 0; count < 350; count++) {
|
||||
if (selectedIndependentNexusPlayer(game, 'ixians').hand.some(card => card.id === desired)) {
|
||||
assert.ok(bid, 'The native hand must acquire its target by an actual paid bid');
|
||||
return { game, bid };
|
||||
}
|
||||
let step: SelectedIndependentNexusBattleStep | null = null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ixAuction') {
|
||||
const returned = game.ixAuction!.cards.find(card => card.id !== desired); assert.ok(returned);
|
||||
assert.ok(game.ixAuction!.cards.some(card => card.id === desired));
|
||||
step = { actor: 'ixians', action: { type: 'decision', card: returned.id, position: 'bottom' } };
|
||||
} else if (selectedIndependentNexusClean(game) && game.auction?.active === 'ixians' &&
|
||||
game.auction.cards[game.auction.index].id === desired && game.auction.bid === 0) {
|
||||
const before = structuredClone(game);
|
||||
step = { actor: 'ixians', action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||||
game = actSelectedIndependentNexusBattle(game, step, actions);
|
||||
bid = { before, step, after: structuredClone(game) };
|
||||
continue;
|
||||
}
|
||||
step ??= nextSelectedIndependentNexusBattleStep(game); assert.ok(step);
|
||||
game = actSelectedIndependentNexusBattle(game, step, actions);
|
||||
}
|
||||
throw Error('Original selected-family auction did not deliver its physical card');
|
||||
}
|
||||
|
||||
/** Fresh selected E1/E2 setup, original paid auctions and shipments, real
|
||||
* reciprocal alliance, closing draw and native battle. No dealt hand, board
|
||||
* counter, wallet, phase, Face Dancer, receipt or module flag is rewritten. */
|
||||
export function createSelectedIndependentNexusBattleFixture(options: SelectedIndependentNexusBattleOptions = {}) {
|
||||
const program = options.program ?? 'substitution', advanced = options.advanced ?? true;
|
||||
const initialize = () => createIndependentCardModulesFixture({ ...options, advanced,
|
||||
expansions: options.expansions ?? ['ix'], factions: ['ixians', 'tleilaxu', 'guild', 'emperor'],
|
||||
independent: true, nexus: true, tech: options.tech ?? false });
|
||||
// The constructor restores the caller's entropy method after its initializer;
|
||||
// retain the original scalar lottery through later setup actions as well.
|
||||
// finishSetup then shuffles the unopened native stock and deals its actual
|
||||
// Face Dancers; no already-dealt identity is exchanged for a desired one.
|
||||
const source = program === 'faceDance' ? withClassicDiscoveryNexusLottery(initialize) : initialize();
|
||||
let game = structuredClone(source.game);
|
||||
const afterSetup = structuredClone(game), actions = [...source.steps], staging: string[] = [];
|
||||
if (program === 'faceDance') staging.push('Select the original unopened final setup Face Dancer shuffle through the scalar lottery; retain the three genuinely dealt identities.');
|
||||
const act = (actor: string, action: Action) => {
|
||||
game = actSelectedIndependentNexusBattle(game, { actor, action }, actions);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
game = advanceSelectedIndependentNexusBattle(game, until, actions);
|
||||
};
|
||||
// First turn has no worm. The ordinary second-turn worm opens the actual
|
||||
// alliance Nexus before the existing native closing-draw controller.
|
||||
const takeLand = () => {
|
||||
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
||||
assert.ok(at >= 0); return game.spiceDeck.splice(at, 1)[0];
|
||||
};
|
||||
game.spiceDeck.unshift(takeLand(), ...(advanced ? [takeLand()] : []));
|
||||
staging.push('Position unopened first-turn ordinary land Spice Cards; later choose one original second-turn worm before it is drawn.');
|
||||
reach(state => state.phase === 2 && selectedIndependentNexusClean(state));
|
||||
const purchase = purchaseOriginalCard(game, REINFORCEMENTS_CARD, actions, staging); game = purchase.game;
|
||||
const purchases = [purchase.bid];
|
||||
const kind = 'arrakeen', sector = territory(kind).sectors[0], location = `${kind}:${sector}`;
|
||||
reach(state => state.phase === 5 && state.active === 'ixians' && selectedIndependentNexusClean(state));
|
||||
const amount = program === 'mixed-return' ? 9 : 6, elite = program === 'mixed-return' ? 0 : 3;
|
||||
const homeworldSources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'ixians'), amount, elite) : undefined;
|
||||
assert.ok(!game.homeworlds || homeworldSources);
|
||||
const beforeShipment = structuredClone(game);
|
||||
const ownShipment: SelectedIndependentNexusBattleStep = { actor: 'ixians', action: {
|
||||
type: 'ship', territory: kind, sector, amount, elite, allyPayment: 0,
|
||||
...(homeworldSources ? { homeworldSources } : {}),
|
||||
} };
|
||||
game = actSelectedIndependentNexusBattle(game, ownShipment, actions);
|
||||
reach(state => selectedIndependentNexusClean(state));
|
||||
const afterShipment = structuredClone(game);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && selectedIndependentNexusClean(state));
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed);
|
||||
assert.ok(worm >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && selectedIndependentNexusClean(state));
|
||||
act('guild', { type: 'alliance', target: 'emperor' });
|
||||
act('emperor', { type: 'alliance', target: 'guild' });
|
||||
reach(state => state.nexusCards?.phase?.stage === 'drawing');
|
||||
const cards = game.nexusCards!.cards!, next = cards.deck.indexOf('ixians'); assert.ok(next >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(next, 1)[0]);
|
||||
staging.push('Order only the still-undealt Ixian Nexus singleton; the unallied native receives it through its original closing draw.');
|
||||
const beforeNexusDraw = structuredClone(game);
|
||||
const drawStep: SelectedIndependentNexusBattleStep = { actor: 'ixians', action: {
|
||||
type: 'nexusCardChoice', turn: game.turn, card: cards.hands.ixians, choice: 'draw', ownRedraws: 0,
|
||||
} };
|
||||
game = actSelectedIndependentNexusBattle(game, drawStep, actions);
|
||||
const afterNexusDraw = structuredClone(game);
|
||||
if (program === 'mixed-return') {
|
||||
reach(state => state.phase === 2 && selectedIndependentNexusClean(state));
|
||||
const purchase = purchaseOriginalCard(game, HARASS_WITHDRAW_CARD, actions, staging);
|
||||
game = purchase.game; purchases.push(purchase.bid);
|
||||
}
|
||||
reach(state => state.phase === 5 && state.active === 'guild' && selectedIndependentNexusClean(state));
|
||||
const enemySources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'guild'), 3, 0) : undefined;
|
||||
assert.ok(!game.homeworlds || enemySources);
|
||||
const enemyShipment: SelectedIndependentNexusBattleStep = { actor: 'guild', action: {
|
||||
type: 'ship', territory: kind, sector, amount: 3, elite: 0, allyPayment: 0,
|
||||
...(enemySources ? { homeworldSources: enemySources } : {}),
|
||||
} };
|
||||
game = actSelectedIndependentNexusBattle(game, enemyShipment, actions);
|
||||
reach(state => state.phase === 6 && selectedIndependentNexusClean(state));
|
||||
const beforeBattle = structuredClone(game), choices = viewGame(game, game.active!).battleChoices;
|
||||
const choice = choices.find(battle => battle.territory === kind); assert.ok(choice);
|
||||
const battleStep: SelectedIndependentNexusBattleStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory: kind, target: choice.chooser === 'ixians' ? 'guild' : 'ixians',
|
||||
} };
|
||||
game = actSelectedIndependentNexusBattle(game, battleStep, actions);
|
||||
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && selectedIndependentNexusClean(state));
|
||||
const ix = selectedIndependentNexusPlayer(game, 'ixians'), tleilaxu = selectedIndependentNexusPlayer(game, 'tleilaxu');
|
||||
const leaders = ix.leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills?.assignments.some(assignment => assignment.leader === leader.id));
|
||||
const leader = (program === 'faceDance' ? leaders.filter(leader =>
|
||||
tleilaxu.faceDancers!.some(dancer => !dancer.revealed && dancer.leader === leader.id)) : leaders)
|
||||
.sort((one, two) => two.strength - one.strength)[0];
|
||||
assert.ok(leader, 'Use an actually dealt matching native Face Dancer; never exchange identities');
|
||||
const enemy = selectedIndependentNexusPlayer(game, 'guild').leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills?.assignments.some(assignment => assignment.leader === leader.id))
|
||||
.sort((one, two) => one.strength - two.strength)[0]; assert.ok(enemy);
|
||||
const sources = viewGame(game, 'ixians').battle!.reinforcements!.homeworldSources?.[0];
|
||||
const plans: SelectedIndependentNexusBattleStep[] = [
|
||||
{ actor: 'ixians', action: { type: 'battlePlan', leader: leader.id,
|
||||
dial: program === 'mixed-return' ? 1 : 3, support: advanced && program !== 'mixed-return' ? 1 : 0,
|
||||
weapon: REINFORCEMENTS_CARD, defense: program === 'mixed-return' ? HARASS_WITHDRAW_CARD : null,
|
||||
...(sources ? { reinforcementsSources: sources } : {}),
|
||||
} },
|
||||
{ actor: 'guild', action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
|
||||
];
|
||||
const normal = Math.min(3, ix.reserves - (ix.elites?.reserves ?? 0));
|
||||
return { ...source, afterSetup, afterFirstMentat, beforeShipment, ownShipment, afterShipment,
|
||||
beforeNexusDraw, drawStep, afterNexusDraw, enemyShipment, beforeBattle, battleStep,
|
||||
game, program, kind, sector, location, plans, actions, staging, purchases,
|
||||
leader: leader.id, fee: { normal, elite: 3 - normal },
|
||||
returns: program === 'mixed-return' ? { [location]: { normal: 8, elite: 0 } } : null };
|
||||
}
|
||||
|
||||
export function activateSelectedIndependentSuboids(game: Game, actions: SelectedIndependentNexusBattleStep[] = []): Game {
|
||||
const offer = viewGame(game, 'ixians').nexusSuboids!.offer!; assert.ok(offer && !offer.blocked);
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'nexusSuboids', event: offer.event } }, actions);
|
||||
return advanceSelectedIndependentNexusBattle(game, selectedIndependentNexusClean, actions);
|
||||
}
|
||||
export function revealSelectedIndependentNexusPlans(game: Game, plans: readonly SelectedIndependentNexusBattleStep[],
|
||||
actions: SelectedIndependentNexusBattleStep[] = []): Game {
|
||||
for (const step of plans) {
|
||||
game = actSelectedIndependentNexusBattle(game, step, actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
(!state.decision || state.decision.kind === 'harassWithdraw') &&
|
||||
!state.pendingNexusIxianBetrayal && !state.pendingGuildBetrayal, actions);
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions, type IndependentCardModulesStep } from './fixture-independent-card-modules';
|
||||
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type SelectedIndependentNexusStep = IndependentCardModulesStep;
|
||||
export type SelectedIndependentNexusOptions = Pick<IndependentCardModulesOptions, 'advanced' | 'skills' | 'tech' | 'strongholds'> & {
|
||||
expansions?: Game['expansions'];
|
||||
/** A real first-turn bribe funds a paid ten-force turn-two shipment; the
|
||||
* original drawn Cunning is retained for turn three. */
|
||||
drainReserves?: boolean;
|
||||
choamNexus?: 'choam' | 'ixians';
|
||||
};
|
||||
export const selectedIndependentNexusClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
|
||||
!game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
|
||||
export function selectedIndependentNexusPlayer(game: Game, actor: string) {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
}
|
||||
export function actSelectedIndependentNexus(game: Game, actor: string, action: Action, actions?: SelectedIndependentNexusStep[]): Game {
|
||||
actions?.push({ actor, action: structuredClone(action) });
|
||||
return withClassicDiscoveryNexusLottery(() => {
|
||||
// The original action owns all payment, marker, card and force transitions.
|
||||
// Stable scalar lotteries only order still-unused native shuffles.
|
||||
return applyAction(game, actor, action);
|
||||
});
|
||||
}
|
||||
|
||||
function nextSelectedIndependentNexus(game: Game): SelectedIndependentNexusStep | null {
|
||||
if (game.pendingGuildBetrayal) {
|
||||
const pending = game.pendingGuildBetrayal;
|
||||
const actor = pending.required.find(actor => !pending.passed.includes(actor)); assert.ok(actor);
|
||||
return { actor, action: { type: 'guildBetrayalPass', event: pending.invoice.event } };
|
||||
}
|
||||
if (game.pendingNexusIxianReplacement)
|
||||
return { actor: game.pendingNexusIxianReplacement.buyer,
|
||||
action: { type: 'nexusIxianReplacementPass', event: game.pendingNexusIxianReplacement.event } };
|
||||
if (!game.phaseOpening && !game.response && !game.truthtrance) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'homeworldDefense')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } };
|
||||
if (decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
||||
if (decision?.kind === 'richeseDeclaration') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, position: 'first' } };
|
||||
if (decision?.kind === 'richeseBlackMarket') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, decline: true } };
|
||||
if (decision?.kind === 'richeseCache') {
|
||||
const card = game.richeseCache![0]; assert.ok(card);
|
||||
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
}
|
||||
if (decision?.kind === 'richeseUnbid') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
if (!decision && game.richeseAuction && !game.richeseAuction.outcome) {
|
||||
const lot = game.richeseAuction;
|
||||
const actor = lot.method === 'silent'
|
||||
? lot.order.find(actor => lot.eligible.includes(actor) && !Object.hasOwn(lot.sealed, actor)) : lot.active;
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
|
||||
}
|
||||
}
|
||||
return nextPairedChoamSkillsStep(game);
|
||||
}
|
||||
export function advanceSelectedIndependentNexus(game: Game, until: (state: Game) => boolean,
|
||||
actions: SelectedIndependentNexusStep[] = [], basicStormDial?: number): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The real selected-deck chronology ended before its card control');
|
||||
const step = nextSelectedIndependentNexus(game); assert.ok(step, 'Supply the current original human Battle Plan');
|
||||
const action = basicStormDial !== undefined && !game.advanced && step.action.type === 'stormDial'
|
||||
? {...step.action,amount:basicStormDial} : step.action;
|
||||
game = actSelectedIndependentNexus(game, step.actor, action, actions);
|
||||
}
|
||||
throw Error('The selected-deck independent/Nexus chronology did not reach its requested boundary');
|
||||
}
|
||||
function frontOriginalCard(game: Game, id: string): void {
|
||||
const index = game.deck.findIndex(card => card.id === id); assert.ok(index >= 0, `Original unused ${id} must remain in the deck`);
|
||||
game.deck.unshift(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
function orderOriginalBlow(game: Game, worm = false): void {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
if (worm) {
|
||||
const index = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed);
|
||||
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
|
||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
||||
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
}
|
||||
function buyOriginalNormalCard(game: Game, actor: string, card: string, actions: SelectedIndependentNexusStep[]): Game {
|
||||
for (let count = 0; count < 500; count++) {
|
||||
if (selectedIndependentNexusPlayer(game, actor).hand.some(face => face.id === card)) return game;
|
||||
let step: SelectedIndependentNexusStep | null;
|
||||
if (selectedIndependentNexusClean(game) && game.auction && !game.richeseAuction &&
|
||||
game.auction.cards[game.auction.index]?.id === card) {
|
||||
const auction = game.auction;
|
||||
step = { actor: auction.active, action: auction.active === actor && auction.bidder !== actor
|
||||
? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' } };
|
||||
} else step = nextSelectedIndependentNexus(game);
|
||||
assert.ok(step, 'The original normal lot must expose its bid/payment control');
|
||||
game = actSelectedIndependentNexus(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error(`The real first-turn normal auction did not deliver ${card}`);
|
||||
}
|
||||
|
||||
/** Fresh selected deck + independent3 + Nexus12 + optional all14/phase modules.
|
||||
* Real first-turn normal purchases supply Recruits and Reinforcements; real
|
||||
* first marker/reveal supplies the later reserve budget. Nothing is granted to
|
||||
* a played hand, wallet, army or receipt. Only unused original stock is ordered. */
|
||||
export function createSelectedIndependentNexusCardsFixture(options: SelectedIndependentNexusOptions = {}) {
|
||||
const source = createIndependentCardModulesFixture({ ...options, nexus: true, independent: true,
|
||||
tech: options.tech ?? false, expansions: options.expansions ?? ['choam'], factions: ['richese', 'choam', 'guild', 'atreides'] });
|
||||
let game = structuredClone(source.game);
|
||||
const afterSetup = structuredClone(game), actions = source.steps;
|
||||
// The bank deals in front order. Both physical independent cards remain
|
||||
// unused after the native initial base/E1/E2 starting deals.
|
||||
frontOriginalCard(game, REINFORCEMENTS_CARD); frontOriginalCard(game, RECRUITS_CARD_ID);
|
||||
orderOriginalBlow(game);
|
||||
const staging = ['Order the unopened original Recruits and Reinforcements as the first two normal bank lots; buy each for one spice through native bidding.',
|
||||
'Order original non-worm first Blow and the original second-turn worm/land Blow before they are drawn.'];
|
||||
game = buyOriginalNormalCard(game, 'choam', RECRUITS_CARD_ID, actions);
|
||||
game = buyOriginalNormalCard(game, 'richese', REINFORCEMENTS_CARD, actions);
|
||||
const afterPurchases = structuredClone(game);
|
||||
game = advanceSelectedIndependentNexus(game, state => state.phase === 5 && state.active === 'richese' && selectedIndependentNexusClean(state), actions);
|
||||
const marker = selectedIndependentNexusPlayer(game, 'richese').noField!.tokens.find(token => token.value === 0); assert.ok(marker);
|
||||
const firstShipmentBefore = structuredClone(game);
|
||||
const firstShipmentStep: SelectedIndependentNexusStep = { actor: 'richese', action: { type: 'ship', noField: marker.id,
|
||||
event: selectedIndependentNexusPlayer(game, 'richese').noFieldEvent, territory: 'habbanya_ridge_sietch', sector: 17 } };
|
||||
game = actSelectedIndependentNexus(game, firstShipmentStep.actor, firstShipmentStep.action, actions);
|
||||
game = advanceSelectedIndependentNexus(game, selectedIndependentNexusClean, actions);
|
||||
const firstShipmentAfter = structuredClone(game);
|
||||
const deployed = selectedIndependentNexusPlayer(game, 'richese').noField!.deployed; assert.ok(deployed);
|
||||
game = actSelectedIndependentNexus(game, 'richese', { type: 'revealNoField',
|
||||
event: selectedIndependentNexusPlayer(game, 'richese').noFieldEvent, token: deployed.tokenId }, actions);
|
||||
const afterFirstReveal = structuredClone(game);
|
||||
let reserveFunding: { before: Game; step: SelectedIndependentNexusStep; after: Game } | null = null;
|
||||
if (options.drainReserves) {
|
||||
const before = structuredClone(game), step: SelectedIndependentNexusStep = {
|
||||
actor: 'choam', action: { type: 'bribe', target: 'richese', amount: 9 },
|
||||
};
|
||||
game = actSelectedIndependentNexus(game, step.actor, step.action, actions);
|
||||
reserveFunding = { before, step, after: structuredClone(game) };
|
||||
staging.push('CHOAM pays a real nine-spice turn-one bribe to Richese; its native END Mentat credit funds the later paid reserve-draining shipment.');
|
||||
}
|
||||
game = advanceSelectedIndependentNexus(game, state => state.turn === 2 && state.phase === 1 && selectedIndependentNexusClean(state), actions);
|
||||
orderOriginalBlow(game, true);
|
||||
game = advanceSelectedIndependentNexus(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && selectedIndependentNexusClean(state), actions);
|
||||
const beforeAlliance = structuredClone(game);
|
||||
for (const [actor, target] of [['guild', 'atreides'], ['atreides', 'guild']])
|
||||
game = actSelectedIndependentNexus(game, actor, { type: 'alliance', target }, actions);
|
||||
game = advanceSelectedIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing' && selectedIndependentNexusClean(state), actions);
|
||||
const beforeClosingDraw = structuredClone(game), draws: Array<{ before: Game; step: SelectedIndependentNexusStep; after: Game }> = [];
|
||||
for (const [actor, face] of [['richese', 'richese'], ['choam', options.choamNexus ?? 'choam']] as const) {
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face); assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
const before = structuredClone(game), step: SelectedIndependentNexusStep = { actor,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
|
||||
game = actSelectedIndependentNexus(game, step.actor, step.action, actions);
|
||||
draws.push({ before, step, after: structuredClone(game) });
|
||||
}
|
||||
game = advanceSelectedIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'complete' && selectedIndependentNexusClean(state), actions);
|
||||
assert.equal(game.nexusCards!.cards!.hands.richese, 'richese');
|
||||
assert.equal(game.nexusCards!.cards!.hands.choam, options.choamNexus ?? 'choam');
|
||||
return { ...source, afterSetup, afterPurchases, firstShipment: { before: firstShipmentBefore, step: firstShipmentStep, after: firstShipmentAfter },
|
||||
afterFirstReveal, reserveFunding, beforeAlliance, beforeClosingDraw, draws, game, actions, staging };
|
||||
}
|
||||
|
||||
export function selectedIndependentChoamFuelAction(game: Game): Action {
|
||||
const play = viewGame(game, 'choam').choamWorthless!.plays.find(play => play.source === 'nexus' &&
|
||||
play.card.id === RECRUITS_CARD_ID && play.effect === 'kulon');
|
||||
assert.ok(play && play.source === 'nexus' && !play.blocked);
|
||||
return { type: 'card', mode: 'choam', card: RECRUITS_CARD_ID, effect: 'kulon', nexus: play.event };
|
||||
}
|
||||
export function selectedIndependentRichesePairAction(game: Game): Action {
|
||||
const player = selectedIndependentNexusPlayer(game, 'richese'), offer = viewGame(game, 'richese').nexusRicheseCunning;
|
||||
assert.ok(offer && !offer.blocked);
|
||||
const concealed = player.noField!.tokens.find(token => token.value === 3), revealed = player.noField!.tokens.find(token => token.value === 5);
|
||||
assert.ok(concealed && revealed);
|
||||
return { type: 'ship', noField: concealed.id, revealedToken: revealed.id, nexus: offer.event,
|
||||
event: player.noFieldEvent, territory: 'tueks_sietch', sector: 5, allyPayment: 0 };
|
||||
}
|
||||
/** Real two-marker Cunning attacks the untouched native Guild starting army.
|
||||
* Optional paid ten-force ordinary shipment leaves two reserves after the
|
||||
* later concealed 3 appears; the canonical marker values remain 0/3/5. */
|
||||
export function createSelectedIndependentNexusBattleFixture(options: SelectedIndependentNexusOptions = {}) {
|
||||
const source = createSelectedIndependentNexusCardsFixture(options), actions = source.actions;
|
||||
let game = advanceSelectedIndependentNexus(source.game, state => state.phase === 5 && state.active === 'richese' && selectedIndependentNexusClean(state), actions);
|
||||
let reserveShipment: { before: Game; step: SelectedIndependentNexusStep; after: Game } | null = null;
|
||||
if (options.drainReserves) {
|
||||
const before = structuredClone(game), step: SelectedIndependentNexusStep = { actor: 'richese',
|
||||
action: { type: 'ship', amount: 10, elite: 0, territory: 'habbanya_ridge_sietch', sector: 17, allyPayment: 0 } };
|
||||
game = actSelectedIndependentNexus(game, step.actor, step.action, actions);
|
||||
game = advanceSelectedIndependentNexus(game, selectedIndependentNexusClean, actions);
|
||||
reserveShipment = { before, step, after: structuredClone(game) };
|
||||
assert.equal(selectedIndependentNexusPlayer(game, 'richese').reserves, 10);
|
||||
assert.equal(selectedIndependentNexusPlayer(game, 'richese').spice,
|
||||
selectedIndependentNexusPlayer(before, 'richese').spice - 10);
|
||||
assert.equal(game.nexusCards!.cards!.hands.richese, 'richese');
|
||||
game = advanceSelectedIndependentNexus(game, state => state.turn === 3 && state.phase === 1 && selectedIndependentNexusClean(state), actions, 2);
|
||||
orderOriginalBlow(game);
|
||||
game = advanceSelectedIndependentNexus(game, state => state.turn === 3 && state.phase === 5 && state.active === 'richese' && selectedIndependentNexusClean(state), actions);
|
||||
source.staging.push('Richese pays ten spice for an actual ten-force ordinary turn-two shipment to empty Habbanya, then retains its original Nexus until turn-three Cunning. Actual legal Basic Storm dials of two leave the protected Guild stronghold available for shipment.');
|
||||
}
|
||||
const beforePair = structuredClone(game), pairAction = selectedIndependentRichesePairAction(game);
|
||||
game = actSelectedIndependentNexus(game, 'richese', pairAction, actions);
|
||||
game = advanceSelectedIndependentNexus(game, selectedIndependentNexusClean, actions);
|
||||
const afterPair = structuredClone(game);
|
||||
game = advanceSelectedIndependentNexus(game, state => state.phase === 6 && !!state.active && selectedIndependentNexusClean(state), actions);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === 'tueks_sietch'); assert.ok(choice);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory: choice.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker };
|
||||
game = actSelectedIndependentNexus(game, choice.chooser, battleAction, actions);
|
||||
game = advanceSelectedIndependentNexus(game, state => !!state.battle && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false && selectedIndependentNexusClean(state), actions);
|
||||
const available = (actor: string) => selectedIndependentNexusPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills?.assignments.some(assignment => assignment.owner === actor && assignment.leader === leader.id))
|
||||
.sort((a, b) => a.strength - b.strength);
|
||||
const own = available('richese').at(-1), other = available('guild')[0]; assert.ok(own && other);
|
||||
const plans: SelectedIndependentNexusStep[] = [
|
||||
{ actor: 'richese', action: { type: 'battlePlan', leader: own.id, dial: 2, support: game.advanced ? 2 : 0,
|
||||
defense: REINFORCEMENTS_CARD } },
|
||||
{ actor: 'guild', action: { type: 'battlePlan', leader: other.id, dial: 0, support: 0 } },
|
||||
];
|
||||
return { ...source, game, actions, reserveShipment, beforePair, pairAction, afterPair, battleAction, plans, location: 'tueks_sietch:5' };
|
||||
}
|
||||
export function revealSelectedIndependentNexusPlans(game: Game, plans: readonly SelectedIndependentNexusStep[], actions: SelectedIndependentNexusStep[] = []): Game {
|
||||
for (const plan of plans) {
|
||||
game = actSelectedIndependentNexus(game, plan.actor, plan.action, actions);
|
||||
game = advanceSelectedIndependentNexus(game, state => !!state.battle && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false && selectedIndependentNexusClean(state), actions);
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
|
||||
/** Actual compulsory cache auction, funded CHOAM bid and absent-Ix offer. The
|
||||
* discarded bought cache face is replaced by an original independent face. */
|
||||
export function createSelectedIndependentNexusCacheFixture(options: SelectedIndependentNexusOptions = {}) {
|
||||
const source = createSelectedIndependentNexusCardsFixture({ ...options, choamNexus: 'ixians' }), actions = source.actions;
|
||||
let game = advanceSelectedIndependentNexus(source.game, state => !state.phaseOpening && !state.response && state.decision?.kind === 'richeseCache', actions);
|
||||
const card = structuredClone(game.richeseCache![0]); assert.ok(card);
|
||||
game = actSelectedIndependentNexus(game, 'richese', { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'silent' }, actions);
|
||||
game = advanceSelectedIndependentNexus(game, selectedIndependentNexusClean, actions);
|
||||
const beforePurchase = structuredClone(game), lot = game.richeseAuction; assert.ok(lot && !lot.outcome);
|
||||
frontOriginalCard(game, HARASS_WITHDRAW_CARD);
|
||||
const replacement = structuredClone(game.deck[0]);
|
||||
for (let count = 0; !game.pendingNexusIxianReplacement && count < 200; count++) {
|
||||
const current = game.richeseAuction;
|
||||
let step: SelectedIndependentNexusStep | null;
|
||||
if (selectedIndependentNexusClean(game) && current && !current.outcome) {
|
||||
const actor = current.order.find(actor => current.eligible.includes(actor) && !Object.hasOwn(current.sealed, actor)); assert.ok(actor);
|
||||
step = { actor, action: { type: 'richeseBid', event: current.event, amount: actor === 'choam' ? 2 : 0 } };
|
||||
} else step = nextSelectedIndependentNexus(game);
|
||||
assert.ok(step); game = actSelectedIndependentNexus(game, step.actor, step.action, actions);
|
||||
}
|
||||
const offer = viewGame(game, 'choam').nexusIxianReplacement;
|
||||
assert.ok(offer && offer.origin === 'cache' && offer.canUse && offer.purchased?.id === card.id);
|
||||
return { ...source, game, actions, beforePurchase, card, replacement, price: 2,
|
||||
useAction: { type: 'nexusIxianReplacementUse', event: offer.event } as Action };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
|
||||
export type SelectedIndependentEntryOptions = {
|
||||
expansions: Game['expansions'];
|
||||
factions: readonly FactionId[];
|
||||
advanced?: boolean;
|
||||
skills?: boolean;
|
||||
homeworlds?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
};
|
||||
|
||||
/** Ordinary host controls and original private choices; no dealt inventory edits. */
|
||||
export function createSelectedIndependentEntry(options: SelectedIndependentEntryOptions) {
|
||||
const [host, ...others] = options.factions;
|
||||
let game = createGame('SELECTEDINDEPENDENTNEXUS', newPlayer(host, host, host), options.advanced ?? false, options.expansions);
|
||||
for (const faction of others) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: {actor: string; action: Action}[] = [];
|
||||
const act = (actor: string, action: Action) => {
|
||||
actions.push(structuredClone({actor, action}));
|
||||
game = applyAction(game, actor, action);
|
||||
};
|
||||
act(host, {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
act(host, {type:'moduleOption', module:'nexusCards', enabled:true});
|
||||
if (options.skills) act(host, {type:'moduleOption', module:'leaderSkills', enabled:true});
|
||||
if (options.homeworlds) act(host, {type:'homeworlds', enabled:true});
|
||||
if (options.tech) act(host, {type:'techTokens', enabled:true});
|
||||
if (options.strongholds) act(host, {type:'strongholdCards', enabled:true});
|
||||
for (const player of game.players) act(player.id, {type:'ready'});
|
||||
const lobby = structuredClone(game);
|
||||
act(host, options.advanced ? {type:'start', advancedPreview:true} : {type:'start'});
|
||||
const setup = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
let next: {actor:string; action:Action} | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { next = {actor:player.id, action}; break; }
|
||||
}
|
||||
assert.ok(next, `Original ${game.setupStage} setup must have an owned legal choice`);
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
return {lobby, setup, game, actions};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { NEXUS_FACTIONS, type NexusPlayer } from '../game/nexus-cards';
|
||||
import {
|
||||
IxianBetrayalError, closeIxianBetrayalSource, createIxianBetrayalSource,
|
||||
initialIxianBetrayalCursor, ixianBetrayalEligible, ixianBetrayalResponders,
|
||||
validateIxianBetrayalFrame, validateIxianBetrayalHistory,
|
||||
type IxianBetrayalAuthority, type IxianBetrayalContext,
|
||||
type IxianBetrayalPhysicalSelection,
|
||||
} from '../game/nexus-ixian-betrayal';
|
||||
|
||||
function fixture(mixed = true, advanced = true): IxianBetrayalContext {
|
||||
const players: NexusPlayer[] = mixed ? [
|
||||
{ id: 'ix', faction: 'ixians' }, { id: 'holder', faction: 'richese' },
|
||||
{ id: 'choam', faction: 'choam' }, { id: 'tl', faction: 'tleilaxu' },
|
||||
{ id: 'observer', faction: 'atreides' },
|
||||
] : [
|
||||
{ id: 'ix', faction: 'ixians' }, { id: 'holder', faction: 'atreides' },
|
||||
{ id: 'observer', faction: 'tleilaxu' },
|
||||
];
|
||||
const expansions = mixed ? ['ix', 'choam'] : ['ix'];
|
||||
return {
|
||||
status: 'playing', phase: 3, turn: 2, advanced, sequence: 0,
|
||||
expansions, ecazTreachery: true, players,
|
||||
cards: { version: 1, deck: NEXUS_FACTIONS.filter(card => card !== 'ixians' && card !== 'guild'),
|
||||
discard: [], hands: Object.fromEntries(players.map(player =>
|
||||
[player.id, player.id === 'holder' ? 'ixians' : player.id === 'observer' ? 'guild' : null])) },
|
||||
physicalCards: [...treacheryDeck(expansions), ...ecazTreacheryCards(), ...(mixed ? richeseCards() : [])],
|
||||
};
|
||||
}
|
||||
function authority(technology = false): IxianBetrayalAuthority {
|
||||
return { kind: technology ? 'technology' : 'bidding', provider: 'ix',
|
||||
nativeWindow: technology ? 'ixTechnology' : 'ixAuction',
|
||||
nativeContext: technology ? 'original-native-unused-card-exchange' : 'original-undrawn-native-pool',
|
||||
nativeRequired: ['observer'], nativePassed: ['observer'] };
|
||||
}
|
||||
function selection(ctx: IxianBetrayalContext): IxianBetrayalPhysicalSelection {
|
||||
return { expansions: ctx.expansions, ecazTreachery: ctx.ecazTreachery,
|
||||
richese: ctx.players.some(player => player.faction === 'richese') };
|
||||
}
|
||||
|
||||
for (const mixed of [false, true]) for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} selected ${mixed ? 'Ix/CHOAM with native cache' : 'Ix'} independent inventory cancels the original native Bidding attempt`, () => {
|
||||
const ctx = fixture(mixed, advanced), before = structuredClone(ctx), native = authority();
|
||||
assert.equal(ctx.physicalCards.length, mixed ? 60 : 50);
|
||||
const source = createIxianBetrayalSource(ctx, native, 'current-native-bidding-parent');
|
||||
assert.deepEqual(source.required, ['holder', 'observer']);
|
||||
assert.equal(source.eligible, 'holder');
|
||||
assert.deepEqual(ixianBetrayalResponders(ctx.cards, ctx.players, selection(ctx)), source.required);
|
||||
assert.equal(ixianBetrayalEligible(ctx.cards, ctx.players, 'holder', selection(ctx)), true);
|
||||
validateIxianBetrayalFrame(ctx, source, native, source.parent, source.required, ['observer']);
|
||||
const closed = closeIxianBetrayalSource(source, 'use', 'holder', initialIxianBetrayalCursor());
|
||||
assert.equal(closed.receipt.outcome, 'use', 'The receipt cancels this native attempt, not a different draw or exchange.');
|
||||
assert.equal(closed.receipt.source.nativeWindow, 'ixAuction');
|
||||
assert.equal(closed.cursor.sequence, 1);
|
||||
validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards, selection(ctx));
|
||||
assert.deepEqual(ctx, before, 'The pure native cancellation does not deal, discard or move physical inventory.');
|
||||
|
||||
// Historical cancellation survives ordinary later custody changes; the
|
||||
// caller still supplies the same public complete selected inventory.
|
||||
const recycled = structuredClone(ctx);
|
||||
recycled.cards.hands.holder = null;
|
||||
recycled.cards.discard.push('ixians');
|
||||
recycled.physicalCards = [...recycled.physicalCards].reverse();
|
||||
validateIxianBetrayalHistory([closed.receipt], closed.cursor, recycled.physicalCards, selection(recycled));
|
||||
assert.throws(() => validateIxianBetrayalFrame({ ...ctx, sequence: 1 }, source,
|
||||
native, source.parent, source.required, []), IxianBetrayalError);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a passed native draw and a later canceled Technology exchange retain distinct original selected-deck sources', () => {
|
||||
const ctx = fixture(), bidding = authority();
|
||||
const first = createIxianBetrayalSource(ctx, bidding, 'original-draw-parent');
|
||||
const passed = closeIxianBetrayalSource(first, 'pass', null, initialIxianBetrayalCursor());
|
||||
assert.equal(passed.receipt.outcome, 'pass');
|
||||
const technology = authority(true), next = { ...ctx, sequence: passed.cursor.sequence };
|
||||
const second = createIxianBetrayalSource(next, technology, 'original-technology-parent');
|
||||
const used = closeIxianBetrayalSource(second, 'use', 'holder', passed.cursor);
|
||||
assert.notEqual(second.event, first.event);
|
||||
assert.equal(used.receipt.source.nativeWindow, 'ixTechnology');
|
||||
validateIxianBetrayalHistory([passed.receipt, used.receipt], used.cursor, ctx.physicalCards, selection(ctx));
|
||||
assert.throws(() => createIxianBetrayalSource({ ...next, advanced: false }, technology,
|
||||
second.parent), IxianBetrayalError, 'Basic does not gain Advanced Technology.');
|
||||
assert.throws(() => validateIxianBetrayalFrame(next, second, bidding, first.parent,
|
||||
second.required, []), IxianBetrayalError, 'The canceled exchange cannot authorize a different native draw.');
|
||||
});
|
||||
|
||||
void test('selected physical inventories require all three independent faces and only the actually seated native cache', () => {
|
||||
const ctx = fixture(), before = structuredClone(ctx);
|
||||
const independentIndex = ctx.physicalCards.findIndex(card => card.id === 'ecaz-recruits');
|
||||
assert.ok(independentIndex >= 0);
|
||||
const missing = ctx.physicalCards.filter((_, index) => index !== independentIndex);
|
||||
const duplicated = [...ctx.physicalCards, ctx.physicalCards[independentIndex]];
|
||||
const malformed = [...structuredClone(ctx.physicalCards)];
|
||||
malformed[independentIndex] = { ...malformed[independentIndex], name: 'Invented Recruits' };
|
||||
const missingCache = ctx.physicalCards.filter(card => !richeseCards().some(cache => cache.id === card.id));
|
||||
for (const physicalCards of [missing, duplicated, malformed, missingCache])
|
||||
assert.throws(() => createIxianBetrayalSource({ ...ctx, physicalCards }, authority(),
|
||||
'original-parent'), IxianBetrayalError);
|
||||
const ixOnly = fixture(false);
|
||||
assert.throws(() => createIxianBetrayalSource({ ...ixOnly,
|
||||
physicalCards: [...ixOnly.physicalCards, ...richeseCards()] }, authority(), 'original-parent'), IxianBetrayalError);
|
||||
assert.throws(() => createIxianBetrayalSource({ ...ctx, ecazTreachery: false }, authority(),
|
||||
'original-parent'), IxianBetrayalError, 'A physical hand cannot silently opt into the independent variant.');
|
||||
assert.deepEqual(ctx, before);
|
||||
});
|
||||
|
||||
void test('public selected families authorize native responders without admitting unselected or E3 seats', () => {
|
||||
const ctx = fixture();
|
||||
for (const expansions of [['choam'], ['ix', 'ix'], ['ix', 'ecaz'], []])
|
||||
assert.throws(() => createIxianBetrayalSource({ ...ctx, expansions }, authority(),
|
||||
'original-parent'), IxianBetrayalError);
|
||||
assert.throws(() => ixianBetrayalResponders(ctx.cards, ctx.players), IxianBetrayalError,
|
||||
'Omitted selection keeps the original base/Ix/Tleilaxu roster contract.');
|
||||
for (const faction of ['ecaz', 'moritani'] as const) {
|
||||
const players = ctx.players.map(player => player.id === 'holder' ? { ...player, faction } : player);
|
||||
assert.throws(() => ixianBetrayalResponders(ctx.cards, players, selection(ctx)), IxianBetrayalError);
|
||||
}
|
||||
const allied = { ...ctx, players: ctx.players.map(player => player.id === 'holder'
|
||||
? { ...player, ally: 'observer' } : player.id === 'observer' ? { ...player, ally: 'holder' } : player) };
|
||||
assert.deepEqual(ixianBetrayalResponders(allied.cards, allied.players, selection(allied)), []);
|
||||
assert.equal(ixianBetrayalEligible(allied.cards, allied.players, 'holder', selection(allied)), false);
|
||||
});
|
||||
|
||||
void test('omitted selection preserves original Ix47 source and receipt grammar and rejects silent new card inventory', () => {
|
||||
const selected = fixture(false), ctx = { ...selected, physicalCards: treacheryDeck(['ix']) };
|
||||
delete ctx.expansions;
|
||||
delete ctx.ecazTreachery;
|
||||
const native = authority(), source = createIxianBetrayalSource(ctx, native, 'legacy-parent');
|
||||
const explicit = createIxianBetrayalSource({ ...ctx, expansions: ['ix'], ecazTreachery: false },
|
||||
native, 'legacy-parent');
|
||||
assert.deepEqual(explicit, source, 'Public configuration adds no unconditional receipt fields or signature changes.');
|
||||
const closed = closeIxianBetrayalSource(source, 'use', 'holder', initialIxianBetrayalCursor());
|
||||
validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards);
|
||||
assert.throws(() => validateIxianBetrayalHistory([closed.receipt], closed.cursor,
|
||||
selected.physicalCards), IxianBetrayalError);
|
||||
validateIxianBetrayalHistory([closed.receipt], closed.cursor, selected.physicalCards, selection(selected));
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import {
|
||||
activateSelectedIndependentSuboids, actSelectedIndependentNexusBattle,
|
||||
advanceSelectedIndependentNexusBattle, createSelectedIndependentNexusBattleFixture,
|
||||
revealSelectedIndependentNexusPlans, selectedIndependentNexusClean,
|
||||
selectedIndependentNexusPlayer as player,
|
||||
} from './fixture-selected-independent-nexus-battles';
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} original selected Ix Cunning pays its mixed physical fee before Harass returns eight Suboids`, () => {
|
||||
const f = createSelectedIndependentNexusBattleFixture({ advanced, program: 'mixed-return', expansions: ['ix'] });
|
||||
assert.deepEqual(f.fee, { normal: 1, elite: 2 });
|
||||
assert.equal(f.ownShipment.action.amount, 9);
|
||||
assert.equal(f.ownShipment.action.elite, 0);
|
||||
assert.equal(player(f.beforeShipment, 'ixians').spice - player(f.afterShipment, 'ixians').spice, 9);
|
||||
assert.equal(f.purchases.length, 2);
|
||||
assert.ok(f.purchases.every(purchase => purchase.step.action.type === 'bid' && purchase.step.action.amount === 1));
|
||||
if (!advanced) assert.ok(f.actions.some(step => step.actor === 'ixians' && step.action.type === 'charity'),
|
||||
'After spending the original ten spice, Basic funds the second bid through its actual eligible charity claim');
|
||||
assert.ok(player(f.purchases[1].before, 'ixians').spice >= 1);
|
||||
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands.ixians, 'ixians');
|
||||
assert.equal(player(f.afterNexusDraw, 'guild').ally, 'emperor');
|
||||
assert.equal(player(f.afterNexusDraw, 'ixians').ally, null);
|
||||
assert.ok(player(f.game, 'ixians').hand.some(card => card.id === REINFORCEMENTS_CARD));
|
||||
assert.ok(player(f.game, 'ixians').hand.some(card => card.id === HARASS_WITHDRAW_CARD));
|
||||
const before = player(f.game, 'ixians');
|
||||
let game = activateSelectedIndependentSuboids(f.game, f.actions);
|
||||
const own = viewGame(game, 'ixians').battle!.ownForces!;
|
||||
assert.deepEqual([own.normal, own.elite, own.normalFixedHalf, own.normalFreeSupport], [9, 0, false, true]);
|
||||
assert.deepEqual(viewGame(game, 'ixians').battle!.reinforcements!.normal, 1);
|
||||
assert.deepEqual(viewGame(game, 'ixians').battle!.reinforcements!.elite, 2);
|
||||
game = revealSelectedIndependentNexusPlans(game, f.plans, f.actions);
|
||||
assert.equal(game.decision, null,
|
||||
'One ordinary pool has a unique undialed return and needs no invented allocation prompt');
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.battle && selectedIndependentNexusClean(state), f.actions);
|
||||
const after = player(game, 'ixians');
|
||||
assert.equal(game.lastBattleContext!.winner, 'ixians');
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(after.forces[f.location] ?? 0, 0);
|
||||
assert.equal(after.reserves, before.reserves - 3 + 8);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves - 2,
|
||||
'Returned ordinary Suboids cannot replace the originally quoted two Cyborg fee counters');
|
||||
assert.equal(after.tanks, before.tanks + 4, 'The original fee3 and one committed physical Suboid pay once');
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 2);
|
||||
assert.equal(after.forces[MOBILE_LOCATION], before.forces[MOBILE_LOCATION]);
|
||||
assert.equal(after.elites!.forces[MOBILE_LOCATION], before.elites!.forces[MOBILE_LOCATION]);
|
||||
assert.ok(!game.pendingIxSubstitution,
|
||||
'Reserve-fee Cyborgs are not actual battlefield Cyborg casualties available for substitution');
|
||||
assert.equal(player(game, 'guild').tanks, player(f.game, 'guild').tanks + 3);
|
||||
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
|
||||
assert.equal(game.discard.filter(physical => physical.id === card).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
|
||||
assert.equal(viewGame(game, 'ixians').nexusSuboids!.active, true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const modules of [false, true]) {
|
||||
void test(`original selected ${modules ? 'union E1/E2 Worlds/all14/phase' : 'E1'} Cunning preserves actual Cyborg losses and equal residual Suboid substitution`, () => {
|
||||
const f = createSelectedIndependentNexusBattleFixture({ advanced: true, program: 'substitution',
|
||||
expansions: modules ? ['ix', 'choam'] : ['ix'], homeworlds: modules, skills: modules,
|
||||
tech: modules, strongholds: modules });
|
||||
assert.deepEqual(f.fee, { normal: 3, elite: 0 });
|
||||
assert.equal(f.ownShipment.action.amount, 6);
|
||||
assert.equal(f.ownShipment.action.elite, 3);
|
||||
if (modules) {
|
||||
assert.ok(f.plans[0].action.reinforcementsSources);
|
||||
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, 'ixians');
|
||||
}
|
||||
const original = player(f.game, 'ixians');
|
||||
let game = activateSelectedIndependentSuboids(f.game, f.actions);
|
||||
game = revealSelectedIndependentNexusPlans(game, f.plans, f.actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
(state.decision?.kind === 'battleLosses' || state.decision?.kind === 'ixSubstitution'), f.actions);
|
||||
if (game.decision?.kind === 'battleLosses') {
|
||||
const choice = game.decision.options.findIndex(loss => loss.normal === 1 && loss.elite === 1); assert.ok(choice >= 0);
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision', choice } }, f.actions);
|
||||
}
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'ixSubstitution', f.actions);
|
||||
assert.equal(player(game, 'ixians').forces[f.location], 4);
|
||||
assert.equal(player(game, 'ixians').elites!.forces[f.location], 2);
|
||||
assert.equal(player(game, 'ixians').tanks, original.tanks + 5);
|
||||
assert.equal(player(game, 'ixians').elites!.tanks, original.elites!.tanks + 1);
|
||||
assert.deepEqual(game.pendingIxSubstitution!.losses, { [f.location]: 1 });
|
||||
assert.throws(() => applyAction(game, 'ixians', { type: 'decision',
|
||||
sources: { [f.location]: 2 }, recover: { [f.location]: 2 } }));
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision',
|
||||
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }, f.actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.battle && selectedIndependentNexusClean(state), f.actions);
|
||||
const after = player(game, 'ixians');
|
||||
assert.equal(game.lastBattleContext!.winner, 'ixians');
|
||||
assert.equal(after.forces[f.location], 4);
|
||||
assert.equal(after.elites!.forces[f.location], 3);
|
||||
assert.equal(after.tanks, original.tanks + 5);
|
||||
assert.equal(after.elites!.tanks, original.elites!.tanks);
|
||||
assert.equal(after.reserves, original.reserves - 3);
|
||||
assert.equal(after.elites!.reserves, original.elites!.reserves);
|
||||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
|
||||
assert.equal(after.forces[MOBILE_LOCATION], original.forces[MOBILE_LOCATION]);
|
||||
assert.equal(after.elites!.forces[MOBILE_LOCATION], original.elites!.forces[MOBILE_LOCATION]);
|
||||
});
|
||||
}
|
||||
|
||||
void test('original selected independent Cunning completes actual reserve fees and residual substitution before a genuinely dealt Face Dance', () => {
|
||||
const f = createSelectedIndependentNexusBattleFixture({ advanced: true, program: 'faceDance', expansions: ['ix', 'choam'] });
|
||||
assert.ok(player(f.afterSetup, 'tleilaxu').faceDancers!.some(dancer => dancer.leader === f.leader && !dancer.revealed));
|
||||
let game = revealSelectedIndependentNexusPlans(activateSelectedIndependentSuboids(f.game, f.actions), f.plans, f.actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
(state.decision?.kind === 'battleLosses' || state.decision?.kind === 'ixSubstitution'), f.actions);
|
||||
if (game.decision?.kind === 'battleLosses') {
|
||||
const choice = game.decision.options.findIndex(loss => loss.normal === 1 && loss.elite === 1); assert.ok(choice >= 0);
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision', choice } }, f.actions);
|
||||
}
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'ixSubstitution', f.actions);
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision',
|
||||
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }, f.actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'faceDance', f.actions);
|
||||
assert.equal(game.decision!.kind, 'faceDance');
|
||||
const before = player(game, 'ixians'), native = player(game, 'tleilaxu');
|
||||
assert.equal(before.forces[f.location], 4);
|
||||
assert.equal(before.elites!.forces[f.location], 3);
|
||||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'tleilaxu', action: {
|
||||
type: 'decision', reveal: true, sources: { reserves: 4 }, sector: f.sector,
|
||||
} }, f.actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.battle && selectedIndependentNexusClean(state), f.actions);
|
||||
const after = player(game, 'ixians');
|
||||
assert.equal(after.forces[f.location] ?? 0, 0);
|
||||
assert.equal(after.elites!.forces[f.location] ?? 0, 0);
|
||||
assert.equal(after.reserves, before.reserves + 4);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + 3);
|
||||
assert.equal(after.tanks, before.tanks);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks);
|
||||
assert.equal(after.leaders.find(leader => leader.id === f.leader)!.dead, true);
|
||||
assert.equal(player(game, 'tleilaxu').reserves, native.reserves - 4);
|
||||
assert.equal(player(game, 'tleilaxu').forces[f.location], 4);
|
||||
assert.equal(player(game, 'tleilaxu').faceDancers!.find(dancer => dancer.leader === f.leader)!.revealed, true);
|
||||
});
|
||||
|
||||
void test('all four minimal policies retain a positive native Reinforcements path after genuine selected-family Suboid Cunning', () => {
|
||||
const f = createSelectedIndependentNexusBattleFixture({ advanced: true, program: 'substitution', expansions: ['ix', 'choam'], homeworlds: true });
|
||||
const game = activateSelectedIndependentSuboids(f.game, f.actions);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(game, 'ixians');
|
||||
view.players.find(player => player.id === 'ixians')!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'battlePlan' && Number(action.dial) > 0 &&
|
||||
(action.weapon === REINFORCEMENTS_CARD || action.defense === REINFORCEMENTS_CARD));
|
||||
assert.ok(action, `${difficulty} must expose a positive original card plan`);
|
||||
assert.ok(action.reinforcementsSources, `${difficulty} must retain the original native source quote`);
|
||||
assert.ok(applyAction(game, 'ixians', action).battle!.plans.ixians);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions } from './fixture-independent-card-modules';
|
||||
import {
|
||||
actSelectedIndependentNexus, advanceSelectedIndependentNexus, createSelectedIndependentNexusBattleFixture,
|
||||
createSelectedIndependentNexusCacheFixture, createSelectedIndependentNexusCardsFixture,
|
||||
revealSelectedIndependentNexusPlans, selectedIndependentChoamFuelAction,
|
||||
selectedIndependentNexusClean as clean, selectedIndependentNexusPlayer as player,
|
||||
} from './fixture-selected-independent-nexus-cards';
|
||||
|
||||
/** Component quantities only; no historical-save or seat assurance programme. */
|
||||
function selectedStocks(game: Game): void {
|
||||
const actual = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.ixSetupCards ?? []), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])];
|
||||
const expected = [...treacheryDeck(game.expansions), ...ecazTreacheryCards(),
|
||||
...(game.players.some(player => player.faction === 'richese') ? richeseCards() : [])];
|
||||
assert.deepEqual(actual.map(card => card.id).sort(), expected.map(card => card.id).sort());
|
||||
const cards = game.nexusCards!.cards!;
|
||||
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
if (game.leaderSkills) assert.deepEqual([...game.leaderSkills.deck,
|
||||
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards),
|
||||
...game.leaderSkills.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
}
|
||||
|
||||
const configurations: Array<IndependentCardModulesOptions & { factions: NonNullable<IndependentCardModulesOptions['factions']> }> = [
|
||||
{ expansions: ['ix'], factions: ['atreides', 'guild'], skills: true, tech: false },
|
||||
{ expansions: ['choam'], factions: ['harkonnen', 'emperor', 'guild'], homeworlds: true, tech: true },
|
||||
{ expansions: ['ix', 'choam'], factions: ['richese', 'choam', 'guild', 'atreides'], advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
|
||||
{ expansions: ['ix'], factions: ['ixians', 'tleilaxu', 'atreides', 'emperor', 'guild'], advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
|
||||
{ expansions: ['ix', 'choam'], factions: ['ixians', 'tleilaxu', 'richese', 'choam', 'harkonnen', 'guild'], advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
|
||||
];
|
||||
for (const configuration of configurations) void test(`fresh ${configuration.expansions!.join('+')} independent/Nexus ${configuration.factions.length}-seat native setup keeps selected components and original reaction stamps`, () => {
|
||||
const fixture = createIndependentCardModulesFixture({ ...configuration, independent: true, nexus: true });
|
||||
const game = fixture.game;
|
||||
assert.deepEqual(game.expansions, configuration.expansions);
|
||||
assert.equal(game.ecazTreachery, true);
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.nexusCards!.cards!.deck.length, 12);
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||||
assert.equal(game.nexusCards!.phase, null);
|
||||
assert.equal(!!game.nexusIxianReplacementPreview, !configuration.factions.includes('ixians'));
|
||||
assert.equal(!!game.nexusIxianBetrayalPreview, configuration.factions.includes('ixians'));
|
||||
assert.equal(!!game.nexusHarkonnenBetrayalPreview, configuration.factions.includes('harkonnen'));
|
||||
assert.equal(!!game.guildBetrayalPreview, configuration.factions.includes('guild'));
|
||||
if (configuration.homeworlds) assert.ok(game.homeworlds!.custody);
|
||||
if (configuration.skills) assert.equal(game.leaderSkills!.assignments.length, configuration.factions.length);
|
||||
if (configuration.strongholds) assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
|
||||
selectedStocks(game);
|
||||
});
|
||||
|
||||
void test('native CHOAM Cunning consumes a genuinely auctioned independent Recruits as Kulon fuel without playing Recruits', () => {
|
||||
const fixture = createSelectedIndependentNexusCardsFixture({ advanced: true, skills: true, tech: true, strongholds: true });
|
||||
assert.ok(player(fixture.afterPurchases, 'choam').hand.some(card => card.id === RECRUITS_CARD_ID));
|
||||
assert.ok(fixture.actions.some(step => step.actor === 'choam' && step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.equal(fixture.draws.find(draw => draw.step.actor === 'choam')!.after.nexusCards!.cards!.hands.choam, 'choam');
|
||||
let game = advanceSelectedIndependentNexus(fixture.game, state => state.phase === 5 && state.active === 'choam' && clean(state));
|
||||
assert.equal(game.recruits ?? null, null);
|
||||
const action = selectedIndependentChoamFuelAction(game), spice = player(game, 'choam').spice;
|
||||
game = actSelectedIndependentNexus(game, 'choam', action);
|
||||
assert.equal(game.nexusCards!.cards!.hands.choam, null);
|
||||
if (game.response) {
|
||||
assert.equal(game.response.kind, 'choamWorthless');
|
||||
assert.ok(player(game, 'choam').hand.some(card => card.id === RECRUITS_CARD_ID));
|
||||
} else assert.equal(game.nexusChoamLast!.stage, 'complete',
|
||||
'Without an eligible native counter, automatic completion needs no fabricated response');
|
||||
game = advanceSelectedIndependentNexus(game, clean);
|
||||
assert.equal(game.nexusChoamLast!.stage, 'complete');
|
||||
assert.equal(game.choamMovement!.bonus, 1);
|
||||
assert.equal(game.recruits ?? null, null, 'CHOAM conversion never runs the independent card’s printed Revival effect');
|
||||
assert.equal(game.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'choam').length, 1);
|
||||
assert.equal(player(game, 'choam').spice, spice);
|
||||
assert.throws(() => applyAction(game, 'choam', action));
|
||||
selectedStocks(game);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} native Richese two-marker Cunning materializes before independent Reinforcements pays its physical fee`, () => {
|
||||
const fixture = createSelectedIndependentNexusBattleFixture({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
|
||||
assert.equal(player(fixture.afterFirstReveal, 'richese').reserves, 20);
|
||||
assert.equal(player(fixture.afterPair, 'richese').reserves, 15);
|
||||
assert.equal(player(fixture.afterPair, 'richese').forces[fixture.location], 5);
|
||||
assert.ok(player(fixture.afterPair, 'richese').noField!.deployed);
|
||||
assert.equal(fixture.afterPair.nexusCards!.cards!.hands.richese, null);
|
||||
assert.equal(fixture.afterPair.nexusRicheseCunningLast!.stage, 'shipped');
|
||||
const shipment = fixture.afterPair.nexusGuildBetrayalHistory!.at(-1)!;
|
||||
assert.equal(shipment.invoice.source, 'reserve');
|
||||
assert.equal(shipment.invoice.shipper, 'richese');
|
||||
assert.equal(shipment.invoice.turn, 2);
|
||||
assert.equal(shipment.invoice.price, 1);
|
||||
assert.equal(shipment.invoice.originalReceiver, 'guild');
|
||||
assert.equal(shipment.recipient, null);
|
||||
assert.ok(fixture.actions.some(step => step.actor === 'choam' && step.action.type === 'guildBetrayalPass' && step.action.event === shipment.invoice.event),
|
||||
'Cunning settles its genuine signed Guild fee acknowledgement before force custody changes');
|
||||
const preview = viewGame(fixture.game, 'richese').battle!.reinforcements!;
|
||||
assert.equal(preview.blocked, null);
|
||||
assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 12 });
|
||||
assert.equal(preview.normal, 3);
|
||||
let game = revealSelectedIndependentNexusPlans(fixture.game, fixture.plans);
|
||||
assert.equal(player(game, 'richese').noField!.deployed, null);
|
||||
assert.equal(player(game, 'richese').forces[fixture.location], 8);
|
||||
assert.equal(player(game, 'richese').reserves, 12);
|
||||
const tanks = player(game, 'richese').tanks;
|
||||
game = advanceSelectedIndependentNexus(game, state => !state.battle && clean(state));
|
||||
assert.equal(game.lastBattleContext!.winner, 'richese');
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(player(game, 'richese').reserves, 9, 'Three genuine uncommitted reserves pay the card after its own concealed army appears');
|
||||
assert.equal(player(game, 'richese').tanks, tanks + 5, 'Three reserve-fee counters plus two actual dial casualties');
|
||||
assert.equal(player(game, 'richese').forces[fixture.location], 6);
|
||||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||||
selectedStocks(game);
|
||||
});
|
||||
|
||||
void test('an actual paid ten-force shipment leaves too few reserves for Reinforcements after the retained Cunning pair, while the plain plan remains legal', () => {
|
||||
const fixture = createSelectedIndependentNexusBattleFixture({ drainReserves: true });
|
||||
assert.equal(player(fixture.afterFirstReveal, 'richese').reserves, 20);
|
||||
assert.deepEqual(player(fixture.afterFirstReveal, 'richese').noField!.tokens.map(token => token.value).sort((a, b) => a - b), [0, 3, 5]);
|
||||
const funding = fixture.reserveFunding, shipment = fixture.reserveShipment;
|
||||
assert.ok(funding && shipment);
|
||||
assert.equal(funding.step.action.type, 'bribe');
|
||||
assert.equal(player(funding.after, 'choam').spice, player(funding.before, 'choam').spice - 9);
|
||||
assert.equal(player(funding.after, 'richese').bribes, player(funding.before, 'richese').bribes + 9);
|
||||
assert.equal(shipment.step.action.type, 'ship');
|
||||
assert.equal(player(shipment.after, 'richese').spice, player(shipment.before, 'richese').spice - 10);
|
||||
assert.equal(player(shipment.after, 'richese').reserves, 10);
|
||||
assert.equal(player(shipment.after, 'richese').forces['habbanya_ridge_sietch:17'], 10);
|
||||
assert.equal(shipment.after.nexusCards!.cards!.hands.richese, 'richese');
|
||||
assert.equal(fixture.beforePair.turn, 3);
|
||||
assert.equal(player(fixture.afterPair, 'richese').reserves, 5);
|
||||
const preview = viewGame(fixture.game, 'richese').battle!.reinforcements!;
|
||||
assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 2 });
|
||||
assert.match(preview.blocked!, /three.*reserves.*after materialization/i);
|
||||
assert.throws(() => applyAction(fixture.game, 'richese', fixture.plans[0].action), /at least three own forces in reserves/i);
|
||||
assert.ok(player(fixture.game, 'richese').hand.some(card => card.id === REINFORCEMENTS_CARD));
|
||||
assert.deepEqual(fixture.game.battle!.plans, {});
|
||||
const plans = fixture.plans.map(plan => plan.actor === 'richese' ? { ...plan, action: { ...plan.action, defense: null } } : plan);
|
||||
let game = revealSelectedIndependentNexusPlans(fixture.game, plans);
|
||||
assert.equal(player(game, 'richese').reserves, 2);
|
||||
assert.equal(player(game, 'richese').forces[fixture.location], 8);
|
||||
game = advanceSelectedIndependentNexus(game, state => !state.battle && clean(state));
|
||||
assert.equal(game.lastBattleContext!.winner, 'richese');
|
||||
assert.ok(player(game, 'richese').hand.some(card => card.id === REINFORCEMENTS_CARD));
|
||||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 0);
|
||||
selectedStocks(game);
|
||||
});
|
||||
|
||||
void test('actual Richese cache purchase opens absent-Ix replacement and draws an original independent Harass without charging twice', () => {
|
||||
const fixture = createSelectedIndependentNexusCacheFixture({ advanced: true, skills: true, tech: true, strongholds: true });
|
||||
assert.equal(fixture.game.nexusCards!.cards!.hands.choam, 'ixians');
|
||||
assert.equal(fixture.game.players.some(player => player.faction === 'ixians'), false);
|
||||
assert.equal(player(fixture.game, 'choam').spice, player(fixture.beforePurchase, 'choam').spice - fixture.price);
|
||||
assert.ok(player(fixture.game, 'choam').hand.some(card => card.id === fixture.card.id));
|
||||
const paid = player(fixture.game, 'choam').spice, seller = player(fixture.game, 'richese').spice;
|
||||
let game = actSelectedIndependentNexus(fixture.game, 'choam', fixture.useAction);
|
||||
game = advanceSelectedIndependentNexus(game, state => !state.pendingNexusIxianReplacement && !state.currentAuctionSale && clean(state));
|
||||
assert.equal(player(game, 'choam').spice, paid);
|
||||
assert.equal(player(game, 'richese').spice, seller + fixture.price);
|
||||
assert.ok(player(game, 'choam').hand.some(card => card.id === HARASS_WITHDRAW_CARD));
|
||||
assert.equal(fixture.replacement.id, HARASS_WITHDRAW_CARD);
|
||||
assert.equal(player(game, 'choam').hand.some(card => card.id === fixture.card.id), false);
|
||||
assert.equal(game.discard.filter(card => card.id === fixture.card.id).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.hands.choam, null);
|
||||
assert.equal(game.richeseCache!.some(card => card.id === fixture.card.id), false);
|
||||
selectedStocks(game);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { selectedIndependentNexusProfile, nexusCoreProfile } from '../game/faction-module-profile';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createSelectedIndependentEntry, type SelectedIndependentEntryOptions } from './fixture-selected-independent-nexus-entry';
|
||||
|
||||
const entries: SelectedIndependentEntryOptions[] = [
|
||||
{expansions:['ix'], factions:['ixians','tleilaxu','atreides']},
|
||||
{expansions:['choam'], factions:['richese','choam','guild'], advanced:true, skills:true, homeworlds:true, tech:true, strongholds:true},
|
||||
{expansions:['ix','choam'], factions:['ixians','richese','harkonnen','atreides'], advanced:true, skills:true, homeworlds:true, tech:true, strongholds:true},
|
||||
{expansions:['ix','choam'], factions:['atreides','guild']},
|
||||
{expansions:['choam'], factions:['emperor','atreides'], advanced:true, homeworlds:true, strongholds:true},
|
||||
{expansions:['ix'], factions:['tleilaxu','guild'], homeworlds:true},
|
||||
];
|
||||
for (const options of entries) void test(`normal independent Nexus entry ${options.expansions.join('+')}/${options.factions.join('+')} retains selected components`, () => {
|
||||
const {lobby, setup, game} = createSelectedIndependentEntry(options);
|
||||
assert.equal(lobby.lobbyOptions?.ecazTreachery, true);
|
||||
assert.equal(lobby.nexusCards?.cards, null);
|
||||
assert.equal(game.ecazTreachery, true);
|
||||
assert.deepEqual(game.expansions, options.expansions);
|
||||
assert.equal(nexusCoreProfile(game), true);
|
||||
assert.equal(selectedIndependentNexusProfile(game), true);
|
||||
assert.equal(!!game.leaderSkills, !!options.skills);
|
||||
assert.equal(!!game.homeworlds, !!options.homeworlds);
|
||||
assert.equal(!!game.techTokens, !!options.tech);
|
||||
assert.equal(!!game.strongholdCards, !!options.strongholds);
|
||||
assert.equal(game.phase, 0);
|
||||
assert.equal(game.nexusCards!.cards!.deck.length, 12);
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||||
const ordinary = game.deck.length + game.discard.length + game.players.reduce((sum, player) => sum + player.hand.length, 0);
|
||||
assert.equal(ordinary, options.expansions.includes('ix') ? 50 : 38);
|
||||
assert.equal(game.richeseCache?.length ?? 0, options.factions.includes('richese') ? 10 : 0);
|
||||
assert.equal(!!game.nexusIxianReplacementPreview, !options.factions.includes('ixians'));
|
||||
assert.equal(!!game.nexusIxianBetrayalPreview, options.factions.includes('ixians'));
|
||||
assert.equal(!!game.nexusHarkonnenBetrayalPreview, options.factions.includes('harkonnen'));
|
||||
assert.equal(!!game.guildBetrayalPreview, options.factions.includes('guild'));
|
||||
if (options.skills) assert.ok(['skillTreachery','leaderSkills'].includes(setup.setupStage!),
|
||||
'The original Ixian starting-card choice may precede skill training');
|
||||
if (setup.techTokens) {
|
||||
assert.ok(Object.values(lobby.techTokens!).every(token => token.owner === null));
|
||||
assert.deepEqual(setup.techTokens, createTechTokens(setup.players),
|
||||
'Original setup assigns each seated native its printed Tech token; missing natives wait for Storm');
|
||||
}
|
||||
if (game.strongholdCards) assert.ok(Object.values(game.strongholdCards.owners).every(owner => owner === null));
|
||||
});
|
||||
|
||||
void test('selected core capability requires real independent cards and selected registered seats', () => {
|
||||
const base = {advanced:true, expansions:['ix'], players:[{faction:'ixians'},{faction:'atreides'}], nexusCards:true, ecazTreachery:true};
|
||||
assert.equal(selectedIndependentNexusProfile(base), true);
|
||||
for (const invalid of [
|
||||
{...base, ecazTreachery:false}, {...base, expansions:['ix','ix']},
|
||||
{...base, players:[{faction:'ixians'},{faction:'richese'}]},
|
||||
{...base, players:[{faction:'ixians'},{faction:'ixians'}]},
|
||||
{...base, techTokens:true}, {...base, advanced:false, strongholdCards:true},
|
||||
{...base, discoveryEnabled:true}, {...base, semutaPreview:true},
|
||||
]) assert.equal(selectedIndependentNexusProfile(invalid), false);
|
||||
});
|
||||
Reference in new issue
Block a user