Compose independent cards with Discovery and optional Nexus
Connect original selected-deck Discovery setup, optional Nexus/World/Skills/phase modules and physical card consumers through shared public capabilities. Retain Cunning revealed-location arrival, Sandtrout suppression, paid nested R/Harass and actual later Recruits revival. Preserve native trained-capture and unrelated-preview boundaries. Validation: frozen 15-file118/118, types/lint/build and six finite original-action/rendered-control programmes; four minimal legal policies. External smoke corrections changed no application source. Existing persistent notices also rechecked23/23. Full combinations, assurance and deployed acceptance remain open; no saved-game, restart or deployment operation.
This commit is contained in:
1 parent
b4157aaa9b
commit
677ad7aa3c
21 files changed
+1000
-37
No files matched your search
@@ -83,6 +83,15 @@ function command(g: Game): Action {
|
||||
};
|
||||
}
|
||||
const acquire = (g: Game) => applyAction(g, 'ec', command(g));
|
||||
void test('independent Discovery without Nexus admits the untrained Advanced Ecaz/Harkonnen acquisition quote', () => {
|
||||
const g = fixture('harkonnen', true);
|
||||
g.ecazTreachery = true;
|
||||
g.discoveryEnabled = true;
|
||||
assert.equal(g.nexusCards, undefined);
|
||||
assert.equal(quoteEcazDukeAcquisition(g, 'ec').controller, 'ec');
|
||||
const excluded = { ...g, leaderSkills: {} };
|
||||
assert.throws(() => quoteEcazDukeAcquisition(excluded as Game, 'ec'), EcazDukeAcquisitionError);
|
||||
});
|
||||
function reject(g: Game, id: string, action: Action) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, id, action), RuleError);
|
||||
|
||||
@@ -40,6 +40,16 @@ function fixture(advanced = false) {
|
||||
g.dukeVidal!.leader.deaths = 1;
|
||||
return g;
|
||||
}
|
||||
void test('independent Discovery without Nexus admits the untrained Advanced Ecaz/Harkonnen revival quote', () => {
|
||||
const g = fixture(true);
|
||||
g.players[1] = newPlayer('h', 'Harkonnen', 'harkonnen');
|
||||
g.ecazTreachery = true;
|
||||
g.discoveryEnabled = true;
|
||||
assert.equal(g.nexusCards, undefined);
|
||||
assert.equal(quoteEcazDukeRevival(g, 'ec', false).cost, 5);
|
||||
const excluded = { ...g, leaderSkills: {} };
|
||||
assert.throws(() => quoteEcazDukeRevival(excluded as Game, 'ec', false), EcazDukeRevivalError);
|
||||
});
|
||||
void test('canonical dead shared Duke resolves without manufacturing or reviving it and always has printed normal price five', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const g = fixture(advanced);
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { JACURUTU_SIETCH, DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { RECRUITS_CARD_ID, recruitsPlayAction } from '../game/recruits';
|
||||
import { createSelectedIndependentEntry, type SelectedIndependentEntryOptions } from './fixture-selected-independent-nexus-entry';
|
||||
import { e3IndependentDukeClean, nextE3IndependentDukeStep } from './fixture-e3-independent-nexus-duke';
|
||||
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type IndependentDiscoveryBattleStep = { actor: string; action: Action };
|
||||
export type IndependentDiscoveryBattleOptions = Omit<SelectedIndependentEntryOptions, 'factions' | 'discoveries'>;
|
||||
export const independentDiscoveryBattleClean = e3IndependentDukeClean;
|
||||
export const independentDiscoveryBattlePlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export function actIndependentDiscoveryBattle(game: Game, actor: string, action: Action,
|
||||
actions: IndependentDiscoveryBattleStep[] = []): Game {
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
}
|
||||
export function advanceIndependentDiscoveryBattle(game: Game, until: (state: Game) => boolean,
|
||||
actions: IndependentDiscoveryBattleStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original nested battle programme ended before its next owned boundary');
|
||||
assert.notEqual(game.decision?.kind, 'harassWithdraw', 'Supply the actual typed Harass allocation explicitly');
|
||||
const step = nextE3IndependentDukeStep(game);
|
||||
assert.ok(step, `Complete the real ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
|
||||
game = actIndependentDiscoveryBattle(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original independent Discovery battle did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Only original unopened Spice Cards are ordered. No discarded encounter is
|
||||
* retagged as a new blow, and no already-placed Discovery token is exchanged. */
|
||||
function orderSpice(game: Game, discovery: boolean) {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
for (let pile = 0; pile < (game.advanced ? 2 : 1); pile++) {
|
||||
const at = game.spiceDeck.findIndex(card => 'territory' in card &&
|
||||
(discovery && pile === 0 ? card.discovery === 'discovery-hagga-basin' : !card.discovery));
|
||||
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
}
|
||||
|
||||
/** Three genuinely paid original lots, all selected while still unopened.
|
||||
* The ordinary native auction handles payment, card ownership and hand limit. */
|
||||
function purchaseOriginalCards(game: Game, actions: IndependentDiscoveryBattleStep[]) {
|
||||
assert.equal(game.phase, 2);
|
||||
const desired = [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, RECRUITS_CARD_ID];
|
||||
const front = desired.map(id => {
|
||||
const at = game.deck.findIndex(card => card.id === id); assert.ok(at >= 0, `Original ${id} must still be undealt`);
|
||||
return game.deck.splice(at, 1)[0];
|
||||
});
|
||||
game.deck.unshift(...front);
|
||||
const before = structuredClone(game), bids: { before: Game; step: IndependentDiscoveryBattleStep; after: Game }[] = [];
|
||||
for (let count = 0; count < 420; count++) {
|
||||
if (desired.every(id => independentDiscoveryBattlePlayer(game, 'emperor').hand.some(card => card.id === id))) {
|
||||
assert.equal(bids.length, 3); return { game, before, bids };
|
||||
}
|
||||
if (independentDiscoveryBattleClean(game) && game.auction?.active === 'emperor' &&
|
||||
desired.includes(game.auction.cards[game.auction.index].id as typeof desired[number]) && game.auction.bid === 0) {
|
||||
const prior = structuredClone(game), step: IndependentDiscoveryBattleStep = {
|
||||
actor: 'emperor', action: { type: 'bid', amount: 1, allyPayment: 0 },
|
||||
};
|
||||
game = actIndependentDiscoveryBattle(game, step.actor, step.action, actions);
|
||||
bids.push({ before: prior, step, after: structuredClone(game) });
|
||||
} else {
|
||||
const step = nextE3IndependentDukeStep(game); assert.ok(step);
|
||||
game = actIndependentDiscoveryBattle(game, step.actor, step.action, actions);
|
||||
}
|
||||
}
|
||||
throw Error('The actual first auction did not deliver its three paid original card lots');
|
||||
}
|
||||
|
||||
/** Original placement, paid finder, actual inspection/reveal, next-turn free
|
||||
* nested entry and paid enemy shipment. No dealt card, counter, wallet, phase,
|
||||
* native reserve pool, leader identity or earned receipt is staged. */
|
||||
export function createIndependentDiscoveryBattleFixture(options: Partial<IndependentDiscoveryBattleOptions> = {}) {
|
||||
const source = withClassicDiscoveryNexusLottery(() => createSelectedIndependentEntry({
|
||||
...options, advanced: options.advanced ?? true, expansions: options.expansions ?? ['ix', 'choam', 'ecaz'],
|
||||
factions: ['emperor', 'guild', 'atreides'], discoveries: true,
|
||||
}));
|
||||
let game = structuredClone(source.game);
|
||||
const afterSetup = structuredClone(game), actions = [...source.actions];
|
||||
const act = (actor: string, action: Action) => { game = actIndependentDiscoveryBattle(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceIndependentDiscoveryBattle(game, until, actions); };
|
||||
orderSpice(game, true);
|
||||
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === JACURUTU_SIETCH && token.status === 'placed') && count < 100; count++) {
|
||||
const step = nextE3IndependentDukeStep(game); assert.ok(step);
|
||||
actions.push(structuredClone(step));
|
||||
// The token's own scalar producer surrounds applyAction directly; the
|
||||
// generic shuffle wrapper must not replace this original supply lottery.
|
||||
game = withClassicDiscoveryNexusToken(game, JACURUTU_SIETCH, () => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
const token = game.discoveries!.tokens.find(token => token.face === JACURUTU_SIETCH && token.status === 'placed')?.id;
|
||||
assert.ok(token);
|
||||
const placed = structuredClone(game), parent = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
reach(state => state.phase === 2 && independentDiscoveryBattleClean(state));
|
||||
const purchased = purchaseOriginalCards(game, actions); game = purchased.game;
|
||||
reach(state => state.phase === 5 && state.active === 'emperor' && independentDiscoveryBattleClean(state));
|
||||
const shipments: { before: Game; step: IndependentDiscoveryBattleStep; after: Game }[] = [];
|
||||
const ship = (actor: string, territory: string, sector: number, amount: number, elite: number) => {
|
||||
const homeworldSources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
|
||||
assert.ok(!game.homeworlds || homeworldSources);
|
||||
const before = structuredClone(game), step: IndependentDiscoveryBattleStep = { actor, action: {
|
||||
type: 'ship', territory, sector, amount, elite, allyPayment: 0,
|
||||
...(homeworldSources ? { homeworldSources } : {}),
|
||||
} };
|
||||
act(actor, step.action); reach(independentDiscoveryBattleClean);
|
||||
shipments.push({ before, step, after: structuredClone(game) });
|
||||
};
|
||||
ship('emperor', parent.territory, parent.sector, 3, game.advanced ? 2 : 0);
|
||||
reach(state => state.phase === 7 && independentDiscoveryBattleClean(state));
|
||||
const collection = structuredClone(game), finder = viewGame(game, 'emperor').discoveries!;
|
||||
assert.ok(finder.canInspect.includes(token), 'The actual parent-board finder must inspect the still-hidden original token');
|
||||
const inspection: IndependentDiscoveryBattleStep = { actor: 'emperor', action: { type: 'discovery', token, reveal: false } };
|
||||
act(inspection.actor, inspection.action);
|
||||
const inspected = structuredClone(game);
|
||||
assert.ok(viewGame(game, 'emperor').discoveries!.canReveal.includes(token));
|
||||
const reveal: IndependentDiscoveryBattleStep = { actor: 'emperor', action: { type: 'discovery', token, reveal: true } };
|
||||
act(reveal.actor, reveal.action);
|
||||
const revealed = structuredClone(game);
|
||||
reach(state => state.turn === 2 && state.decision?.kind === 'discoveryEntry' &&
|
||||
state.decision.player === 'emperor' && !state.response && !state.phaseOpening);
|
||||
const beforeEntry = structuredClone(game), entryView = viewGame(game, 'emperor');
|
||||
const entryAction = discoveryEntryMoveAction(entryView, entryView.discoveryEntry!.sources); assert.ok(entryAction);
|
||||
const entry: IndependentDiscoveryBattleStep = { actor: 'emperor', action: entryAction };
|
||||
act(entry.actor, entry.action); reach(independentDiscoveryBattleClean);
|
||||
const afterEntry = structuredClone(game);
|
||||
orderSpice(game, false);
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === 'guild' && independentDiscoveryBattleClean(state));
|
||||
ship('guild', JACURUTU_SIETCH, 0, 2, 0);
|
||||
reach(state => state.phase === 6 && !!state.active && independentDiscoveryBattleClean(state));
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
|
||||
.find(choice => choice.territory === JACURUTU_SIETCH &&
|
||||
[choice.attacker, choice.defender].includes('emperor') && [choice.attacker, choice.defender].includes('guild'));
|
||||
assert.ok(choice);
|
||||
const battle: IndependentDiscoveryBattleStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory: JACURUTU_SIETCH, target: choice.chooser === 'emperor' ? 'guild' : 'emperor',
|
||||
} };
|
||||
act(battle.actor, battle.action);
|
||||
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && independentDiscoveryBattleClean(state));
|
||||
const leaders = (actor: string) => independentDiscoveryBattlePlayer(game, actor).leaders.filter(leader =>
|
||||
!leader.dead && !leader.usedAt && !game.leaderSkills?.assignments.some(assignment => assignment.leader === leader.id));
|
||||
const leader = leaders('emperor').sort((a, b) => b.strength - a.strength)[0], enemy = leaders('guild').sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(leader && enemy);
|
||||
const fee = viewGame(game, 'emperor').battle!.reinforcements!;
|
||||
assert.ok(fee && !fee.blocked);
|
||||
const sources = fee.homeworldSources?.[0]; assert.ok(!game.homeworlds || sources);
|
||||
const plans: IndependentDiscoveryBattleStep[] = [
|
||||
{ actor: 'emperor', action: { type: 'battlePlan', leader: leader.id, dial: 1, support: game.advanced ? 1 : 0,
|
||||
weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD,
|
||||
...(sources ? { reinforcementsSources: sources } : {}) } },
|
||||
{ actor: 'guild', action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
|
||||
];
|
||||
const nested = `${JACURUTU_SIETCH}:0`, returns = { [nested]: { normal: game.advanced ? 0 : 2, elite: game.advanced ? 2 : 0 } };
|
||||
return { ...source, game, afterSetup, placed, token, parent, purchased, shipments, collection,
|
||||
inspection, inspected, reveal, revealed, beforeEntry, entry, afterEntry, beforeBattle, battle,
|
||||
plans, returns, fee: { normal: fee.normal, elite: fee.elite }, sources, nested, actions,
|
||||
owner: 'emperor', opponent: 'guild', territory: JACURUTU_SIETCH,
|
||||
staging: ['Only unopened original card lots, Discovery supply/scalar lotteries and Spice positions are selected. Native host, setup, paid shipments, reveal, free entry and battle remain real actions. Nexus is selected but no held Nexus card or effect is asserted.'] };
|
||||
}
|
||||
export type IndependentDiscoveryBattleFixture = ReturnType<typeof createIndependentDiscoveryBattleFixture>;
|
||||
|
||||
/** Seal the real original plans and finish typed Harass, physical fee and
|
||||
* casualty settlement. An explicit allocation uses the submitted owner pool. */
|
||||
export function completeIndependentDiscoveryBattle(fixture: IndependentDiscoveryBattleFixture) {
|
||||
const actions: IndependentDiscoveryBattleStep[] = [];
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.plans) {
|
||||
game = actIndependentDiscoveryBattle(game, step.actor, step.action, actions);
|
||||
game = advanceIndependentDiscoveryBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
(!state.decision || state.decision.kind === 'harassWithdraw') && !state.pendingGuildBetrayal &&
|
||||
!state.pendingNexusIxianBetrayal && !state.pendingNexusHarkonnenBetrayal, actions);
|
||||
}
|
||||
assert.ok(game.battle?.revealed);
|
||||
const revealed = structuredClone(game);
|
||||
if (game.decision?.kind === 'harassWithdraw') game = actIndependentDiscoveryBattle(game, game.decision.player,
|
||||
{ type: 'decision', event: game.decision.event, returns: fixture.returns }, actions);
|
||||
game = advanceIndependentDiscoveryBattle(game, state => !state.battle && independentDiscoveryBattleClean(state), actions);
|
||||
return { game, revealed, actions };
|
||||
}
|
||||
|
||||
/** The paid Recruits is used only after the real battle supplied physical
|
||||
* casualties. Continue the original next-turn clock before its free revival. */
|
||||
export function reviveIndependentDiscoveryBattle(fixture: IndependentDiscoveryBattleFixture, afterBattle: Game) {
|
||||
const actions: IndependentDiscoveryBattleStep[] = [];
|
||||
let game = structuredClone(afterBattle);
|
||||
// Select only the still-unopened next-turn land replacements before the
|
||||
// current turn closes; no played blow, discard or encounter is replaced.
|
||||
orderSpice(game, false);
|
||||
game = advanceIndependentDiscoveryBattle(game, state => state.turn === fixture.game.turn + 1 &&
|
||||
state.phase === 4 && independentDiscoveryBattleClean(state), actions);
|
||||
const before = structuredClone(game), play = recruitsPlayAction(viewGame(game, fixture.owner).recruitsPreview);
|
||||
assert.ok(play, 'The genuinely purchased Recruits needs its original Revival control');
|
||||
game = actIndependentDiscoveryBattle(game, fixture.owner, play, actions);
|
||||
game = advanceIndependentDiscoveryBattle(game, independentDiscoveryBattleClean, actions);
|
||||
const activated = structuredClone(game), revival: Action = { type: 'revive', amount: 2, elite: 0 };
|
||||
game = actIndependentDiscoveryBattle(game, fixture.owner, revival, actions);
|
||||
game = advanceIndependentDiscoveryBattle(game, independentDiscoveryBattleClean, actions);
|
||||
const revived = structuredClone(game);
|
||||
game = advanceIndependentDiscoveryBattle(game, state => state.phase === 5 && independentDiscoveryBattleClean(state), actions);
|
||||
return { game, before, activated, revived, play, revival, actions };
|
||||
}
|
||||
|
||||
/** Minimal legal positive native card plans from all four real owner views. */
|
||||
export function smokeIndependentDiscoveryBattle(options: Partial<IndependentDiscoveryBattleOptions> = {}) {
|
||||
const fixture = createIndependentDiscoveryBattleFixture(options);
|
||||
return (['Easy', 'Medium', 'Hard', 'Brutal'] as const).map(difficulty => {
|
||||
const view = viewGame(fixture.game, fixture.owner);
|
||||
view.players.find(player => player.id === fixture.owner)!.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 retain a positive native Reinforcements plan at the nested site`);
|
||||
assert.ok(!fixture.game.homeworlds || action.reinforcementsSources);
|
||||
const game = actIndependentDiscoveryBattle(structuredClone(fixture.game), fixture.owner, action);
|
||||
return { difficulty, plan: game.battle!.plans[fixture.owner], sources: action.reinforcementsSources };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import {
|
||||
createSelectedIndependentEntry, type SelectedIndependentEntryOptions,
|
||||
} from './fixture-selected-independent-nexus-entry';
|
||||
import {
|
||||
withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
fremenCunningModulesClean, fremenCunningModulesPlayer, nextFremenCunningModulesStep,
|
||||
type FremenCunningModulesStep,
|
||||
} from './fixture-fremen-cunning-modules';
|
||||
|
||||
export type IndependentDiscoveryCardsOptions = Partial<Omit<SelectedIndependentEntryOptions, 'discoveries'>>;
|
||||
export type IndependentDiscoveryCardsStep = FremenCunningModulesStep;
|
||||
export const independentDiscoveryCardsPlayer = fremenCunningModulesPlayer;
|
||||
export const independentDiscoveryCardsClean = fremenCunningModulesClean;
|
||||
|
||||
export function actIndependentDiscoveryCards(game: Game, actor: string, action: Action,
|
||||
actions?: IndependentDiscoveryCardsStep[]): Game {
|
||||
actions?.push({ actor, action: structuredClone(action) });
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
}
|
||||
export function nextIndependentDiscoveryCardsStep(game: Game): IndependentDiscoveryCardsStep | null {
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
|
||||
if (decision?.kind === 'greatMakerVote')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
|
||||
if (decision?.kind === 'discoveryDiscard') {
|
||||
const card = independentDiscoveryCardsPlayer(game, decision.player).hand[0]; assert.ok(card);
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, card: card.id } };
|
||||
}
|
||||
}
|
||||
return nextFremenCunningModulesStep(game);
|
||||
}
|
||||
export function advanceIndependentDiscoveryCards(game: Game, until: (state: Game) => boolean,
|
||||
actions: IndependentDiscoveryCardsStep[] = []): Game {
|
||||
for (let count = 0; count < 2500; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original independent Discovery chronology ended early');
|
||||
const next = nextIndependentDiscoveryCardsStep(game); assert.ok(next,
|
||||
`Choose the original ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
|
||||
game = actIndependentDiscoveryCards(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original independent Discovery chronology did not reach its requested boundary');
|
||||
}
|
||||
/** Only original unopened Spice Cards are positioned. A Discovery card uses
|
||||
* its real printed parent placement and ordinary production, not a saved flag. */
|
||||
function orderOriginalSpice(game: Game, entries: readonly string[]): void {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
for (const entry of entries) {
|
||||
const index = game.spiceDeck.findIndex(card => entry === 'worm'
|
||||
? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed
|
||||
: entry === 'sandtrout' ? 'sandtrout' in card
|
||||
: entry.startsWith('discovery-') ? 'territory' in card && card.discovery === entry
|
||||
: 'territory' in card && !card.discovery && card.territory === entry);
|
||||
assert.ok(index >= 0, `Use original unopened Spice Card ${entry}`);
|
||||
front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
}
|
||||
function nativeShipment(game: Game, actor: string, territory: string, sector: number, amount: number, elite = 0): Action {
|
||||
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
|
||||
assert.ok(!game.homeworlds || sources);
|
||||
return { type: 'ship', territory, sector, amount, elite, allyPayment: 0,
|
||||
...(sources ? { homeworldSources: sources } : {}) };
|
||||
}
|
||||
|
||||
/** Original host modules/setup → physical Discovery placement → paid parent
|
||||
* shipment → private inspection/public reveal → actual next-turn free-entry
|
||||
* window. Fremen separately earns its remote source through native shipment.
|
||||
* No hand, purse, force, clock, token custody or receipt is assigned afterward. */
|
||||
export function createIndependentDiscoveryCardsFixture(options: IndependentDiscoveryCardsOptions = {}) {
|
||||
const source = withClassicDiscoveryNexusLottery(() => createSelectedIndependentEntry({
|
||||
...options, expansions: options.expansions ?? [], factions: options.factions ?? ['fremen', 'guild', 'emperor'], discoveries: true,
|
||||
}));
|
||||
let game = source.game;
|
||||
const actions = [...source.actions], afterSetup = structuredClone(game), staging: string[] = [];
|
||||
assert.ok(game.discoveryEnabled && game.discoveries && game.ecazTreachery && game.nexusCards?.cards);
|
||||
for (const actor of ['fremen', 'guild', 'emperor']) assert.ok(game.players.some(player => player.id === actor));
|
||||
const act = (actor: string, action: Action) => { game = actIndependentDiscoveryCards(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceIndependentDiscoveryCards(game, until, actions); };
|
||||
orderOriginalSpice(game, game.advanced ? ['discovery-hagga-basin', 'broken_land'] : ['discovery-hagga-basin']);
|
||||
reach(state => state.phase === 1 && independentDiscoveryCardsClean(state));
|
||||
for (let count = 0; count < 100 && !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed'); count++) {
|
||||
const next = nextIndependentDiscoveryCardsStep(game); assert.ok(next);
|
||||
actions.push(structuredClone(next));
|
||||
// This original supply-token lottery must surround the actual producer,
|
||||
// outside the generic scalar wrapper used for all other actions.
|
||||
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action));
|
||||
}
|
||||
const token = game.discoveries!.tokens.find(token => token.face === 'cistern' && token.status === 'placed'); assert.ok(token);
|
||||
const placed = structuredClone(game), parent = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
assert.equal(token.territory, parent.territory); assert.equal(token.sector, parent.sector);
|
||||
const shipments: Array<{ actor: string; before: Game; step: IndependentDiscoveryCardsStep; after: Game }> = [];
|
||||
const shipped = new Set<string>();
|
||||
for (let count = 0; count < 800; count++) {
|
||||
if (game.phase === 7 && independentDiscoveryCardsClean(game)) break;
|
||||
if (game.phase === 5 && independentDiscoveryCardsClean(game) && game.active &&
|
||||
['fremen', 'guild'].includes(game.active) && !shipped.has(game.active)) {
|
||||
const actor = game.active, before = structuredClone(game);
|
||||
const action = actor === 'fremen' ? nativeShipment(game, actor, 'wind_pass', 14, 3, game.advanced ? 2 : 0)
|
||||
: nativeShipment(game, actor, parent.territory, parent.sector, 3);
|
||||
const step: IndependentDiscoveryCardsStep = { actor, action };
|
||||
act(actor, action); reach(independentDiscoveryCardsClean);
|
||||
shipments.push({ actor, before, step, after: structuredClone(game) }); shipped.add(actor);
|
||||
continue;
|
||||
}
|
||||
const next = nextIndependentDiscoveryCardsStep(game); assert.ok(next); act(next.actor, next.action);
|
||||
}
|
||||
assert.equal(shipped.size, 2);
|
||||
assert.equal(game.phase, 7);
|
||||
const beforeInspection = structuredClone(game), own = viewGame(game, 'guild').discoveries; assert.ok(own);
|
||||
assert.ok(own.canInspect.includes(token.id));
|
||||
const inspect: IndependentDiscoveryCardsStep = { actor: 'guild', action: { type: 'discovery', token: token.id, reveal: false } };
|
||||
act(inspect.actor, inspect.action);
|
||||
const inspected = structuredClone(game);
|
||||
assert.ok(viewGame(game, 'guild').discoveries!.canReveal.includes(token.id));
|
||||
const reveal: IndependentDiscoveryCardsStep = { actor: 'guild', action: { type: 'discovery', token: token.id, reveal: true } };
|
||||
act(reveal.actor, reveal.action);
|
||||
const revealed = structuredClone(game);
|
||||
reach(state => state.turn === 2 && !state.phaseOpening && !state.response && state.decision?.kind === 'discoveryEntry' && state.decision.player === 'guild');
|
||||
staging.push('Original host independently selects all modules; only unopened Spice order and the original Cistern supply-token lottery are positioned. Real native Fremen reinforcement and paid Guild parent shipment precede actual inspection, reveal and next-turn entry.');
|
||||
return { source, afterSetup, game, actions, staging, token: token.id, parent: parent.territory, parentSector: parent.sector,
|
||||
placed, shipments, beforeInspection, inspect, inspected, reveal, revealed,
|
||||
owner: 'fremen', collector: 'guild', sourceKey: 'wind_pass:14', destination: 'cistern' };
|
||||
}
|
||||
export type IndependentDiscoveryCardsFixture = ReturnType<typeof createIndependentDiscoveryCardsFixture>;
|
||||
|
||||
/** Continue the original free-entry choice, then a real ordinary worm,
|
||||
* reciprocal alliance and actual closing draw. The common source is returned
|
||||
* immediately before its next physical Spice production. */
|
||||
function openIndependentDiscoveryCardsDraw(fixture: IndependentDiscoveryCardsFixture) {
|
||||
let game = fixture.game;
|
||||
const actions = fixture.actions, owner = fixture.owner;
|
||||
const entryBefore = structuredClone(game), own = viewGame(game, 'guild'), available = own.discoveryEntry!.sources.find(source => source.normal >= 1); assert.ok(available);
|
||||
const entryAction = discoveryEntryMoveAction(own, [{ source: available.source, normal: 1, elite: 0 }]); assert.ok(entryAction);
|
||||
const entry: IndependentDiscoveryCardsStep = { actor: 'guild', action: entryAction };
|
||||
game = actIndependentDiscoveryCards(game, entry.actor, entry.action, actions);
|
||||
const entered = structuredClone(game);
|
||||
game = advanceIndependentDiscoveryCards(game, state => state.turn === 2 && state.phase === 1 && independentDiscoveryCardsClean(state), actions);
|
||||
orderOriginalSpice(game, game.advanced ? ['worm', 'red_chasm', 'rock_outcroppings'] : ['worm', 'red_chasm']);
|
||||
game = advanceIndependentDiscoveryCards(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && independentDiscoveryCardsClean(state), actions);
|
||||
const beforeAlliance = structuredClone(game);
|
||||
for (const [actor, target] of [['guild', 'emperor'], ['emperor', 'guild']])
|
||||
game = actIndependentDiscoveryCards(game, actor, { type: 'alliance', target }, actions);
|
||||
game = advanceIndependentDiscoveryCards(game, state => state.nexusCards?.phase?.stage === 'drawing' && independentDiscoveryCardsClean(state), actions);
|
||||
const deck = game.nexusCards!.cards!.deck, index = deck.indexOf('fremen'); assert.ok(index >= 0);
|
||||
deck.unshift(deck.splice(index, 1)[0]);
|
||||
const drawBefore = structuredClone(game), draw: IndependentDiscoveryCardsStep = { actor: owner, action: {
|
||||
type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
|
||||
game = actIndependentDiscoveryCards(game, draw.actor, draw.action, actions);
|
||||
const drawn = structuredClone(game);
|
||||
game = advanceIndependentDiscoveryCards(game, state => state.turn === 3 && state.phase === 1 && independentDiscoveryCardsClean(state), actions);
|
||||
return { game, actions, entryBefore, entry, entered, beforeAlliance, drawBefore, draw, drawn };
|
||||
}
|
||||
|
||||
/** Spend the actually drawn own card and take its typed post-Nexus remote ride
|
||||
* into the revealed nested location, retaining native destination admission. */
|
||||
export function completeIndependentDiscoveryCards(fixture: IndependentDiscoveryCardsFixture) {
|
||||
const opened = openIndependentDiscoveryCardsDraw(fixture), actions = opened.actions;
|
||||
let game = opened.game;
|
||||
orderOriginalSpice(game, game.advanced ? ['worm', 'sihaya_ridge', 'habbanya_erg'] : ['worm', 'sihaya_ridge']);
|
||||
game = advanceIndependentDiscoveryCards(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'nexusFremenCunningOffer', actions);
|
||||
const beforeSpend = structuredClone(game), occurrence = structuredClone(game.nexusFremenCunningOffer!.occurrence);
|
||||
const spend: IndependentDiscoveryCardsStep = { actor: fixture.owner, action: { type: 'decision', event: occurrence.event, accept: true } };
|
||||
game = actIndependentDiscoveryCards(game, spend.actor, spend.action, actions);
|
||||
const spent = structuredClone(game);
|
||||
game = advanceIndependentDiscoveryCards(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'nexusFremenCunningRide', actions);
|
||||
const beforeRide = structuredClone(game), source = independentDiscoveryCardsPlayer(game, fixture.owner);
|
||||
const elite = game.advanced ? 2 : 0;
|
||||
assert.ok(source.forces[fixture.sourceKey] >= 3 && (source.elites?.forces[fixture.sourceKey] ?? 0) >= elite);
|
||||
const ride: IndependentDiscoveryCardsStep = { actor: fixture.owner, action: { type: 'decision', event: occurrence.event,
|
||||
accept: true, source: 'wind_pass', forces: { [fixture.sourceKey]: { normal: 3 - elite, elite } }, territory: fixture.destination, sector: 0 } };
|
||||
game = actIndependentDiscoveryCards(game, ride.actor, ride.action, actions);
|
||||
const afterRide = structuredClone(game);
|
||||
game = advanceIndependentDiscoveryCards(game, independentDiscoveryCardsClean, actions);
|
||||
return { ...opened, game, actions, occurrence, beforeSpend, spend, spent, beforeRide, ride, afterRide };
|
||||
}
|
||||
|
||||
/** The original E1 Sandtrout breaks the actual alliance, suppresses the next
|
||||
* original worm and doubles only its immediate original land replacement. */
|
||||
export function completeIndependentDiscoverySandtrout(fixture: IndependentDiscoveryCardsFixture) {
|
||||
assert.ok(fixture.afterSetup.expansions.includes('ix'));
|
||||
const opened = openIndependentDiscoveryCardsDraw(fixture), actions = opened.actions;
|
||||
let game = opened.game;
|
||||
const before = structuredClone(game);
|
||||
orderOriginalSpice(game, game.advanced ? ['sandtrout', 'worm', 'sihaya_ridge', 'habbanya_erg'] : ['sandtrout', 'worm', 'sihaya_ridge']);
|
||||
game = advanceIndependentDiscoveryCards(game, state => state.spiceWindow?.territory === 'sihaya_ridge', actions);
|
||||
const doubled = structuredClone(game);
|
||||
game = advanceIndependentDiscoveryCards(game, state => state.phase === 2 && independentDiscoveryCardsClean(state), actions);
|
||||
return { ...opened, game, actions, before, doubled };
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export type SelectedIndependentEntryOptions = {
|
||||
homeworlds?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
discoveries?: boolean;
|
||||
nexus?: boolean;
|
||||
};
|
||||
|
||||
/** Ordinary host controls and original private choices; no dealt inventory edits. */
|
||||
@@ -24,7 +26,8 @@ export function createSelectedIndependentEntry(options: SelectedIndependentEntry
|
||||
game = applyAction(game, actor, action);
|
||||
};
|
||||
act(host, {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
act(host, {type:'moduleOption', module:'nexusCards', enabled:true});
|
||||
if (options.nexus !== false) act(host, {type:'moduleOption', module:'nexusCards', enabled:true});
|
||||
if (options.discoveries) act(host, {type:'moduleOption', module:'discoveries', 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});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import type { Game } from '../game/engine';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { JACURUTU_SIETCH } from '../game/discoveries';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import {
|
||||
completeIndependentDiscoveryBattle, createIndependentDiscoveryBattleFixture,
|
||||
independentDiscoveryBattlePlayer as player, reviveIndependentDiscoveryBattle,
|
||||
smokeIndependentDiscoveryBattle, type IndependentDiscoveryBattleFixture,
|
||||
} from './fixture-independent-discovery-battles';
|
||||
|
||||
function nativePool(game: Game, world: string) {
|
||||
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(group => group.id === world)!.forces.emperor;
|
||||
}
|
||||
function originalSource(f: IndependentDiscoveryBattleFixture) {
|
||||
assert.equal(f.placed.discoveries!.tokens.find(token => token.id === f.token)!.face, JACURUTU_SIETCH);
|
||||
assert.equal(f.placed.discoveries!.tokens.find(token => token.id === f.token)!.revealedTurn, null);
|
||||
assert.ok(f.inspected.discoveries!.tokens.find(token => token.id === f.token)!.known.includes('emperor'));
|
||||
assert.equal(f.revealed.discoveries!.tokens.find(token => token.id === f.token)!.revealedTurn, 1);
|
||||
assert.equal(f.purchased.bids.length, 3);
|
||||
assert.ok(f.purchased.bids.every(bid => bid.step.actor === 'emperor' && bid.step.action.amount === 1));
|
||||
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, RECRUITS_CARD_ID])
|
||||
assert.ok(player(f.game, f.owner).hand.some(card => card.id === id), 'Only original actually purchased cards reach the battle');
|
||||
const prior = player(f.beforeEntry, f.owner), entered = player(f.afterEntry, f.owner);
|
||||
assert.equal(entered.forces[f.nested], 3);
|
||||
assert.equal(entered.forces[`${f.parent.territory}:${f.parent.sector}`] ?? 0, 0);
|
||||
assert.equal(entered.reserves, prior.reserves);
|
||||
assert.equal(entered.spice, prior.spice);
|
||||
assert.equal(entered.moved, prior.moved);
|
||||
assert.equal(entered.shipped, prior.shipped, 'The real next-turn nested entry consumes no ordinary shipment');
|
||||
assert.deepEqual(f.afterEntry.homeworlds, f.beforeEntry.homeworlds);
|
||||
assert.equal(player(f.game, f.opponent).forces[f.nested], 2);
|
||||
assert.equal(f.shipments[1].step.action.territory, JACURUTU_SIETCH);
|
||||
assert.ok(player(f.shipments[1].after, f.opponent).spice < player(f.shipments[1].before, f.opponent).spice,
|
||||
'The enemy army uses its actual printed paid nested shipment');
|
||||
assert.deepEqual(f.fee, { normal: 3, elite: 0 });
|
||||
assert.equal(f.game.nexusCards?.cards?.hands.emperor ?? null, null);
|
||||
assert.equal(f.game.nexusCards?.cards?.hands.guild ?? null, null, 'Selected Nexus is not a claim that an undrawn card was earned');
|
||||
}
|
||||
function settled(f: IndependentDiscoveryBattleFixture, game: Game) {
|
||||
const before = player(f.game, f.owner), after = player(game, f.owner);
|
||||
assert.equal(game.lastBattleContext!.territory, JACURUTU_SIETCH);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(after.forces[f.nested] ?? 0, 0);
|
||||
assert.equal(after.reserves, before.reserves - 3 + 2);
|
||||
assert.equal(after.tanks, before.tanks + 4, 'Three actual reserve-fee counters and one dialed normal casualty reach Tanks');
|
||||
assert.equal(after.elites?.tanks ?? 0, before.elites?.tanks ?? 0);
|
||||
assert.equal(after.elites?.reserves ?? 0, (before.elites?.reserves ?? 0) + (game.advanced ? 2 : 0));
|
||||
assert.equal(player(game, f.opponent).tanks, player(f.game, f.opponent).tanks + 2);
|
||||
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
|
||||
assert.equal(game.discard.filter(card => card.id === id).length, 1);
|
||||
assert.ok(after.hand.some(card => card.id === RECRUITS_CARD_ID));
|
||||
}
|
||||
|
||||
for (const nexus of [true, false]) void test(`original Basic classic Discovery placement${nexus ? ' with Nexus' : ' without Nexus'}, inspection and free nested entry feed real Reinforcements and Harass battle settlement`, () => {
|
||||
const f = createIndependentDiscoveryBattleFixture({ advanced: false, expansions: [],
|
||||
homeworlds: false, skills: false, tech: false, strongholds: false, nexus });
|
||||
originalSource(f);
|
||||
assert.equal(f.game.discoveryEnabled, true);
|
||||
assert.equal(f.game.ecazTreachery, true);
|
||||
assert.equal(!!f.game.nexusCards, nexus);
|
||||
assert.equal(f.game.turn, 2);
|
||||
assert.equal(f.game.battle!.territory, JACURUTU_SIETCH);
|
||||
const result = completeIndependentDiscoveryBattle(f);
|
||||
assert.equal(result.revealed.battle!.plans.emperor.weapon, REINFORCEMENTS_CARD);
|
||||
assert.equal(result.revealed.battle!.plans.emperor.defense, HARASS_WITHDRAW_CARD);
|
||||
settled(f, result.game);
|
||||
});
|
||||
|
||||
void test('original selected-family World/all14/phase Discovery battle pays native reserve fee, returns Sardaukar and revives actual losses through paid Recruits', () => {
|
||||
const f = createIndependentDiscoveryBattleFixture({ advanced: true, expansions: ['ix', 'choam', 'ecaz'],
|
||||
homeworlds: true, skills: true, tech: true, strongholds: true });
|
||||
originalSource(f);
|
||||
assert.ok(f.game.homeworlds && f.game.leaderSkills && f.game.techTokens && f.game.strongholdCards);
|
||||
assert.equal(player(f.afterEntry, f.owner).elites!.forces[f.nested], 2);
|
||||
assert.ok(f.plans[0].action.reinforcementsSources);
|
||||
const { game } = completeIndependentDiscoveryBattle(f);
|
||||
settled(f, game);
|
||||
assert.deepEqual(nativePool(game, 'homeworld:emperor'), {
|
||||
normal: nativePool(f.game, 'homeworld:emperor').normal - 3,
|
||||
elite: nativePool(f.game, 'homeworld:emperor').elite,
|
||||
});
|
||||
assert.deepEqual(nativePool(game, 'homeworld:emperor:salusa'), {
|
||||
normal: nativePool(f.game, 'homeworld:emperor:salusa').normal,
|
||||
elite: nativePool(f.game, 'homeworld:emperor:salusa').elite + 2,
|
||||
});
|
||||
const revived = reviveIndependentDiscoveryBattle(f, game);
|
||||
assert.equal(revived.before.turn, 3);
|
||||
assert.equal(revived.before.phase, 4);
|
||||
assert.equal(player(revived.before, f.owner).tanks, 4);
|
||||
assert.equal(revived.activated.recruits!.player, f.owner);
|
||||
assert.equal(revived.activated.recruits!.turn, 3);
|
||||
assert.equal(revived.revived.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
|
||||
assert.equal(player(revived.revived, f.owner).tanks, 2);
|
||||
assert.equal(player(revived.revived, f.owner).reserves, player(revived.activated, f.owner).reserves + 2);
|
||||
assert.equal(player(revived.revived, f.owner).spice, player(revived.activated, f.owner).spice,
|
||||
'The doubled printed free allowance pays no invented income or extra invoice');
|
||||
assert.deepEqual(nativePool(revived.revived, 'homeworld:emperor'), {
|
||||
normal: nativePool(revived.activated, 'homeworld:emperor').normal + 2,
|
||||
elite: nativePool(revived.activated, 'homeworld:emperor').elite,
|
||||
});
|
||||
assert.equal(revived.game.phase, 5);
|
||||
});
|
||||
|
||||
void test('all four minimal policies retain source-bearing positive Reinforcements plans in a genuinely revealed nested Discovery battle', () => {
|
||||
const report = smokeIndependentDiscoveryBattle({ advanced: true, expansions: ['ix', 'choam', 'ecaz'],
|
||||
homeworlds: true, skills: true, tech: true, strongholds: true });
|
||||
assert.equal(report.length, 4);
|
||||
assert.ok(report.every(result => result.plan.dial > 0 && result.sources));
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { independentDiscoveryNexusProfile } from '../game/faction-module-profile';
|
||||
import { validateDiscoveryState } from '../game/discoveries';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import {
|
||||
createIndependentDiscoveryCardsFixture, completeIndependentDiscoveryCards,
|
||||
completeIndependentDiscoverySandtrout, independentDiscoveryCardsPlayer as player,
|
||||
} from './fixture-independent-discovery-cards';
|
||||
|
||||
function originalModules(game: Game): void {
|
||||
assert.ok(independentDiscoveryNexusProfile(game));
|
||||
assert.ok(game.ecazTreachery && game.discoveryEnabled && game.discoveries && game.nexusCards?.cards);
|
||||
validateDiscoveryState(game.discoveries);
|
||||
assert.equal(game.discoveries.tokens.length, 8);
|
||||
const cards = game.nexusCards.cards;
|
||||
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
}
|
||||
for (const modules of [false, true]) void test(`${modules ? 'Advanced six seats across three families with World/all14/phase' : 'Basic classic independent'} original Discovery reveal and next-turn entry precede native Fremen Cunning into its revealed nested location`, () => {
|
||||
const fixture = createIndependentDiscoveryCardsFixture({ advanced: modules,
|
||||
expansions: modules ? ['ix', 'choam', 'ecaz'] : [],
|
||||
factions: modules ? ['fremen', 'guild', 'emperor', 'ixians', 'choam', 'ecaz'] : ['fremen', 'guild', 'emperor'],
|
||||
skills: modules, homeworlds: modules, tech: modules, strongholds: modules });
|
||||
originalModules(fixture.afterSetup);
|
||||
assert.equal(!!fixture.afterSetup.homeworlds, modules);
|
||||
assert.equal(!!fixture.afterSetup.leaderSkills, modules);
|
||||
assert.equal(!!fixture.afterSetup.techTokens, modules);
|
||||
assert.equal(!!fixture.afterSetup.strongholdCards, modules);
|
||||
assert.equal(viewGame(fixture.beforeInspection, 'guild').discoveries!.tokens.find(token => token.id === fixture.token)!.face, null);
|
||||
assert.equal(viewGame(fixture.inspected, 'guild').discoveries!.tokens.find(token => token.id === fixture.token)!.face, 'cistern');
|
||||
assert.equal(viewGame(fixture.inspected, 'emperor').discoveries!.tokens.find(token => token.id === fixture.token)!.face, null);
|
||||
assert.equal(viewGame(fixture.beforeInspection, 'fremen').discoveries!.tokens.find(token => token.id === fixture.token)!.face, 'cistern',
|
||||
'Native Fremen already knows the Hiereg face.');
|
||||
assert.equal(viewGame(fixture.revealed, 'fremen').discoveries!.tokens.find(token => token.id === fixture.token)!.face, 'cistern');
|
||||
assert.equal(fixture.revealed.discoveries!.tokens.find(token => token.id === fixture.token)!.revealedTurn, 1);
|
||||
assert.equal(fixture.game.turn, 2);
|
||||
assert.equal(fixture.game.phase, 0);
|
||||
assert.equal(fixture.game.decision?.kind, 'discoveryEntry');
|
||||
const guild = fixture.shipments.find(shipment => shipment.actor === 'guild'); assert.ok(guild);
|
||||
assert.equal(player(guild.after, 'guild').reserves, player(guild.before, 'guild').reserves - 3);
|
||||
assert.equal(player(guild.after, 'guild').spice, player(guild.before, 'guild').spice - 3);
|
||||
const done = completeIndependentDiscoveryCards(fixture);
|
||||
assert.equal(player(done.entered, 'guild').forces['cistern:0'], 1);
|
||||
assert.equal(player(done.entered, 'guild').forces[`${fixture.parent}:${fixture.parentSector}`], player(done.entryBefore, 'guild').forces[`${fixture.parent}:${fixture.parentSector}`] - 1);
|
||||
assert.equal(player(done.entered, 'guild').spice, player(done.entryBefore, 'guild').spice);
|
||||
assert.equal(player(done.entered, 'guild').moved, player(done.entryBefore, 'guild').moved);
|
||||
assert.equal(player(done.entered, 'guild').shipped, player(done.entryBefore, 'guild').shipped);
|
||||
assert.equal(done.drawn.nexusCards!.cards!.hands.fremen, 'fremen');
|
||||
assert.equal(player(done.drawn, 'guild').ally, 'emperor');
|
||||
assert.equal(player(done.drawn, 'fremen').ally, null);
|
||||
assert.equal(done.occurrence.initiallyEmpty, true);
|
||||
assert.equal(done.occurrence.territory, 'red_chasm');
|
||||
assert.equal(done.occurrence.origin, 'natural');
|
||||
assert.equal(done.beforeSpend.spice['red_chasm:7'], 8);
|
||||
assert.equal(done.spent.nexusCards!.cards!.hands.fremen, null);
|
||||
assert.equal(done.spent.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.equal(done.beforeRide.spice['red_chasm:7'] ?? 0, 0);
|
||||
assert.equal(done.beforeRide.decision?.kind, 'nexusFremenCunningRide');
|
||||
assert.equal(player(done.afterRide, 'fremen').forces['cistern:0'], 3);
|
||||
assert.equal(player(done.afterRide, 'fremen').elites?.forces['cistern:0'] ?? 0, modules ? 2 : 0);
|
||||
assert.equal(player(done.afterRide, 'fremen').forces[fixture.sourceKey] ?? 0, player(done.beforeRide, 'fremen').forces[fixture.sourceKey] - 3);
|
||||
assert.deepEqual(player(done.afterRide, 'guild').forces, player(done.beforeRide, 'guild').forces);
|
||||
assert.equal(player(done.afterRide, 'fremen').reserves, player(done.beforeRide, 'fremen').reserves);
|
||||
assert.equal(player(done.afterRide, 'fremen').moved, player(done.beforeRide, 'fremen').moved);
|
||||
assert.equal(player(done.afterRide, 'fremen').shipped, player(done.beforeRide, 'fremen').shipped);
|
||||
assert.equal(player(done.afterRide, 'fremen').spice, player(done.beforeRide, 'fremen').spice);
|
||||
assert.equal(done.afterRide.nexusFremenCunningRides!.find(ride => ride.occurrence.event === done.occurrence.event)!.stage, 'complete');
|
||||
originalModules(done.game);
|
||||
});
|
||||
|
||||
void test('actual selected Ix Sandtrout breaks the earned alliance and suppresses its next worm without losing independent Discovery or native Fremen card custody', () => {
|
||||
const fixture = createIndependentDiscoveryCardsFixture({ expansions: ['ix'], factions: ['fremen', 'guild', 'emperor'] });
|
||||
const done = completeIndependentDiscoverySandtrout(fixture);
|
||||
assert.equal(player(done.before, 'guild').ally, 'emperor');
|
||||
assert.equal(player(done.before, 'emperor').ally, 'guild');
|
||||
assert.equal(done.before.nexusCards!.cards!.hands.fremen, 'fremen');
|
||||
assert.ok(done.doubled.players.every(player => player.ally === null));
|
||||
assert.equal(done.doubled.sandtrout, false);
|
||||
assert.equal(done.doubled.nexus, false);
|
||||
assert.equal(done.doubled.nexusFremenCunningOffer ?? null, null);
|
||||
assert.equal(done.doubled.nexusFremenCunningRides?.length ?? 0, done.before.nexusFremenCunningRides?.length ?? 0);
|
||||
assert.equal(done.doubled.spiceWindow!.amount, 12);
|
||||
assert.deepEqual(done.doubled.spiceDiscard[0].slice(-3), [
|
||||
{ sandtrout: true }, { worm: true, suppressed: true }, { territory: 'sihaya_ridge', amount: 6, sector: 9 },
|
||||
]);
|
||||
assert.equal(done.doubled.nexusCards!.cards!.hands.fremen, 'fremen');
|
||||
assert.equal(done.doubled.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 0);
|
||||
assert.deepEqual(player(done.doubled, 'fremen').forces, player(done.before, 'fremen').forces);
|
||||
assert.equal(player(done.doubled, 'fremen').tanks, player(done.before, 'fremen').tanks);
|
||||
assert.equal(done.doubled.spice['red_chasm:7'], done.before.spice['red_chasm:7']);
|
||||
assert.equal(done.game.phase, 2);
|
||||
assert.equal(done.game.nexusCards!.phase!.occurred, false);
|
||||
assert.equal(done.game.spice['sihaya_ridge:9'], 12);
|
||||
originalModules(done.game);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spiceDeck } from '../game/cards';
|
||||
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { ECAZ_TREACHERY_DEFINITIONS } from '../game/ecaz-cards';
|
||||
import { independentDiscoveryProfile, independentDiscoveryNexusProfile, independentNexusProfile, 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: [], factions: ['atreides', 'guild'] },
|
||||
{ expansions: ['ix'], factions: ['ixians', 'tleilaxu'] },
|
||||
{ expansions: ['choam'], factions: ['choam', 'richese', 'guild'], advanced: true,
|
||||
skills: true, homeworlds: true, tech: true, strongholds: true },
|
||||
{ expansions: ['ecaz'], factions: ['ecaz', 'moritani'] },
|
||||
{ expansions: ['ecaz', 'ix', 'choam'], factions: ['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'],
|
||||
advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
|
||||
{ expansions: ['ix', 'choam', 'ecaz'], factions: ['emperor', 'atreides'],
|
||||
advanced: true, skills: true, strongholds: true },
|
||||
];
|
||||
|
||||
for (const options of [entries[0], entries[4]]) void test(
|
||||
`normal independent Discovery keeps Nexus optional for ${options.expansions.join('+') || 'classic'}`,
|
||||
() => {
|
||||
const { game, actions } = createSelectedIndependentEntry({ ...options, discoveries: true, nexus: false });
|
||||
assert.ok(independentDiscoveryProfile(game));
|
||||
assert.equal(independentDiscoveryNexusProfile(game), false);
|
||||
assert.equal(nexusCoreProfile(game), false);
|
||||
assert.equal(game.nexusCards, undefined);
|
||||
assert.ok(game.discoveries);
|
||||
validateDiscoveryState(game.discoveries);
|
||||
assert.equal(game.discoveries.tokens.length, 8);
|
||||
assert.ok(!actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'nexusCards'));
|
||||
assert.equal(game.nexusIxianReplacementPreview, undefined);
|
||||
assert.equal(game.nexusIxianBetrayalPreview, undefined);
|
||||
assert.equal(game.guildBetrayalPreview, undefined);
|
||||
assert.equal(!!game.leaderSkills, !!options.skills);
|
||||
assert.equal(!!game.homeworlds, !!options.homeworlds);
|
||||
},
|
||||
);
|
||||
|
||||
for (const options of entries) void test(
|
||||
`normal ${options.advanced ? 'Advanced' : 'Basic'} independent Discovery Nexus setup ${options.expansions.join('+') || 'classic'}/${options.factions.join('+')}`,
|
||||
() => {
|
||||
const { lobby, setup, game, actions } = createSelectedIndependentEntry({ ...options, discoveries: true });
|
||||
assert.ok(actions.some(step => step.actor === lobby.host && step.action.type === 'moduleOption' &&
|
||||
step.action.module === 'discoveries' && step.action.enabled === true));
|
||||
assert.equal(lobby.discoveryEnabled, true);
|
||||
assert.equal(lobby.lobbyOptions?.ecazTreachery, true);
|
||||
assert.equal(lobby.discoveries, undefined, 'The host selects a module before any token is dealt.');
|
||||
assert.equal(game.discoveryEnabled, true);
|
||||
assert.equal(game.ecazTreachery, true);
|
||||
assert.equal(independentDiscoveryNexusProfile(setup), true);
|
||||
assert.equal(independentDiscoveryNexusProfile(game), true);
|
||||
assert.equal(independentNexusProfile(game), true);
|
||||
assert.equal(nexusCoreProfile(game), true);
|
||||
assert.deepEqual(game.expansions, options.expansions);
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.phase, 0);
|
||||
assert.equal(!!game.homeworlds, !!options.homeworlds);
|
||||
assert.equal(!!game.leaderSkills, !!options.skills);
|
||||
assert.equal(!!game.techTokens, !!options.tech);
|
||||
assert.equal(!!game.strongholdCards, !!options.strongholds);
|
||||
|
||||
const ordinary = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)];
|
||||
assert.equal(ordinary.length, options.expansions.includes('ix') ? 50 : options.expansions.includes('choam') ? 38 : 36);
|
||||
for (const definition of ECAZ_TREACHERY_DEFINITIONS)
|
||||
assert.equal(ordinary.filter(card => card.id === definition.card.id).length, 1,
|
||||
`${definition.card.name} remains one real selected independent card.`);
|
||||
assert.equal(game.richeseCache?.length ?? 0, options.factions.includes('richese') ? 10 : 0);
|
||||
assert.equal(game.nexusCards!.cards!.deck.length, 12);
|
||||
assert.equal(game.nexusCards!.cards!.discard.length, 0);
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null),
|
||||
'Fresh module setup does not invent a qualifying closing Nexus draw.');
|
||||
|
||||
assert.ok(game.discoveries);
|
||||
validateDiscoveryState(game.discoveries);
|
||||
assert.equal(game.discoveries.tokens.length, 8);
|
||||
assert.ok(game.discoveries.tokens.every(token => token.status === 'supply' &&
|
||||
token.territory === null && token.sector === null && token.revealedTurn === null));
|
||||
assert.deepEqual(game.discoveries.newlyRevealed, []);
|
||||
assert.equal(game.spiceDeck.length, spiceDeck(options.expansions.includes('ix')).length + 7);
|
||||
assert.deepEqual(game.spiceDeck.flatMap(card => 'territory' in card && card.discovery ? [card.discovery] : []).sort(),
|
||||
DISCOVERY_SPICE_CARDS.map(card => card.discovery).sort());
|
||||
assert.equal(game.spiceDeck.filter(card => 'worm' in card && card.greatMaker).length, 1);
|
||||
assert.equal(game.spiceDeck.filter(card => 'sandtrout' in card).length, options.expansions.includes('ix') ? 1 : 0);
|
||||
assert.equal(game.greatMaker, undefined, 'No appearance occurs during fresh Storm setup.');
|
||||
|
||||
assert.equal(!!game.nexusIxianBetrayalPreview, options.factions.includes('ixians'));
|
||||
assert.equal(!!game.nexusIxianReplacementPreview, !options.factions.includes('ixians'));
|
||||
assert.equal(!!game.ecazOccupyPreview, options.factions.includes('ecaz'));
|
||||
assert.equal(!!game.moritaniAssassinate, !!options.advanced && options.factions.includes('moritani'));
|
||||
if (options.factions.includes('richese'))
|
||||
assert.equal(game.players.find(player => player.faction === 'richese')!.noField!.tokens.length, 3);
|
||||
if (options.factions.includes('tleilaxu'))
|
||||
assert.ok(game.players.find(player => player.faction === 'tleilaxu')!.faceDancers?.length,
|
||||
'The original Tleilaxu private setup remains connected.');
|
||||
if (options.skills) assert.ok(['skillTreachery', 'leaderSkills'].includes(setup.setupStage!));
|
||||
if (setup.techTokens) assert.deepEqual(setup.techTokens, createTechTokens(setup.players));
|
||||
if (game.strongholdCards) assert.ok(Object.values(game.strongholdCards.owners).every(owner => owner === null));
|
||||
},
|
||||
);
|
||||
|
||||
void test('independent Discovery capability requires explicit cards, Discovery, Nexus and registered selected seats', () => {
|
||||
const base = { advanced: true, expansions: [], players: [{ faction: 'atreides' }, { faction: 'guild' }],
|
||||
ecazTreachery: true, discoveryEnabled: true, nexusCards: true };
|
||||
assert.equal(independentDiscoveryNexusProfile(base), true);
|
||||
const invalid = [
|
||||
{ ...base, ecazTreachery: false }, { ...base, discoveryEnabled: false },
|
||||
{ ...base, discoveryEnabled: undefined, discoveries: {} }, { ...base, nexusCards: false },
|
||||
{ ...base, expansions: ['ix', 'ix'] }, { ...base, expansions: ['ecaz', 'ix', 'choam', 'ecaz'] },
|
||||
{ ...base, expansions: ['sandtrout'] }, { ...base, players: [{ faction: 'atreides' }] },
|
||||
{ ...base, players: [{ faction: 'atreides' }, { faction: 'atreides' }] },
|
||||
{ ...base, players: [{ faction: 'atreides' }, { faction: 'richese' }] },
|
||||
{ ...base, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
|
||||
{ ...base, expansions: ['ix'], players: [{ faction: 'ixians' }, { faction: 'richese' }] },
|
||||
{ ...base, techTokens: true }, { ...base, advanced: false, strongholdCards: true },
|
||||
{ ...base, semutaPreview: true }, { ...base, advancedPreview: true },
|
||||
{ ...base, kullPreview: true }, { ...base, nexusKullPreview: true },
|
||||
{ ...base, richeseBetrayalPreview: true },
|
||||
];
|
||||
for (const configuration of invalid)
|
||||
assert.equal(independentDiscoveryNexusProfile(configuration), false, JSON.stringify(configuration));
|
||||
});
|
||||
|
||||
void test('live independent Discovery retains native stamps and transient encounters while preserving trained E3 capture boundary', () => {
|
||||
const native = { advanced: true, expansions: ['ecaz'],
|
||||
players: [{ faction: 'moritani' }, { faction: 'harkonnen' }],
|
||||
ecazTreachery: true, discoveryEnabled: true, nexusCards: true };
|
||||
assert.equal(independentDiscoveryNexusProfile({ ...native, moritaniAssassinatePreview: true }), true);
|
||||
assert.equal(independentDiscoveryNexusProfile({ ...native, leaderSkills: true }), false);
|
||||
assert.equal(independentDiscoveryNexusProfile({ ...native, advanced: false, leaderSkills: true }), true);
|
||||
assert.equal(independentDiscoveryNexusProfile({ ...native, advanced: false, moritaniAssassinatePreview: true }), false);
|
||||
assert.equal(independentDiscoveryNexusProfile({ ...native, leaderSkills: true,
|
||||
players: [{ faction: 'atreides' }, { faction: 'harkonnen' }] }), true,
|
||||
'Selecting an unused E3 deck does not add native trained capture.');
|
||||
|
||||
const encounter = { ...native, expansions: ['ecaz', 'ix'],
|
||||
players: [{ faction: 'moritani' }, { faction: 'ixians' }, { faction: 'guild' }],
|
||||
nexusIxianBetrayalPreview: true, moritaniAssassinatePreview: true,
|
||||
discoveries: {}, discoveryStash: {}, greatMaker: {}, sandtrout: {},
|
||||
homeworlds: true, leaderSkills: true, techTokens: true, strongholdCards: true };
|
||||
assert.equal(independentDiscoveryNexusProfile(encounter), true,
|
||||
'The public runtime envelope does not reject a legal initialized module because an encounter exists.');
|
||||
assert.equal(nexusCoreProfile(encounter), true);
|
||||
assert.equal(independentNexusProfile(encounter), true);
|
||||
assert.equal(independentDiscoveryNexusProfile({ ...encounter,
|
||||
players: [{ faction: 'moritani' }, { faction: 'tleilaxu' }, { faction: 'guild' }] }), false,
|
||||
'An Ixian-specific native stamp requires the actual seated Ixian.');
|
||||
});
|
||||
Reference in new issue
Block a user