Compose selected-deck Tech and Stronghold modules

This commit is contained in:
Quintine committed 2026-10-26 11:07:56 +11:00
1 parent 82370d906f
commit 68a7870e2e
21 files changed
+1168 -50

No files matched your search

+11 -5
View File
@@ -295,11 +295,17 @@ for (const strongholds of [false, true])
for (const strongholds of [false, true]) {
const initialize = strongholds ? initializeStrongholdFactionsGameForAudit : initializeFactionExpansionsGameForAudit;
void test(`${strongholds ? 'Stronghold+Tech' : 'plain Tech'} initializer rejects paired E3 and mixed selected families without admitting unrelated overlays`, () => {
assert.throws(() => initialize(lobby(['ecaz', 'moritani', 'guild'])));
assert.throws(() => initialize(lobby(['ecaz', 'ixians', 'guild'], true, ['ecaz', 'ix'])));
assert.throws(() => initialize(lobby(['moritani', 'choam', 'guild'], true, ['ecaz', 'choam'])));
assert.throws(() => initialize(lobby(['moritani', 'harkonnen', 'guild'])));
void test(`${strongholds ? 'Stronghold+Tech' : 'plain Tech'} initializer admits selected mixed families while excluding unrelated overlays and used inventory`, () => {
for (const admitted of [lobby(['ecaz', 'moritani', 'guild']),
lobby(['ecaz', 'ixians', 'guild'], true, ['ecaz', 'ix']),
lobby(['moritani', 'choam', 'guild'], true, ['ecaz', 'choam']),
lobby(['moritani', 'harkonnen', 'guild'])]) {
const setup = initialize(admitted);
assert.equal(setup.status, 'setup');
assert.deepEqual(setup.expansions, admitted.expansions);
assert.equal(!!setup.strongholdCards, strongholds);
assert.equal(!!setup.moritaniAssassinate, admitted.players.some(p => p.faction === 'moritani'));
}
assert.throws(() => initialize(lobby(['ecaz', 'guild'])));
if (strongholds) assert.throws(() => initialize(lobby(['ecaz', 'guild', 'emperor'], false)));
for (const overlay of ['leaderSkills', 'homeworlds', 'nexusCards', 'discoveryEnabled', 'ecazTreachery',
+176
View File
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { TERRITORIES, MOBILE_STRONGHOLD, location, territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { actAdvisorPreparation } from './fixture-advisor-preparation';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type MixedPhaseModulesStep = { actor: string; action: Action };
export type MixedPhaseModulesTransition = { before: Game; steps: MixedPhaseModulesStep[]; after: Game };
export type MixedPhaseModulesOptions = {
factions: readonly FactionId[];
expansions: Game['expansions'];
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
};
export const MIXED_PHASE_NATIVE_FACTIONS = ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const;
export const actMixedPhaseModules = actAdvisorPreparation;
export const mixedPhaseModulesPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const mixedPhaseModulesClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.truthtrance &&
!game.pendingShipment && !game.pendingKarama && !game.currentAuctionSale;
/** Current physical zones, excluding the ordinary auction's historical aliases. */
export function mixedPhaseModulesInventory(game: Game): string[] {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original physical Treachery Card has one current slot');
return ids;
}
/** Native decisions only. Optional Terror placement is declined so the positive
* shipment source does not depend on a separately selected Terror effect. */
export function nextMixedPhaseModules(game: Game): MixedPhaseModulesStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision) {
const d = game.decision;
if (d.kind === 'moritaniSetup') return { actor: d.player,
action: { type: 'decision', territory: 'carthag', sector: territory('carthag').sectors[0] } };
if (d.kind === 'moritaniPlacement' || d.kind === 'choamStorm' || d.kind === 'choamMovement' ||
d.kind === 'revivalStop') return { actor: d.player, action: { type: 'decision', decline: true } };
if (d.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } };
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
if (d.kind === 'richeseUnbid') return { actor: d.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
}
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceMixedPhaseModules(game: Game, until: (state: Game) => boolean,
actions: MixedPhaseModulesStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original phase-module source ended before its requested boundary');
const step = nextMixedPhaseModules(game);
assert.ok(step, `Original continuation required at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actMixedPhaseModules(game, step.actor, step.action, actions);
}
throw Error('The original phase-module source did not reach its finite boundary');
}
export function finishMixedPhaseModules(game: Game, actions: MixedPhaseModulesStep[] = []): Game {
const phase = game.phase, turn = game.turn;
return advanceMixedPhaseModules(game, state => (state.phase !== phase || state.turn !== turn) && mixedPhaseModulesClean(state), actions);
}
/** Public module selections, ready/start and every original setup decision.
* Only scalar lotteries and unopened Spice positions are selected. No played
* card, force, wallet, Tanks, phase, token or Stronghold owner is installed. */
export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions) {
const [host, ...others] = options.factions; assert.ok(host);
let game = createGame('MIXEDPHASEMODULES', newPlayer(host, host, host), options.advanced ?? false, options.expansions);
for (const faction of others) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: MixedPhaseModulesStep[] = [];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
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); assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => !player.hand.length));
const originalInventory = mixedPhaseModulesInventory(game);
game = advanceMixedPhaseModules(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position);
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
game = advanceMixedPhaseModules(game, state => state.phase === 1 && mixedPhaseModulesClean(state), actions);
const firstStorm = structuredClone(game);
assert.deepEqual(mixedPhaseModulesInventory(game), originalInventory);
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, firstStorm,
originalInventory, originalSpice, spiceOrder, actions, sourceActions: structuredClone(actions),
sourceActionCount: actions.length, staging: [] as string[] };
}
/** Actual all-six-native two-turn programme. The first two positive shipments
* accrue Heighliners once; native first Mentat claims precede a real later
* Storm casualty (Basic dials2 / Advanced card1) and that owner's free return. Sources remain ordinary public
* entry plus original native choices, with no played-state installation. */
export function createMixedPhaseModulesProgramme(options: { advanced?: boolean; strongholds?: boolean } = {}) {
const advanced = options.advanced ?? false, strongholds = options.strongholds ?? advanced;
const source = createMixedPhaseModulesFixture({ factions: MIXED_PHASE_NATIVE_FACTIONS,
expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceMixedPhaseModules(game, until, actions); };
const transition = (run: () => void): MixedPhaseModulesTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
reach(state => state.turn === 1 && state.phase === 5 && state.active === 'choam' && mixedPhaseModulesClean(state));
const tabr = territory('sietch_tabr').sectors[0], choamLocation = location('sietch_tabr', tabr);
assert.ok(game.players.every(player => !player.forces[choamLocation]));
const choamShipment = transition(() => {
act('choam', { type: 'ship', territory: 'sietch_tabr', sector: tabr, amount: 1, elite: 0, allyPayment: 0 });
reach(mixedPhaseModulesClean);
});
reach(state => state.turn === 1 && state.phase === 5 && state.active === 'moritani' && mixedPhaseModulesClean(state));
const sector = game.storm % 18 + 1;
const target = TERRITORIES.find(t => t.type === 'sand' && t.id !== 'imperial_basin' && t.sectors.includes(sector) &&
game.players.every(player => !Object.entries(player.forces).some(([key, count]) => count > 0 && key.startsWith(`${t.id}:`))));
assert.ok(target, 'The next original Storm sector has an empty desert shipment destination');
const casualtyLocation = location(target.id, sector);
const moritaniShipment = transition(() => {
act('moritani', { type: 'ship', territory: target.id, sector, amount: 1, elite: 0, allyPayment: 0 });
reach(mixedPhaseModulesClean);
});
// Capture the actual phase-closing action, rather than folding later income
// into the Heighliners receipt.
reach(state => state.phase === 5 && mixedPhaseModulesClean(state) &&
state.order.at(-1) === state.active);
const shipmentPayout = transition(() => {
act(game.active!, { type: 'endMovement' });
reach(state => state.phase !== 5);
});
reach(state => state.turn === 1 && state.phase === 8 && mixedPhaseModulesClean(state));
const mentat = transition(() => {
reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && mixedPhaseModulesClean(state));
});
assert.equal(game.stormMovementSource?.kind, advanced ? 'card' : 'dials'); assert.equal(game.stormPending, advanced ? 1 : 2);
const storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1 && mixedPhaseModulesClean(state)); });
assert.equal(mixedPhaseModulesPlayer(game, 'moritani').forces[casualtyLocation], undefined);
assert.equal(mixedPhaseModulesPlayer(game, 'moritani').tanks, 1);
reach(state => state.turn === 2 && state.phase === 4 && mixedPhaseModulesClean(state) &&
(!state.revivalOrder || state.revivalOrder.remaining[0] === 'moritani'));
const revival = transition(() => {
act('moritani', { type: 'revive', amount: 1, elite: 0 }); reach(mixedPhaseModulesClean);
});
// Complete earlier/remaining owned opportunities with the original ready
// controls, and expose only the final phase-end token payout as its receipt.
reach(state => state.phase === 4 && mixedPhaseModulesClean(state) && (state.revivalOrder
? state.revivalOrder.remaining.length === 1 : state.ready.length === state.players.length - 1));
const revivalPayout = transition(() => {
const step = nextMixedPhaseModules(game); assert.ok(step); act(step.actor, step.action);
reach(state => state.phase === 5 && mixedPhaseModulesClean(state));
});
assert.equal(game.phase, 5);
reach(state => state.phase === 5 && mixedPhaseModulesClean(state));
assert.deepEqual(mixedPhaseModulesInventory(game), source.originalInventory);
if (strongholds) {
assert.equal(mentat.after.strongholdCards?.owners.carthag, 'moritani');
assert.equal(mentat.after.strongholdCards?.owners.sietch_tabr, 'choam');
assert.equal(mentat.after.strongholdCards?.owners[MOBILE_STRONGHOLD], 'ixians');
}
return { ...source, game, sourceGame: structuredClone(game), advanced, strongholds,
choamLocation, casualtyLocation, choamShipment, moritaniShipment, shipmentPayout, mentat, storm,
revival, revivalPayout, actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length };
}
@@ -98,12 +98,14 @@ export function createMoritaniAssassinateGholaTargetFixture() {
while (source.actions[sourceActionCount]?.action.type === 'passResponse') sourceActionCount++;
const sourceActions = structuredClone(source.actions.slice(0, sourceActionCount));
let game = mixedE3MoritaniReload(prefix);
const actions: MixedE3MoritaniStep[] = [];
// Complete the remaining actual Revival opportunities and closing windows.
// The later conserved position is arranged only after native Shipment and
// Movement opens; its real end-turn actions then produce the Battle phase.
let game = advanceMixedE3Moritani(mixedE3MoritaniReload(prefix),
state => state.phase === 5 && mixedE3MoritaniClean(state), actions);
assert.ok(!game.revivalOrder, 'Native phase completion retires the original Revival frame.');
const swap = swapHeldTraitor(game, owner, target);
game.phase = 6;
game.ready = [];
game.order = [opponent, owner, ...game.order.filter(id => id !== opponent && id !== owner)];
game.active = opponent;
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
@@ -136,7 +138,6 @@ export function createMoritaniAssassinateGholaTargetFixture() {
.sort((first, second) => first.strength - second.strength)[0];
assert.ok(winningDisc && losingDisc && winningDisc.strength > losingDisc.strength,
'Different living native Guild and Moritani discs must produce the unarmed loss.');
const actions: MixedE3MoritaniStep[] = [];
game = openMixedE3MoritaniBattle(game, opponent, owner, actions);
game = advanceMixedE3Moritani(game, state => !!state.battle && mixedE3MoritaniClean(state) &&
!state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
@@ -170,7 +171,7 @@ export function createMoritaniAssassinateGholaTargetFixture() {
...source.staging,
'Reuse only the source fixture’s genuine native Guild death and paid Tleilaxu foreign-revival prefix. The later source winning-Ghola battle is not used as this target witness.',
`Explicitly swap Moritani’s physical ${swap.previous} Traitor Card with ${target} from the ${swap.zone}; preserve the complete original Traitor/Face Dancer census. This is controlled card positioning, not a new deal or a natural draw.`,
'Explicitly stage the later phase-six/order/army position: return every current board counter to its own reserves, retain all Tanks and elite stocks, then place three normal Guild and three normal Moritani counters together outside the unchanged storm. No wallet, hand, mode, target disc or custody field is assigned.',
'Actually finish remaining native Revival opportunities and closing windows. At the original Shipment and Movement phase, explicitly stage only the conserved army position: return every current board counter to its own reserves, retain all Tanks and elite stocks, then place three normal Guild and three normal Moritani counters together outside the unchanged storm. Native end-turn actions open Battle; no phase, order, active player, wallet, hand, mode, target disc or custody field is assigned.',
'Use actual chooseBattle, ordinary preparation, two unarmed private Battle Plans and normal Traitor declines. A different living native Guild disc wins; the original paid foreign Ghola remains unchanged in the Guild roster with Tleilaxu custody. Stop at the real Moritani Assassinate Leaders decision.',
];
return { game, owner, opponent, target, event, potentialBounty: choice.potentialBounty,
+18 -4
View File
@@ -127,7 +127,9 @@ function declareCapture(source: Game, target: string, actions: AssassinateTarget
* the first original battle, captures Burseg through its native lottery and
* keeps or executes him. A different living Emperor disc then beats Moritani.
* Stop at that actual assassination opportunity, before any reveal/decline. */
export function createMoritaniAssassinateTargetFixture(options: { execute?: boolean } = {}) {
export function createMoritaniAssassinateTargetFixture(options: {
execute?: boolean; techTokens?: boolean; strongholds?: boolean;
} = {}) {
const captor = 'harkonnen', opponent = 'emperor', owner = 'moritani', target = 'emperor-3';
let game = createGame('MORITANIASSASSINATETARGET', newPlayer(captor, 'Harkonnen', 'harkonnen'), true, ['ecaz']);
joinGame(game, newPlayer(opponent, 'Emperor', 'emperor'));
@@ -139,13 +141,22 @@ export function createMoritaniAssassinateTargetFixture(options: { execute?: bool
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const moduleSelections: AssassinateTargetTransition[] = [];
if (options.techTokens) moduleSelections.push(transition(() => {
act(game.host, { type: 'techTokens', enabled: true });
}));
if (options.strongholds) moduleSelections.push(transition(() => {
act(game.host, { type: 'strongholdCards', enabled: true });
}));
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
assert.deepEqual(game.players.map(player => player.faction), [captor, opponent, owner]);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills &&
!game.discoveryEnabled && !game.ecazTreachery && !game.semutaPreview);
assert.equal(!!game.techTokens, options.techTokens ?? false);
assert.equal(!!game.strongholdCards, options.strongholds ?? false);
assert.ok(game.players.every(player => player.hand.length === 0));
assert.ok(assassinateTargetPlayer(game, owner).traitorChoices.includes(target),
'The identity-order original third-seat Traitor offer really contains Burseg');
@@ -239,13 +250,16 @@ export function createMoritaniAssassinateTargetFixture(options: { execute?: bool
owner, moritani: owner, opponent, emperor: opponent, captor, harkonnen: captor, target,
winningDisc: 'emperor-0', territory: 'imperial_basin', sector: 11, location: 'imperial_basin:11',
event: receipt.event, receipt: structuredClone(receipt), execute: !!options.execute,
techTokens: !!options.techTokens, strongholds: !!options.strongholds, moduleSelections,
potentialBounty: victim.strength, action: reveal,
reveal, revealStep: { actor: owner, action: reveal } satisfies AssassinateTargetStep,
decline, actions, sourceActionCount: actions.length, originalInventory, originalTraitors,
originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public native Advanced Harkonnen/Emperor/Moritani ready/start and private setup. Original scalar shuffle order naturally offers emperor-3 in Moritani’s third-seat Traitor packet; Moritani actually chooses it.',
'Select four original unopened ordinary Treachery positions and two original unopened ordinary Spice positions. Native Moritani setup really places six counters in empty Imperial Basin11. No optional module is selected.',
`Select four original unopened ordinary Treachery positions and two original unopened ordinary Spice positions. Native Moritani setup really places six counters in empty Imperial Basin11. ${options.techTokens || options.strongholds
? `Actual fresh lobby controls select ${[options.techTokens ? 'original Tech Tokens' : '', options.strongholds ? 'six Advanced Stronghold Cards' : ''].filter(Boolean).join(' and ')} before normal start; no other overlay is selected.`
: 'No optional module is selected.'}`,
'Emperor actually pays two to ship two reserves to Carthag11, then really moves one to Imperial Basin11. Native first Storm, dual Spice, Charity, Bidding, Revival and movement turns execute normally.',
'Harkonnen actually wins Carthag with Feyd6 against Bashar2, without weapons or leader deaths. Scope only the ensuing native capture permutation to choose unused Burseg3; no candidate body, captive flag, death or concealment is assigned. The original capturedLeader action really keeps or executes that disc.',
'A different surviving native Emperor disc, Fenring6, really wins Imperial Basin against Moritani’s Vando1. The original printed-Emperor assassination opportunity opens while Moritani still physically holds Burseg’s original Traitor Card.',
@@ -192,6 +192,17 @@ export function createMoritaniForeignGholaWinnerFixture(): ForeignGholaWinnerFix
// Turn two revival: the genuine half-price foreign return, settled by the engine.
g = advanceMixedE3Moritani(guildBattle.after,
(s) => s.turn === 2 && s.phase === 4 && mixedE3MoritaniClean(s), actions);
// A fresh Advanced phase now has native player order. Finish only the
// preceding actual owners; the paid foreign return belongs to Tleilaxu's
// still-unstarted opportunity, not the opening actor's turn.
while (g.revivalOrder && g.revivalOrder.remaining[0] !== tleilaxu) {
assert.ok(g.revivalOrder.remaining.includes(tleilaxu),
'The original Tleilaxu Revival opportunity must remain pending.');
const actor = g.revivalOrder.remaining[0]; assert.ok(actor);
g = stepMixedE3Moritani(g, { actor, action: { type: 'ready' } }, actions);
g = advanceMixedE3Moritani(g,
(s) => s.phase === 4 && mixedE3MoritaniClean(s), actions);
}
const beforeRevival = mixedE3MoritaniReload(g);
const cost = Math.ceil(killedGuild.strength / 2);
assert.ok(mixedE3MoritaniPlayer(beforeRevival, tleilaxu).spice >= cost,
+196
View File
@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { ownedTech } from '../game/tech-tokens';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { actAdvisorPreparation } from './fixture-advisor-preparation';
export type PhaseModuleEcazStep = { actor: string; action: Action };
export const phaseModuleEcazClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingTreacheryDiscard;
export function phaseModuleEcazPlayer(game: Game, actor: string) {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
}
export function phaseModuleEcazInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])]
.map(card => card.id).sort();
assert.deepEqual(cards, treacheryDeck(game.expansions).map(card => card.id).sort());
assert.equal(new Set(cards).size, cards.length);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0), player.faction === 'ixians' ? 7 : 5);
}
return cards;
}
export function actPhaseModuleEcaz(game: Game, actor: string, action: Action, actions: PhaseModuleEcazStep[] = []): Game {
return actAdvisorPreparation(game, actor, action, actions);
}
export function nextPhaseModuleEcazStep(game: Game): PhaseModuleEcazStep | null {
const next = nextRicheseStrongholdsNativeStep(game);
if (next?.action.type === 'stormDial' && game.turn === 1) next.action.amount = 0;
return next;
}
export function advancePhaseModuleEcaz(game: Game, until: (state: Game) => boolean, actions: PhaseModuleEcazStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextPhaseModuleEcazStep(game);
assert.ok(next, `Supply the actual plan or owned choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actPhaseModuleEcaz(game, next.actor, next.action, actions);
}
throw Error('The native selected phase-module coalition did not reach its bounded continuation');
}
/** CONTROLLED physical redistribution only; keep original wallets, phases,
* alliances, native printed subtypes, Tech owners and held-card receipts. */
function reserveBoard(game: Game) {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0); player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
}
}
function place(game: Game, actor: string, key: string, count: number, elite = 0) {
const player = phaseModuleEcazPlayer(game, actor);
assert.ok(player.reserves >= count && (player.elites?.reserves ?? 0) >= elite &&
player.reserves - (player.elites?.reserves ?? 0) >= count - elite);
player.reserves -= count; player.forces[key] = count;
if (elite) { player.elites!.reserves -= elite; player.elites!.forces[key] = elite; }
}
function orderBlow(game: Game, worm: boolean, position: number) {
const index = game.spiceDeck.findIndex((card, index) => index >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed : 'territory' in card && !card.discovery));
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
/** Actual public Ecaz/Ixians/Emperor setup selects all three family decks and
* Tech, plus Advanced Strongholds. A real worm Nexus forms the alliance; the
* real end-Mentat checkpoint earns Ecaz's Tabr card. Explicit conserved board
* and Shield staging is a later coalition supplement, not natural invasion or
* acquisition evidence. Never stage a wallet, phase, token owner or outcome. */
export function createPhaseModuleEcazFixture(options: { advanced?: boolean; lead?: 'ecaz' | 'ally' } = {}) {
const advanced = options.advanced ?? false, lead = options.lead ?? 'ecaz';
const ecaz = 'ecaz', ally = 'ixians', opponent = 'emperor', actor = lead === 'ecaz' ? ecaz : ally;
let game = createGame('PHASEMODULEECAZ', newPlayer(ecaz, 'Ecaz', 'ecaz'), advanced, ['ix', 'choam', 'ecaz']);
joinGame(game, newPlayer(ally, 'Ixians', 'ixians')); joinGame(game, newPlayer(opponent, 'Emperor', 'emperor'));
const actions: PhaseModuleEcazStep[] = [], initial = structuredClone(game);
const act = (owner: string, action: Action) => { game = actPhaseModuleEcaz(game, owner, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advancePhaseModuleEcaz(game, until, actions); };
act(ecaz, { type: 'techTokens', enabled: true });
if (advanced) act(ecaz, { type: 'strongholdCards', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const ready = structuredClone(game);
act(ecaz, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const started = structuredClone(game);
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
const originalInventory = phaseModuleEcazInventory(game);
orderBlow(game, false, 0); orderBlow(game, false, 1);
reach(state => state.turn === 1 && state.phase === 5 && phaseModuleEcazClean(state));
const afterFirstStorm = structuredClone(game);
reserveBoard(game); phaseModuleEcazInventory(game);
reach(state => state.turn === 2 && state.phase === 0 && phaseModuleEcazClean(state));
orderBlow(game, true, 0); orderBlow(game, false, 1); orderBlow(game, false, 2);
reach(state => state.phase === 1 && !!state.nexus && !state.spiceWindow && !state.spiceResolution && phaseModuleEcazClean(state));
const beforeAlliance = structuredClone(game);
act(ecaz, { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: ecaz });
const afterAlliance = structuredClone(game);
reach(state => state.turn === 2 && state.phase === 8 && phaseModuleEcazClean(state));
const kind = 'sietch_tabr', location = `${kind}:${territory(kind).sectors[0]}`;
reserveBoard(game); place(game, ecaz, location, 1); place(game, ally, location, 1);
phaseModuleEcazInventory(game);
let beforeMentat: Game | null = null, mentatStep: PhaseModuleEcazStep | null = null;
for (let count = 0; game.turn === 2 && count < 200; count++) {
const next = nextPhaseModuleEcazStep(game); assert.ok(next);
const before = structuredClone(game);
game = actPhaseModuleEcaz(game, next.actor, next.action, actions);
if (game.turn === 3) { beforeMentat = before; mentatStep = next; }
}
assert.ok(beforeMentat && mentatStep); const afterMentat = structuredClone(game);
if (advanced) { assert.equal(game.strongholdCards!.owners[kind], ecaz); assert.equal(game.strongholdCards!.claimedTurn, 2); }
orderBlow(game, false, 0); orderBlow(game, false, 1);
reach(state => state.turn === 3 && state.phase === 5 && phaseModuleEcazClean(state));
reserveBoard(game);
const ecazForces = advanced ? 5 : 4, allyForces = 6, allyElite = 3, opponentForces = 8;
place(game, ecaz, location, ecazForces); place(game, ally, location, allyForces, allyElite);
place(game, opponent, location, opponentForces);
const shield = stageEcazOccupyCard(game, actor, card => card.kind === 'shield');
phaseModuleEcazInventory(game);
reach(state => state.turn === 3 && state.phase === 6 && phaseModuleEcazClean(state));
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === kind); assert.ok(choice);
const battleStep: PhaseModuleEcazStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: kind,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep.actor, battleStep.action);
assert.ok(game.decision?.kind === 'ecazBattleLead'); const beforeLead = structuredClone(game);
const leadAction: Action = { type: 'decision', event: game.decision.event, lead: actor };
act(ecaz, leadAction);
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && phaseModuleEcazClean(state));
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
const winnerLeader = phaseModuleEcazPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => b.strength - a.strength)[0];
const loserLeader = phaseModuleEcazPlayer(game, opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(winnerLeader && loserLeader);
const planActions: PhaseModuleEcazStep[] = [
{ actor, action: { type: 'battlePlan', leader: winnerLeader.id, dial: profile.fixedEcazDial + 0.5,
support: 0, weapon: null, defense: shield } },
{ actor: opponent, action: { type: 'battlePlan', leader: loserLeader.id, dial: 1, support: 0, weapon: null, defense: null } },
];
const originalTech = structuredClone(game.techTokens!), enemyTokens = ownedTech(game.techTokens, opponent);
assert.equal(enemyTokens.length, 1);
return { game, rawGame: structuredClone(game), sourceGame: structuredClone(game), advanced, lead, actor,
ecaz, ally, opponent, kind, territory: kind, location, ecazForces, allyForces, allyElite, opponentForces,
initial, ready, started, afterSetup, afterFirstStorm, beforeAlliance, afterAlliance,
beforeMentat, mentatStep, afterMentat, beforeBattle, beforeLead, battleStep, leadAction, profile,
shield, winnerLeader: winnerLeader.id, loserLeader: loserLeader.id, planActions,
originalInventory, originalTech, enemyToken: enemyTokens[0], actions, publicActions: actions, sourceActionCount: actions.length,
staging: [
'Actual public Ecaz/Ixians/Emperor lobby selects ix+choam+ecaz decks and original Tech; Advanced additionally selects Stronghold Cards before readiness and explicit preview start. Actual private traitors, Ecaz six-force allocation and Ix starting-card/HMS choices complete setup. The existing original scalar lottery is selected throughout (Advanced Storm cards draw1); UUID and other byte entropy remain native.',
'CONTROLLED conserved positions follow the actual first Storm: return existing counters to their own normal/special reserves, retaining every wallet, phase, card and originally assigned Tech owner. Only remaining original Spice Cards are ordered to cause the actual turn-two worm Nexus; reciprocal alliance actions form Ecaz/Ixians.',
'CONTROLLED one-counter-per-native co-occupation of Tabr precedes the actual turn-two end-Mentat action. Advanced earns the original Tabr card for Ecaz through shared control; no Stronghold owner or claim turn is installed.',
'CONTROLLED later phase-five position conserves Ecaz4 Basic/5 Advanced, Ix3 Suboids+3 Cyborgs and opposing Emperor8 normals at Tabr. One original Shield is moved through real card custody to the selected lead. No shipment, native wallet, phase, Tech owner, plan or victory receipt is installed.',
'Actual chooseBattle, Ecaz lead choice and native preparation stop at unsealed plans. The original fixed Ecaz2/3 and variable0.5/support0 represent one actual Suboid casualty; original winner cleanup precedes the selected winner’s original enemy Tech award. This is a bounded controlled coalition supplement, not complete combination or mode acceptance.',
] };
}
export type PhaseModuleEcazFixture = ReturnType<typeof createPhaseModuleEcazFixture>;
export function quotePhaseModuleEcazBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const view = viewGame(game, game.host).battle!, profile = view.ecazOccupy!.profile!;
const side = (actor: string): ResolutionCombatant => {
const player = phaseModuleEcazPlayer(game, actor), own = viewGame(game, actor).battle!; assert.ok(own.ownForces);
return { id: actor, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand,
plan: battle.plans[actor], leader: player.leaders.find(leader => leader.id === battle.plans[actor].leader),
forces: own.ownForces, stronghold: own.strongholdEffects[actor] };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
ecazOccupy: profile, aggressor: view.aggressor, attacker: side(battle.attacker), defender: side(battle.defender),
voters: view.traitorVoters.map(actor => ({ id: actor, called: false, traitors: phaseModuleEcazPlayer(game, actor).traitors,
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : phaseModuleEcazPlayer(game, actor).ally! })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
/** Caller may replay these exact original steps through GameTable callbacks.
* Return the earned cleanup boundary as well as the completed battle. */
export function resolvePhaseModuleEcazFixture(fixture: PhaseModuleEcazFixture, state = structuredClone(fixture.game),
actions: PhaseModuleEcazStep[] = []) {
let game = state;
for (const step of fixture.planActions) game = actPhaseModuleEcaz(game, step.actor, step.action, actions);
const revealed = structuredClone(game), quote = quotePhaseModuleEcazBattle(game);
game = advancePhaseModuleEcaz(game, state => state.decision?.kind === 'battleCards' && phaseModuleEcazCleanExceptDecision(state), actions);
const beforeCleanup = structuredClone(game), cleanupAction: Action = { type: 'decision', discard: [] };
assert.equal(game.decision!.player, fixture.actor); assert.equal(game.techTokens![fixture.enemyToken].owner, fixture.opponent);
game = actPhaseModuleEcaz(game, fixture.actor, cleanupAction, actions);
const afterCleanup = structuredClone(game);
game = advancePhaseModuleEcaz(game, state => !state.battle && phaseModuleEcazClean(state), actions);
return { game, revealed, quote, beforeCleanup, cleanupAction, afterCleanup, actions };
}
function phaseModuleEcazCleanExceptDecision(game: Game) {
return !game.phaseOpening && !game.response && !game.pendingTreacheryDiscard;
}
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { createMixedPhaseModulesFixture } from './fixture-mixed-phase-modules';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const render = (game: Game, actor = game.host) => renderToStaticMarkup(createElement(GameTable, {
game: viewGame(game, actor), busy: false, send: async () => {}, onExit() {},
}));
// These are stateful React SSR renders and real public actions. They make no
// browser/DOM click claim; authoritative Start is exercised through applyAction.
void test('Basic paired E3 lobby exposes selected Tech and a usable original Begin game path', () => {
const f = createMixedPhaseModulesFixture({ factions: ['ecaz', 'moritani', 'guild'], expansions: ['ecaz'], tech: true });
const html = render(f.lobby);
assert.match(html, /Tech tokens · optional, 3\+ players/); assert.match(html, /Begin game/);
assert.match(html, /Stronghold Cards · Advanced rules required/);
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false);
assert.ok(f.actions.some(step => step.action.type === 'techTokens' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && step.action.advancedPreview === undefined));
assert.match(render(f.game), /Heighliners|Axlotl Tanks|Spice Production/);
});
void test('Advanced mixed selected decks retain public module controls, preview Start and the live Stronghold inspector', () => {
const f = createMixedPhaseModulesFixture({ factions: ['ixians', 'ecaz', 'moritani'], expansions: ['ix', 'ecaz'],
advanced: true, tech: true, strongholds: true });
const html = render(f.lobby);
assert.match(html, /Tech tokens · optional/); assert.match(html, /Stronghold Cards · Advanced rules required/);
assert.match(html, /Begin Advanced preview/);
assert.ok(f.actions.some(step => step.action.type === 'strongholdCards' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && step.action.advancedPreview === true));
const playing = render(f.game);
assert.match(playing, /Inspect Stronghold Cards/); assert.match(playing, /Heighliners/);
assert.match(playing, /Carthag|Sietch Tabr/);
});
void test('public module changes clear readiness and the two-player Tech lobby keeps Begin disabled', () => {
let game = createGame('MIXEDPHASELOBBYCONTROLS', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz']);
joinGame(game, newPlayer('moritani', 'Moritani', 'moritani'));
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
assert.ok(game.players.every(seat => !seat.ready));
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
const html = render(game);
const begin = [...html.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)]
.filter(([, , children]) => children.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() === 'Begin game');
assert.equal(begin.length, 1, 'The lobby exposes one Begin game button, including its decorative children');
assert.match(begin[0][1], /(?:^|\s)disabled=""(?:\s|$)/);
assert.throws(() => applyAction(game, game.host, { type: 'start' }));
});
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards';
import { createTechTokens, ownedTech } from '../game/tech-tokens';
import { MOBILE_STRONGHOLD } from '../game/board';
import { nativeFactionTechProfile } from '../game/faction-module-profile';
import {
createMixedPhaseModulesFixture, createMixedPhaseModulesProgramme, mixedPhaseModulesInventory,
mixedPhaseModulesPlayer as player, mixedPhaseModulesClean,
type MixedPhaseModulesOptions,
} from './fixture-mixed-phase-modules';
const entries: (MixedPhaseModulesOptions & { label: string })[] = [
{ label: 'Basic paired E3 Tech', factions: ['ecaz', 'moritani', 'guild'], expansions: ['ecaz'], tech: true },
{ label: 'Advanced E1 plus E3 Tech', factions: ['ixians', 'moritani', 'guild'], expansions: ['ix', 'ecaz'], advanced: true, tech: true },
{ label: 'Advanced E2 plus E3 Strongholds', factions: ['choam', 'ecaz', 'guild'], expansions: ['choam', 'ecaz'], advanced: true, strongholds: true },
{ label: 'Basic classic-only all-three-deck Tech', factions: ['atreides', 'emperor', 'guild'], expansions: ['ix', 'choam', 'ecaz'], tech: true },
{ label: 'Advanced two-seat classic-only selected E3 Strongholds', factions: ['atreides', 'emperor'], expansions: ['ecaz'], advanced: true, strongholds: true },
];
for (const options of entries) void test(`public ${options.label} starts native setup and completes its original first Storm`, () => {
const f = createMixedPhaseModulesFixture(options);
assert.equal(f.initial.status, 'lobby'); assert.equal(f.lobby.status, 'lobby');
assert.ok(f.lobby.players.every(seat => seat.ready));
assert.equal(f.setup.status, 'setup'); assert.equal(f.afterSetup.status, 'playing');
assert.equal(f.game.phase, 1); assert.equal(f.game.turn, 1); assert.equal(f.game.storm, 1);
assert.equal(f.game.advanced, !!options.advanced);
assert.deepEqual(f.game.expansions, options.expansions);
assert.equal(!!f.game.techTokens, !!options.tech); assert.equal(!!f.game.strongholdCards, !!options.strongholds);
assert.ok(!f.game.homeworlds && !f.game.leaderSkills && !f.game.nexusCards && !f.game.discoveryEnabled &&
!f.game.semutaPreview && !f.game.ecazTreachery);
assert.equal(f.originalInventory.length, treacheryDeck(options.expansions).length + Number(options.factions.includes('richese')) * 10);
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory);
if (options.tech) {
assert.deepEqual(f.setup.techTokens, createTechTokens(f.setup.players));
const owners = Object.values(f.game.techTokens!).map(token => token.owner);
assert.equal(new Set(owners).size, 3); assert.ok(owners.every(Boolean));
}
if (options.strongholds) assert.ok(Object.values(f.game.strongholdCards!.owners).every(owner => owner === null));
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === !!options.advanced));
assert.ok(f.actions.filter(step => step.action.type === 'stormDial').every(step => step.action.amount === 0));
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.actions.length);
});
for (const advanced of [false, true]) void test(`public all-six-native ${advanced ? 'Advanced Tech and Strongholds' : 'Basic Tech'} earns both token payouts, native claims and a real free return`, () => {
const f = createMixedPhaseModulesProgramme({ advanced });
assert.deepEqual(f.setup.players.map(seat => seat.faction), ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani']);
assert.equal(f.setup.techTokens?.heighliners.owner, 'ixians');
assert.equal(f.setup.techTokens?.axlotl.owner, 'tleilaxu');
assert.equal(player(f.choamShipment.before, 'choam').forces[f.choamLocation], undefined);
assert.equal(player(f.choamShipment.after, 'choam').forces[f.choamLocation], 1);
assert.equal(player(f.choamShipment.before, 'choam').spice - player(f.choamShipment.after, 'choam').spice, 1);
assert.equal(player(f.moritaniShipment.after, 'moritani').forces[f.casualtyLocation], 1);
assert.equal(player(f.moritaniShipment.before, 'moritani').spice - player(f.moritaniShipment.after, 'moritani').spice, 2);
for (const shipment of [f.choamShipment, f.moritaniShipment]) {
assert.equal(shipment.steps[0].action.type, 'ship'); assert.equal(shipment.steps[0].action.amount, 1);
assert.equal(shipment.after.techTokens?.heighliners.spice, 1);
assert.equal(shipment.after.techTokens?.heighliners.triggeredTurn, 1);
assert.equal(player(shipment.after, 'ixians').spice, player(shipment.before, 'ixians').spice);
}
assert.equal(f.shipmentPayout.before.phase, 5); assert.equal(f.shipmentPayout.after.phase, 6);
assert.equal(f.shipmentPayout.after.techTokens?.heighliners.spice, 0);
assert.equal(player(f.shipmentPayout.after, 'ixians').spice - player(f.shipmentPayout.before, 'ixians').spice, 1);
if (advanced) {
assert.equal(f.mentat.before.strongholdCards?.claimedTurn, 0);
assert.equal(f.mentat.after.strongholdCards?.claimedTurn, 1);
assert.equal(f.mentat.after.strongholdCards?.owners.carthag, 'moritani');
assert.equal(f.mentat.after.strongholdCards?.owners.sietch_tabr, 'choam');
assert.equal(f.mentat.after.strongholdCards?.owners[MOBILE_STRONGHOLD], 'ixians');
} else assert.equal(f.mentat.after.strongholdCards, undefined);
assert.equal(f.storm.before.stormMovementSource?.kind, advanced ? 'card' : 'dials'); assert.equal(f.storm.before.stormPending, advanced ? 1 : 2);
assert.equal(player(f.storm.before, 'moritani').tanks, 0);
assert.equal(player(f.storm.after, 'moritani').tanks, 1);
assert.equal(player(f.storm.after, 'moritani').forces[f.casualtyLocation], undefined);
assert.deepEqual(f.revival.steps[0], { actor: 'moritani', action: { type: 'revive', amount: 1, elite: 0 } });
if (advanced) assert.equal(f.revival.before.revivalOrder?.remaining[0], 'moritani');
assert.equal(player(f.revival.after, 'moritani').tanks, 0); assert.equal(player(f.revival.after, 'moritani').revived, 1);
assert.equal(player(f.revival.after, 'moritani').reserves - player(f.revival.before, 'moritani').reserves, 1);
assert.equal(player(f.revival.after, 'moritani').spice, player(f.revival.before, 'moritani').spice);
assert.equal(player(f.revival.after, 'tleilaxu').spice - player(f.revival.before, 'tleilaxu').spice, 1);
assert.equal(f.revival.after.techTokens?.axlotl.spice, 1);
assert.equal(f.revival.after.techTokens?.axlotl.triggeredTurn, 2);
assert.equal(f.revivalPayout.after.techTokens?.axlotl.spice, 0);
assert.equal(player(f.revivalPayout.after, 'tleilaxu').spice - player(f.revivalPayout.before, 'tleilaxu').spice, 1);
assert.equal(ownedTech(f.game.techTokens, 'tleilaxu').length, 1);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.ok(mixedPhaseModulesClean(f.game));
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory); assert.deepEqual(f.staging, []);
// A minimal own-view policy path, without a strategic simulation campaign.
const actor = f.game.active; assert.ok(actor);
const view = viewGame(f.game, actor); view.players.find(seat => seat.id === actor)!.bot = 'Easy';
const legal = botActions(view).find(action => action.type === 'endMovement'); assert.ok(legal);
assert.doesNotThrow(() => applyAction(f.game, actor, legal));
});
function lobby(count: number, advanced: boolean): Game {
const seats = ['ecaz', 'moritani', 'guild'] as const;
const game = createGame('MIXEDPHASEINVALID', newPlayer(seats[0], seats[0], seats[0]), advanced, ['ecaz']);
for (const faction of seats.slice(1, count)) joinGame(game, newPlayer(faction, faction, faction));
return game;
}
function rejected(game: Game, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, game.host, action)); assert.deepEqual(game, before);
}
void test('public two-seat Tech and Basic Strongholds are rejected while registered overlays stay outside the new phase-only profile', () => {
let two = applyAction(lobby(2, false), 'ecaz', { type: 'techTokens', enabled: true });
for (const seat of two.players) two = applyAction(two, seat.id, { type: 'ready' });
rejected(two, { type: 'start' });
rejected(lobby(3, false), { type: 'strongholdCards', enabled: true });
let overlay = applyAction(lobby(3, true), 'ecaz', { type: 'techTokens', enabled: true });
overlay = applyAction(overlay, 'ecaz', { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const seat of overlay.players) overlay = applyAction(overlay, seat.id, { type: 'ready' });
// This existing independent-card start has its own registered composition.
// Its permission must not be attributed to the new phase-only profile.
const started = applyAction(overlay, overlay.host, { type: 'start', advancedPreview: true });
assert.equal(started.status, 'setup'); assert.equal(started.ecazTreachery, true);
assert.equal(nativeFactionTechProfile(started), false);
assert.equal(nativeFactionTechProfile({ ...started, ecazTreachery: false }), true);
});
+1 -2
View File
@@ -85,10 +85,9 @@ void test('native Tech seats three through six complete actual Basic/Advanced se
}
});
void test('native Tech rejects below-three, missing-family, wrong-deck and already-owned inventory without dealing', () => {
void test('native Tech rejects below-three, wrong-deck and already-owned inventory without dealing', () => {
const invalid = [
lobby(['ixians', 'guild']),
lobby(['emperor', 'fremen', 'guild']),
lobby(['ixians', 'emperor', 'guild'], ['choam']),
];
const owned = lobby(['ixians', 'emperor', 'guild']);
+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ownedTech } from '../game/tech-tokens';
import { strongholdBenefit } from '../game/stronghold-cards';
import {
createPhaseModuleEcazFixture as fixture, resolvePhaseModuleEcazFixture as resolve,
phaseModuleEcazInventory as inventory, phaseModuleEcazPlayer as player,
type PhaseModuleEcazFixture,
} from './fixture-phase-module-ecaz';
function source(f: PhaseModuleEcazFixture) {
assert.equal(f.initial.status, 'lobby'); assert.equal(f.ready.status, 'lobby'); assert.equal(f.started.status, 'setup');
assert.equal(f.afterSetup.status, 'playing'); assert.equal(f.game.status, 'playing');
assert.equal(f.game.turn, 3); assert.equal(f.game.phase, 6); assert.equal(f.game.advanced, f.advanced);
assert.deepEqual(f.game.expansions, ['ix', 'choam', 'ecaz']);
assert.deepEqual(f.game.players.map(p => p.faction), ['ecaz', 'ixians', 'emperor']);
assert.ok(f.game.techTokens); assert.equal(!!f.game.strongholdCards, f.advanced);
assert.ok(!f.game.leaderSkills && !f.game.homeworlds && !f.game.nexusCards && !f.game.discoveryEnabled && !f.game.ecazTreachery && !f.game.semutaPreview);
assert.ok(f.actions.some(step => step.action.type === 'techTokens' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && (!f.advanced || step.action.advancedPreview === true)));
assert.equal(f.beforeAlliance.turn, 2); assert.equal(f.beforeAlliance.phase, 1); assert.equal(f.beforeAlliance.nexus, true);
assert.ok(f.beforeAlliance.spiceDiscard.flat().some(card => 'worm' in card && !card.suppressed));
assert.equal(player(f.afterAlliance, f.ecaz).ally, f.ally); assert.equal(player(f.afterAlliance, f.ally).ally, f.ecaz);
assert.ok(f.actions.some(step => step.actor === f.ecaz && step.action.type === 'alliance' && step.action.target === f.ally));
assert.ok(f.actions.some(step => step.actor === f.ally && step.action.type === 'alliance' && step.action.target === f.ecaz));
assert.equal(f.beforeMentat.turn, 2); assert.equal(f.beforeMentat.phase, 8); assert.equal(f.afterMentat.turn, 3);
assert.equal(f.mentatStep.action.type, 'ready');
if (f.advanced) {
assert.equal(f.afterMentat.strongholdCards!.owners.sietch_tabr, f.ecaz);
assert.equal(f.afterMentat.strongholdCards!.claimedTurn, 2);
assert.equal(strongholdBenefit(f.game.strongholdCards, f.ecaz, f.territory), f.territory);
assert.equal(strongholdBenefit(f.game.strongholdCards, f.ally, f.territory), null);
}
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead'); assert.equal(f.beforeLead.decision?.player, f.ecaz);
assert.equal(f.game.battle!.revealed, false); assert.equal(f.game.decision ?? null, null); assert.equal(f.game.response ?? null, null);
assert.equal(f.profile.ecaz, f.ecaz); assert.equal(f.profile.ally, f.ally); assert.equal(f.profile.planOwner, f.actor);
assert.equal(f.profile.payer, f.actor); assert.equal(f.profile.forceOwner, f.ally); assert.equal(f.profile.canceled, false);
assert.equal(f.profile.fixedEcazDial, Math.ceil(f.ecazForces / 2));
assert.equal(f.profile.ecazForces.normal, f.ecazForces);
assert.equal(f.profile.allyForces.normal, 3); assert.equal(f.profile.allyForces.elite, 3);
assert.equal(f.profile.allyForces.normalFixedHalf, true); assert.equal(f.profile.allyForces.eliteStrength, 2);
assert.equal(player(f.game, f.ecaz).forces[f.location], f.ecazForces);
assert.equal(player(f.game, f.ally).forces[f.location], 6); assert.equal(player(f.game, f.ally).elites!.forces[f.location], 3);
assert.equal(player(f.game, f.opponent).forces[f.location], 8);
assert.equal(ownedTech(f.afterFirstStorm.techTokens, f.ecaz).length, 1);
assert.equal(ownedTech(f.afterFirstStorm.techTokens, f.ally).length, 1);
assert.equal(ownedTech(f.afterFirstStorm.techTokens, f.opponent).length, 1);
assert.deepEqual(f.game.techTokens, f.afterFirstStorm.techTokens);
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.shield));
assert.equal(f.sourceActionCount, f.actions.length); assert.match(f.staging[1], /CONTROLLED/); assert.match(f.staging[2], /CONTROLLED/);
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.originalInventory.length, 47);
}
for (const advanced of [false, true]) for (const lead of ['ecaz', 'ally'] as const) {
void test(`public ${advanced ? 'Advanced Stronghold+Tech' : 'Basic Tech'} all-three-deck coalition: ${lead} lead retains original native losses, holder benefit and winner Tech`, () => {
const f = fixture({ advanced, lead }); source(f);
const before = structuredClone(f.game), result = resolve(f), { quote, revealed, beforeCleanup, game } = result;
assert.deepEqual(f.game, before);
assert.equal(quote.winner, f.actor); assert.equal(beforeCleanup.lastBattleContext!.winner, f.actor);
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: Math.ceil(f.ecazForces / 2), elite: 0 }]);
assert.equal(quote.casualties!.owner, f.ally);
assert.deepEqual(quote.casualties!.options, [{ normal: 1, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.ok(quote.payments.every(payment => payment.ownPayment === 0 && payment.bankSupport === 0));
const income = advanced && lead === 'ecaz' ? 1 : 0;
assert.deepEqual(quote.strongholdIncome, income ? [{ player: f.actor, amount: income }] : []);
assert.equal(player(beforeCleanup, f.actor).spice, player(revealed, f.actor).spice + income);
const nonLead = f.actor === f.ecaz ? f.ally : f.ecaz;
assert.equal(player(beforeCleanup, nonLead).spice, player(revealed, nonLead).spice);
assert.equal(player(beforeCleanup, f.opponent).spice, player(revealed, f.opponent).spice);
assert.equal(beforeCleanup.decision?.kind, 'battleCards'); assert.equal(beforeCleanup.decision?.player, f.actor);
assert.equal(beforeCleanup.techTokens![f.enemyToken].owner, f.opponent, 'Original winner-card cleanup precedes the original enemy token transfer');
assert.equal(player(beforeCleanup, f.ecaz).tanks - player(f.game, f.ecaz).tanks, Math.ceil(f.ecazForces / 2));
assert.equal(player(beforeCleanup, f.ally).tanks - player(f.game, f.ally).tanks, 1);
assert.equal(player(beforeCleanup, f.ally).elites!.tanks, player(f.game, f.ally).elites!.tanks);
assert.equal(player(beforeCleanup, f.opponent).tanks - player(f.game, f.opponent).tanks, 8);
assert.equal(player(game, f.ecaz).forces[f.location], 2);
assert.equal(player(game, f.ally).forces[f.location], 5); assert.equal(player(game, f.ally).elites!.forces[f.location], 3);
assert.equal(player(game, f.opponent).forces[f.location] ?? 0, 0);
assert.equal(game.lastBattleContext!.winner, f.actor); assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(game.techTokens![f.enemyToken].owner, f.actor);
assert.equal(ownedTech(game.techTokens, f.actor).length, 2); assert.equal(ownedTech(game.techTokens, nonLead).length, 1);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
assert.ok(player(game, f.actor).hand.some(card => card.id === f.shield));
assert.equal(player(game, f.actor).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
if (advanced) assert.equal(game.strongholdCards!.owners.sietch_tabr, f.ecaz, 'Held-card custody is not transferred to the selected ally lead');
assert.ok(result.actions.some(step => step.actor === f.actor && step.action.type === 'decision' && Array.isArray(step.action.discard)));
assert.equal(result.actions.filter(step => step.action.type === 'battlePlan').length, 2);
assert.ok(result.actions.some(step => step.action.type === 'traitorCall' && step.action.call === false));
assert.equal(game.battle ?? null, null); assert.equal(game.decision ?? null, null);
assert.deepEqual(inventory(game), f.originalInventory);
});
}
+118
View File
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
import { STRONGHOLD_CARDS } from '../game/stronghold-cards';
import {
actAssassinateTarget as act, advanceAssassinateTarget as advance,
assassinateTargetInventory as inventory, assassinateTargetPlayer as player,
assassinateTargetTraitors as traitors, createMoritaniAssassinateTargetFixture as fixture,
finishMoritaniAssassinateTarget as finish, type AssassinateTargetStep,
} from './fixture-moritani-assassinate-target';
const cases = [
{ label: 'Tech', techTokens: true, strongholds: false, execute: false },
{ label: 'Strongholds', techTokens: false, strongholds: true, execute: false },
{ label: 'Tech and Strongholds', techTokens: true, strongholds: true, execute: false },
{ label: 'Tech and Strongholds with actual Execute', techTokens: true, strongholds: true, execute: true },
] as const;
for (const selected of cases) {
void test(`native Advanced Harkonnen/Moritani ${selected.label} keeps original capture, assassination and phase-module rewards`, () => {
const f = fixture(selected);
assert.equal(f.initial.status, 'lobby');
assert.equal(!!f.initial.techTokens, false); assert.equal(!!f.initial.strongholdCards, false);
assert.equal(f.moduleSelections.length, Number(selected.techTokens) + Number(selected.strongholds));
for (const selection of f.moduleSelections) {
assert.equal(selection.before.status, 'lobby'); assert.equal(selection.after.status, 'lobby');
assert.ok(selection.before.players.every(seat => !seat.hand.length && !seat.ready));
assert.equal(selection.steps.length, 1); assert.equal(selection.steps[0].actor, f.initial.host);
assert.ok(['techTokens', 'strongholdCards'].includes(selection.steps[0].action.type));
assert.equal(selection.steps[0].action.enabled, true);
}
assert.ok(f.actions.some(step => step.action.type === 'start' && step.action.advancedPreview === true));
assert.equal(f.setup.status, 'setup'); assert.ok(f.setup.moritaniAssassinate);
assert.deepEqual(f.setup.players.map(seat => seat.faction), ['harkonnen', 'emperor', 'moritani']);
assert.equal(!!f.setup.techTokens, selected.techTokens);
assert.equal(!!f.setup.strongholdCards, selected.strongholds);
assert.ok(!f.game.homeworlds && !f.game.leaderSkills && !f.game.nexusCards &&
!f.game.discoveryEnabled && !f.game.semutaPreview && !f.game.ecazTreachery);
assert.equal(f.originalInventory.length, 33);
assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(traitors(f.game), f.originalTraitors);
assert.equal(f.sourceActionCount, f.actions.length);
assert.equal(f.shipment.steps[0].actor, f.opponent);
assert.equal(f.shipment.steps[0].action.type, 'ship');
assert.equal(player(f.shipment.before, f.opponent).spice - player(f.shipment.after, f.opponent).spice, 2);
assert.equal(player(f.movement.after, f.opponent).forces['carthag:11'], 1);
assert.equal(player(f.movement.after, f.opponent).forces['imperial_basin:11'], 1);
assert.equal(f.beforeCaptureChoice.decision?.kind, 'capturedLeader');
assert.ok(f.capture.steps.some(step => step.action.type === 'decision' && step.action.accept === true));
assert.equal(f.captureChoiceStep.action.mode, selected.execute ? 'execute' : 'keep');
const captured = player(f.afterCapture, f.opponent).leaders.find(disc => disc.id === f.target)!;
assert.ok(captured.concealed);
assert.equal(captured.dead, selected.execute);
assert.equal(captured.capturedBy, selected.execute ? undefined : f.captor);
const emperorToken = selected.techTokens ? ownedTech(f.beforeFirstBattle.techTokens, f.opponent) : [];
const moritaniToken = selected.techTokens ? ownedTech(f.beforeSecondBattle.techTokens, f.owner) : [];
if (selected.techTokens) {
assert.equal(emperorToken.length, 1); assert.equal(moritaniToken.length, 1);
assert.equal(new Set(TECH_TOKENS.map(token => f.beforeFirstBattle.techTokens![token.id].owner)).size, 3,
'The real first Storm assigns one original token to each original seat');
assert.equal(f.afterCapture.techTokens![emperorToken[0]].owner, f.captor,
'The first real Harkonnen win earns the Emperor token through original cleanup');
assert.equal(f.game.techTokens![moritaniToken[0]].owner, f.owner,
'The later winner reward waits for this actual assassination opportunity');
}
if (selected.strongholds) {
assert.equal(f.setup.strongholdCards?.claimedTurn, 0);
assert.equal(Object.keys(f.setup.strongholdCards!.owners).length, STRONGHOLD_CARDS.length);
assert.ok(Object.values(f.game.strongholdCards!.owners).every(owner => owner === null),
'No Stronghold advantage is assigned before the first actual END Mentat');
}
const pending = viewGame(f.game, f.owner).moritaniAssassinate?.pending; assert.ok(pending);
assert.ok(pending.cards.some(choice => choice.card === f.target));
assert.equal(f.receipt.opposingLeader, 'emperor-0'); assert.notEqual(f.target, f.receipt.opposingLeader);
const targetBefore = structuredClone(player(f.game, f.opponent).leaders.find(disc => disc.id === f.target)!);
const money = player(f.game, f.owner).spice;
let game = act(f.game, f.owner, f.reveal);
const killed = player(game, f.opponent).leaders.find(disc => disc.id === f.target)!;
assert.equal(killed.dead, true);
assert.equal(killed.deaths, targetBefore.deaths + Number(!selected.execute));
assert.equal(killed.capturedBy, undefined); assert.equal(killed.concealed, undefined);
assert.equal(killed.faction, 'emperor'); assert.equal(killed.strength, targetBefore.strength);
assert.equal(killed.controller, targetBefore.controller); assert.equal(killed.gholaBy, targetBefore.gholaBy);
assert.equal(player(game, f.owner).spice, money + (selected.execute ? 0 : f.potentialBounty));
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.bounty, selected.execute ? 0 : f.potentialBounty);
const continuation: AssassinateTargetStep[] = [];
game = finish(game, f, continuation);
const receipt = game.moritaniAssassinate!.opportunities.find(r => r.event === f.event)!;
assert.equal(receipt.stage, 'replaced'); assert.ok(receipt.replacement && receipt.replacement !== f.target);
assert.equal(player(game, f.owner).traitors.includes(f.target), false);
assert.ok(player(game, f.owner).traitors.includes(receipt.replacement));
assert.deepEqual(traitors(game), f.originalTraitors); assert.deepEqual(inventory(game), f.originalInventory);
if (selected.techTokens) {
assert.equal(game.techTokens![emperorToken[0]].owner, f.captor);
assert.equal(game.techTokens![moritaniToken[0]].owner, f.opponent,
'The surviving native Emperor winner receives the original Moritani token once');
assert.equal(ownedTech(game.techTokens, f.captor).length, 2);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 1);
assert.equal(ownedTech(game.techTokens, f.owner).length, 0);
}
game = advance(game, state => state.turn === 2 && state.phase === 0, continuation);
if (selected.strongholds) {
assert.equal(game.strongholdCards!.claimedTurn, 1);
assert.equal(game.strongholdCards!.owners.carthag, f.captor,
'The actual first END Mentat awards the city still physically held after the native capture');
assert.ok(Object.entries(game.strongholdCards!.owners).every(([id, owner]) => id === 'carthag' || owner === null));
}
assert.ok(continuation.some(step => step.action.type === 'ready'));
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors);
const finalTarget = player(game, f.opponent).leaders.find(disc => disc.id === f.target)!;
assert.equal(finalTarget.deaths, killed.deaths);
assert.equal(finalTarget.capturedBy, undefined); assert.equal(finalTarget.concealed, undefined);
});
}
+130
View File
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
nativeFactionTechProfile as tech, selectedFactionStrongholdProfile as strongholds,
selectedPhaseModuleRoster as roster, type FactionModuleProfile,
} from '../game/faction-module-profile';
import { createStrongholdCards } from '../game/stronghold-cards';
import { createTechTokens } from '../game/tech-tokens';
function profile(factions: readonly string[] = ['ecaz', 'moritani', 'guild'],
expansions: readonly string[] = ['ecaz'], extra: Partial<FactionModuleProfile> = {}): FactionModuleProfile {
return { advanced: true, expansions, players: factions.map(faction => ({ faction })),
techTokens: createTechTokens(), ...extra };
}
void test('selected phase rosters admit paired E3, mixed families and all six natives without changing selected-deck order', () => {
for (const game of [
profile(), profile(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix']),
profile(['moritani', 'choam', 'guild'], ['choam', 'ecaz']),
profile(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz']),
]) {
const before = structuredClone(game);
assert.equal(roster(game), true); assert.equal(tech(game), true); assert.equal(strongholds(game), true);
assert.deepEqual(game, before);
}
});
void test('classic-only selected-deck rosters and unused selected families are valid while no-expansion base admission stays separate', () => {
for (const expansions of [['ix'], ['choam'], ['ecaz'], ['ix', 'choam', 'ecaz']]) {
const classic = profile(['atreides', 'emperor', 'guild'], expansions);
assert.equal(roster(classic), true); assert.equal(tech(classic), true); assert.equal(strongholds(classic), true);
}
const unused = profile(['ixians', 'guild', 'emperor'], ['ecaz', 'choam', 'ix']);
assert.equal(roster(unused), true); assert.equal(tech(unused), true); assert.equal(strongholds(unused), true);
const base = profile(['atreides', 'emperor', 'guild'], []);
assert.equal(roster(base), false); assert.equal(tech(base), false); assert.equal(strongholds(base), false);
});
void test('unknown, missing and duplicate selected families or seated identities cannot grant phase modules', () => {
for (const game of [
profile(['ixians', 'guild', 'emperor'], ['choam']),
profile(['ecaz', 'guild', 'emperor'], ['ix', 'choam']),
profile(['ixians', 'guild', 'emperor'], ['ix', 'ix']),
profile(['ecaz', 'guild', 'emperor'], ['ecaz', 'unknown']),
profile(['ixians', 'guild', 'emperor'], ['ix', 'choam', 'ecaz', 'ix']),
profile(['unknown', 'guild', 'emperor']), profile(['ecaz', 'ecaz', 'guild']),
]) {
assert.equal(roster(game), false); assert.equal(tech(game), false); assert.equal(strongholds(game), false);
}
for (const value of [null, {}, { expansions: 'ix', players: [{ faction: 'ixians' }, { faction: 'guild' }] },
{ expansions: ['ix'], players: [{ faction: 'ixians' }, null] }])
assert.equal(roster(value as unknown as Pick<FactionModuleProfile, 'expansions' | 'players'>), false);
});
void test('the roster admits two through six seats while Tech independently requires at least three', () => {
const seats = ['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'];
for (let count = 2; count <= 6; count++) {
const game = profile(seats.slice(0, count), ['ix', 'choam', 'ecaz']);
assert.equal(roster(game), true); assert.equal(tech(game), count >= 3);
assert.equal(strongholds({ ...game, techTokens: null }), true);
assert.equal(strongholds(game), count >= 3);
}
for (const game of [profile(['ecaz']), profile([...seats, 'guild'], ['ix', 'choam', 'ecaz'])]) {
assert.equal(roster(game), false); assert.equal(tech(game), false); assert.equal(strongholds(game), false);
}
assert.equal(tech({ ...profile(), techTokens: null }), false);
});
void test('Basic Tech remains available but Strongholds require Advanced and may start with absent or unused card state', () => {
const basic = profile(undefined, undefined, { advanced: false });
assert.equal(tech(basic), true); assert.equal(strongholds(basic), false);
assert.equal(tech({ ...basic, strongholdCards: createStrongholdCards() }), false);
const advanced = profile();
assert.equal(strongholds(advanced), true);
assert.equal(strongholds({ ...advanced, strongholdCards: createStrongholdCards() }), true);
assert.equal(tech({ ...advanced, strongholdCards: createStrongholdCards() }), true);
assert.equal(tech({ ...advanced, advanced: 'advanced' as unknown as boolean }), false);
assert.equal(strongholds({ ...advanced, advanced: 'advanced' as unknown as boolean }), false);
});
void test('phase permission wrappers reject unrelated modules and preview selectors while roster validity stays independent', () => {
for (const field of [
'leaderSkills', 'homeworlds', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
'ecazTreachery', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview',
] as const) {
const game = { ...profile(), [field]: true };
assert.equal(roster(game), true); assert.equal(tech(game), false, field); assert.equal(strongholds(game), false, field);
}
});
void test('canonical Ecaz and Moritani runtime stamps and the supported Advanced Harkonnen roster do not block new phase admission', () => {
const game = profile(['ecaz', 'moritani', 'harkonnen'], ['ecaz'], {
ecazOccupyPreview: true, moritaniAssassinatePreview: true, strongholdCards: createStrongholdCards(),
});
assert.equal(roster(game), true); assert.equal(tech(game), true); assert.equal(strongholds(game), true);
assert.equal(tech(game, true), false, 'Explicit Semuta keeps its earlier Advanced Moritani/Harkonnen boundary');
});
void test('explicit Semuta preserves its original E1/E2 and exactly-one-E3-native positive envelope', () => {
for (const game of [
profile(['ixians', 'guild', 'emperor'], ['ix']),
profile(['richese', 'choam', 'guild'], ['choam']),
profile(['ixians', 'guild', 'emperor'], ['ix', 'choam']),
profile(['ecaz', 'guild', 'emperor'], ['ecaz']),
profile(['moritani', 'guild', 'emperor'], ['ecaz']),
profile(['moritani', 'harkonnen', 'guild'], ['ecaz'], { advanced: false }),
]) {
assert.equal(tech(game, true), true);
const stamped = { ...game, semutaPreview: true };
assert.equal(tech(stamped), false); assert.equal(tech(stamped, true), true);
assert.equal(strongholds(stamped), false);
assert.equal(tech({ ...stamped, semutaPreview: 'foreign-stamp' }, true), false);
}
});
void test('explicit Semuta cannot inherit paired, mixed, all-three-family, classic-only or Advanced Moritani/Harkonnen admission', () => {
for (const game of [
profile(), profile(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix']),
profile(['moritani', 'choam', 'guild'], ['ecaz', 'choam']),
profile(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz']),
profile(['ixians', 'guild', 'emperor'], ['ix', 'choam', 'ecaz']),
profile(['atreides', 'emperor', 'guild'], ['choam']),
profile(['moritani', 'harkonnen', 'guild'], ['ecaz']),
]) {
const before = structuredClone(game);
assert.equal(tech(game), true); assert.equal(tech(game, true), false);
assert.equal(tech({ ...game, semutaPreview: true }, true), false); assert.deepEqual(game, before);
}
});
+10
View File
@@ -37,6 +37,16 @@ for (const advanced of [false, true]) {
state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0,
leader: player(state, 'e').leaders[0].id, defense: f.defense });
for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'traitorCall', call: false });
// Traitor votes now open the original signed final-action opportunity;
// both combatants must finish it before mandatory disposal can be offered.
let acknowledgements = 0;
while (state.battle?.outcomeWindow && !state.battle.outcomeWindow.closed) {
const window = state.battle.outcomeWindow;
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); assert.ok(actor);
state = applyAction(state, actor, { type: 'battleOutcomeReady', event: window.event });
acknowledgements++;
}
assert.equal(acknowledgements, 2);
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const winner = applyAction(pass(reload(state)), 'a', { type: 'decision', discard: [f.weapon] });
assert.equal(winner.pendingTreacheryDiscard?.reaction?.stage, 'offer');