Compose independent Ecaz cards with selected phase modules

This commit is contained in:
Quintine committed 2026-10-26 11:18:50 +11:00
1 parent 68a7870e2e
commit 0a98a92f4b
17 files changed
+733 -15

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD, defaultHarassWithdrawAllocation } from '../game/harass-withdraw';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import {
createPhaseModuleEcazFixture, actPhaseModuleEcaz as act, advancePhaseModuleEcaz as advance,
phaseModuleEcazClean as clean, phaseModuleEcazInventory as inventory, quotePhaseModuleEcazBattle,
type PhaseModuleEcazStep,
} from './fixture-phase-module-ecaz';
/** Controlled physical card/army supplement after real public setup, alliance
* and earned card. Every original wallet/phase/token owner remains native. */
export function createIndependentPhaseEcazFixture(options: { advanced?: boolean; lead?: 'ecaz' | 'ally' } = {}) {
const f = createPhaseModuleEcazFixture({ ...options, independent: true });
const game = structuredClone(f.game);
stageEcazOccupyCard(game, f.actor, card => card.id === REINFORCEMENTS_CARD);
stageEcazOccupyCard(game, f.actor, card => card.id === HARASS_WITHDRAW_CARD);
const planActions = f.planActions.map(step => step.actor === f.actor
? { actor: step.actor, action: { ...step.action, weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } } : step);
assert.deepEqual(inventory(game), f.originalInventory);
return { ...f, game, rawGame: structuredClone(game), planActions,
staging: [...f.staging,
'CONTROLLED conserved original Reinforcements and Harass & Withdraw custody replaces the selected plan slots; the unused Shield remains held. Neither special was acquired through a claimed native purchase. No fee, return, card disposal or token transfer is installed.'] };
}
export type IndependentPhaseEcazFixture = ReturnType<typeof createIndependentPhaseEcazFixture>;
export function resolveIndependentPhaseEcaz(f: IndependentPhaseEcazFixture, state: Game = structuredClone(f.game)) {
const actions: PhaseModuleEcazStep[] = [];
let game = state;
for (const step of f.planActions) game = act(game, step.actor, step.action, actions);
assert.ok(game.battle?.revealed);
while (game.decision?.kind === 'harassWithdraw') {
const decision = game.decision;
const offer = viewGame(game, decision.player).battle!.harassAllocation; assert.ok(offer);
const returns = defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support);
game = act(game, decision.player, { type: 'decision', event: decision.event, returns }, actions);
}
const revealed = structuredClone(game), quote = quotePhaseModuleEcazBattle(game);
game = advance(game, state => !state.battle && clean(state), actions);
return { game, revealed, quote, actions };
}
+6 -3
View File
@@ -13,6 +13,7 @@ export type MixedPhaseModulesOptions = {
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
independent?: boolean;
};
export const MIXED_PHASE_NATIVE_FACTIONS = ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const;
export const actMixedPhaseModules = actAdvisorPreparation;
@@ -74,6 +75,7 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
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.independent) act(host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
if (options.tech) act(host, { type: 'techTokens', enabled: true });
if (options.strongholds) act(host, { type: 'strongholdCards', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
@@ -95,7 +97,8 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
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,
return { game, sourceGame: structuredClone(game), independent: options.independent ?? false,
initial, lobby, setup, afterSetup, firstStorm,
originalInventory, originalSpice, spiceOrder, actions, sourceActions: structuredClone(actions),
sourceActionCount: actions.length, staging: [] as string[] };
}
@@ -104,10 +107,10 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
* 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 } = {}) {
export function createMixedPhaseModulesProgramme(options: { advanced?: boolean; strongholds?: boolean; independent?: 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 });
expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds, independent: options.independent });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
+16 -3
View File
@@ -7,6 +7,10 @@ import { ownedTech } from '../game/tech-tokens';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { actAdvisorPreparation } from './fixture-advisor-preparation';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { usesNativeBattleSpecial } from '../game/battle-card-slots';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
export type PhaseModuleEcazStep = { actor: string; action: Action };
export const phaseModuleEcazClean = (game: Game) => !game.phaseOpening && !game.response &&
@@ -19,7 +23,8 @@ export function phaseModuleEcazInventory(game: Game) {
...(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.deepEqual(cards, [...treacheryDeck(game.expansions), ...(game.ecazTreachery ? ecazTreacheryCards() : [])]
.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);
@@ -75,7 +80,7 @@ function orderBlow(game: Game, worm: boolean, position: number) {
* 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' } = {}) {
export function createPhaseModuleEcazFixture(options: { advanced?: boolean; lead?: 'ecaz' | 'ally'; independent?: boolean } = {}) {
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']);
@@ -85,6 +90,7 @@ export function createPhaseModuleEcazFixture(options: { advanced?: boolean; lead
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 });
if (options.independent) act(ecaz, { type: 'moduleOption', module: 'ecazTreachery', 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' });
@@ -167,7 +173,14 @@ export function quotePhaseModuleEcazBattle(game: Game) {
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] };
forces: own.ownForces, stronghold: own.strongholdEffects[actor],
...(usesNativeBattleSpecial(battle.plans[actor], REINFORCEMENTS_CARD)
? { reinforcementsReserves: { normal: player.reserves - (player.elites?.reserves ?? 0),
elite: player.elites?.reserves ?? 0 } } : {}),
...(usesNativeBattleSpecial(battle.plans[actor], HARASS_WITHDRAW_CARD) && own.harassWithdraw
? { harassWithdraw: { ...own.harassWithdraw, blocked: null },
...(battle.harassAllocation?.player === actor && battle.harassAllocation.stage === 'selected' && battle.harassAllocation.selection
? { harassSelection: battle.harassAllocation.selection } : {}) } : {}) };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
ecazOccupy: profile, aggressor: view.aggressor, attacker: side(battle.attacker), defender: side(battle.defender),
+141
View File
@@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES, location } from '../game/board';
import { RECRUITS_CARD_ID, recruitsPlayAction } from '../game/recruits';
import {
createMixedPhaseModulesFixture, MIXED_PHASE_NATIVE_FACTIONS,
actMixedPhaseModules, mixedPhaseModulesClean, mixedPhaseModulesInventory,
mixedPhaseModulesPlayer, nextMixedPhaseModules,
type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
} from './fixture-mixed-phase-modules';
export const actSelectedPhaseRecruits = actMixedPhaseModules;
export const selectedPhaseRecruitsClean = mixedPhaseModulesClean;
export const selectedPhaseRecruitsInventory = mixedPhaseModulesInventory;
export const selectedPhaseRecruitsPlayer = mixedPhaseModulesPlayer;
export type SelectedPhaseRecruitsStep = MixedPhaseModulesStep;
export type SelectedPhaseRecruitsTransition = MixedPhaseModulesTransition;
/** The actual Ixian inspection keeps Recruits in its original auction pool;
* a real Mori bid1 buys that unopened first ordinary lot. All other native
* choices retain the shared source driver and its physical phase accounting. */
export function nextSelectedPhaseRecruits(game: Game): SelectedPhaseRecruitsStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision?.kind === 'ixAuction') {
const returned = game.ixAuction!.cards.find(card => card.id !== RECRUITS_CARD_ID); assert.ok(returned);
return { actor: game.decision.player,
action: { type: 'decision', card: returned.id, position: 'bottom' } };
}
const auction = game.auction;
if (selectedPhaseRecruitsClean(game) && auction?.cards[auction.index]?.id === RECRUITS_CARD_ID &&
auction.active === 'moritani' && auction.bid === 0)
return { actor: 'moritani', action: { type: 'bid', amount: 1 } };
return nextMixedPhaseModules(game);
}
export function advanceSelectedPhaseRecruits(game: Game, until: (state: Game) => boolean,
actions: SelectedPhaseRecruitsStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Recruits source ended before its next control');
const step = nextSelectedPhaseRecruits(game);
assert.ok(step, `Choose the actual native control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSelectedPhaseRecruits(game, step.actor, step.action, actions);
}
throw Error('The original selected-phase Recruits source did not reach its finite boundary');
}
export function finishSelectedPhaseRecruits(game: Game, actions: SelectedPhaseRecruitsStep[] = []): Game {
return advanceSelectedPhaseRecruits(game, state => state.phase === 5 && selectedPhaseRecruitsClean(state), actions);
}
/** All six native seats and all three original family decks. Public independent
* cards/Tech selections and optional Advanced Strongholds precede normal start.
* The sole new lottery is the still-unopened remaining Recruits position before
* the first ordinary auction. Actual inspection, bid/payment/acquisition,
* shipment3, next Storm and phase progression supply the played source.
* No played hand, force, wallet, Tanks, phase, queue or receipt is installed. */
export function createSelectedPhaseRecruitsFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false, owner = 'moritani', bankOwner = 'tleilaxu';
const source = createMixedPhaseModulesFixture({ factions: MIXED_PHASE_NATIVE_FACTIONS,
expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds: advanced, independent: true });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actSelectedPhaseRecruits(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSelectedPhaseRecruits(game, until, actions); };
const transition = (run: () => void): SelectedPhaseRecruitsTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
assert.equal(game.phase, 1); assert.ok(game.ecazTreachery && game.techTokens);
const beforeAuctionOrder = structuredClone(game);
const from = game.deck.findIndex(card => card.id === RECRUITS_CARD_ID); assert.ok(from >= 0,
'The original scalar setup leaves physical Recruits undealt for this ordinary auction');
const recruits = game.deck.splice(from, 1)[0]; game.deck.unshift(recruits);
const unopenedAuctionOrder = { card: recruits.id, from, to: 0 };
assert.deepEqual(selectedPhaseRecruitsInventory(game), source.originalInventory);
let beforeBid: Game | undefined, bid: SelectedPhaseRecruitsStep | undefined, bidAt = -1;
for (let count = 0; !selectedPhaseRecruitsPlayer(game, owner).hand.some(card => card.id === RECRUITS_CARD_ID) && count < 1000; count++) {
const step = nextSelectedPhaseRecruits(game); assert.ok(step);
if (step.actor === owner && step.action.type === 'bid' && step.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
assert.equal(game.auction?.index, 0, 'Recruits is the actual first ordinary lot');
}
act(step.actor, step.action);
}
assert.ok(beforeBid && bid && bidAt >= 0);
const acquisition = { before: beforeBid, bid, steps: structuredClone(actions.slice(bidAt)), after: structuredClone(game) };
assert.equal(selectedPhaseRecruitsPlayer(beforeBid, owner).spice - selectedPhaseRecruitsPlayer(game, owner).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && selectedPhaseRecruitsClean(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, 'A native empty desert lies in the next Storm traversal');
const casualtyLocation = location(target.id, sector);
const shipment = transition(() => {
act(owner, { type: 'ship', territory: target.id, sector, amount: 3, elite: 0, allyPayment: 0 });
reach(selectedPhaseRecruitsClean);
});
assert.equal(selectedPhaseRecruitsPlayer(game, owner).forces[casualtyLocation], 3);
assert.equal(selectedPhaseRecruitsPlayer(shipment.before, owner).spice - selectedPhaseRecruitsPlayer(game, owner).spice, 6);
reach(state => state.turn === 1 && state.phase === 8 && selectedPhaseRecruitsClean(state));
const mentat = transition(() => {
reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && selectedPhaseRecruitsClean(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 && selectedPhaseRecruitsClean(state)); });
assert.equal(selectedPhaseRecruitsPlayer(game, owner).forces[casualtyLocation], undefined);
assert.equal(selectedPhaseRecruitsPlayer(game, owner).tanks, 3);
reach(state => state.turn === 2 && state.phase === 4 && selectedPhaseRecruitsClean(state) &&
(!state.revivalOrder || state.revivalOrder.remaining[0] === owner));
const playAction = recruitsPlayAction(viewGame(game, owner).recruitsPreview); assert.ok(playAction);
const revivalAction: Action = { type: 'revive', amount: 3, elite: 0 };
assert.deepEqual(selectedPhaseRecruitsInventory(game), source.originalInventory);
return { ...source, game, sourceGame: structuredClone(game), advanced, owner, bankOwner,
card: RECRUITS_CARD_ID, playAction, revivalAction, beforeAuctionOrder, unopenedAuctionOrder,
acquisition, shipment, mentat, storm, casualtyLocation,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], lottery: 'Original unopened deal/Spice/scalar choices from the shared native source, plus unopened Recruits first-lot order; actual Ix inspection returns a different physical card.' };
}
export type SelectedPhaseRecruitsFixture = ReturnType<typeof createSelectedPhaseRecruitsFixture>;
/** Original card use, normal three-counter return and exact final phase payout.
* The source remains reusable before play for actual GameTable callbacks. */
export function completeSelectedPhaseRecruits(f: SelectedPhaseRecruitsFixture, state = f.game) {
let game = state;
const actions: SelectedPhaseRecruitsStep[] = [];
const act = (actor: string, action: Action) => { game = actSelectedPhaseRecruits(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSelectedPhaseRecruits(game, until, actions); };
const transition = (run: () => void): SelectedPhaseRecruitsTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
const play = transition(() => { act(f.owner, f.playAction); reach(selectedPhaseRecruitsClean); });
const revival = transition(() => { act(f.owner, f.revivalAction); reach(selectedPhaseRecruitsClean); });
reach(state => state.phase === 4 && selectedPhaseRecruitsClean(state) && (state.revivalOrder
? state.revivalOrder.remaining.length === 1 : state.ready.length === state.players.length - 1));
const revivalPayout = transition(() => {
const step = nextSelectedPhaseRecruits(game); assert.ok(step); act(step.actor, step.action);
reach(state => state.phase === 5 && selectedPhaseRecruitsClean(state));
});
assert.deepEqual(selectedPhaseRecruitsInventory(game), f.originalInventory);
return { game, play, revival, revivalPayout, actions, actionCount: actions.length };
}
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { ownedTech } from '../game/tech-tokens';
import { viewGame } from '../game/engine';
import { phaseModuleEcazInventory as inventory, phaseModuleEcazPlayer as player } from './fixture-phase-module-ecaz';
import { createIndependentPhaseEcazFixture as fixture, resolveIndependentPhaseEcaz as resolve } from './fixture-independent-phase-ecaz';
for (const advanced of [false, true]) for (const lead of ['ecaz', 'ally'] as const)
void test(`mixed independent ${advanced ? 'Advanced Stronghold+Tech' : 'Basic Tech'} ${lead} coalition pays physical fees and withdraws only the card user before winner Tech`, () => {
const f = fixture({ advanced, lead }), before = structuredClone(f.game);
assert.equal(before.ecazTreachery, true); assert.equal(f.originalInventory.length, 50);
assert.deepEqual(before.expansions, ['ix', 'choam', 'ecaz']);
const own = viewGame(before, f.actor).battle!;
assert.equal(own.reinforcements?.blocked, null); assert.equal(own.harassWithdraw?.blocked, null);
const { game, quote, actions } = resolve(f);
assert.deepEqual(f.game, before);
const nonLead = f.actor === f.ecaz ? f.ally : f.ecaz;
assert.equal(quote.winner, f.actor); assert.equal(game.lastBattleContext!.winner, f.actor);
assert.deepEqual(quote.reinforcements, [{ player: f.actor, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
const returned = lead === 'ecaz' ? { normal: 2, elite: 0 } : { normal: 2, elite: 3 };
const withdrawal = quote.harassWithdraw!.find(entry => entry.player === f.actor)!;
assert.deepEqual(withdrawal.returned, returned);
assert.equal(quote.harassWithdraw!.length, 1);
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: advanced ? 3 : 2, elite: 0 }]);
assert.equal(quote.casualties!.owner, f.ally);
assert.deepEqual(quote.casualties!.options, [{ normal: 1, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.equal(player(game, f.actor).reserves, player(before, f.actor).reserves - 3 + returned.normal + returned.elite);
assert.equal(player(game, nonLead).reserves, player(before, nonLead).reserves);
assert.equal(player(game, f.ecaz).tanks - player(before, f.ecaz).tanks, (advanced ? 3 : 2) + (lead === 'ecaz' ? 3 : 0));
assert.equal(player(game, f.ally).tanks - player(before, f.ally).tanks, 1 + (lead === 'ally' ? 3 : 0));
assert.equal(player(game, f.ally).elites!.tanks, player(before, f.ally).elites!.tanks);
assert.equal(player(game, f.ecaz).forces[f.location] ?? 0, lead === 'ecaz' ? 0 : 2);
assert.equal(player(game, f.ally).forces[f.location] ?? 0, lead === 'ally' ? 0 : 5);
assert.equal(player(game, f.ally).elites!.forces[f.location] ?? 0, lead === 'ally' ? 0 : 3);
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);
const reward = advanced && lead === 'ecaz' ? 1 : 0;
assert.deepEqual(quote.strongholdIncome, reward ? [{ player: f.actor, amount: reward }] : []);
for (const seat of before.players)
assert.equal(player(game, seat.id).spice, seat.spice + (seat.id === f.actor ? reward : 0));
for (const id of ['heighliners', 'axlotl', 'production'] as const) {
assert.equal(game.techTokens![id].spice, before.techTokens![id].spice);
assert.equal(game.techTokens![id].triggeredTurn, before.techTokens![id].triggeredTurn,
'Reserve fees and Harass returns do not trigger shipment or free-revival income');
}
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(game.discard.filter(discarded => discarded.id === card).length, 1);
assert.ok(actions.some(step => step.action.type === 'traitorCall' && step.action.call === false));
assert.deepEqual(inventory(game), f.originalInventory);
});
@@ -0,0 +1,54 @@
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 { viewGame } 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();
// Stateful components render through React SSR. Recorded public actions prove
// authoritative selection/Start; these renders do not claim browser clicks.
void test('original independent-card and phase controls remain selected through Basic and Advanced preview Start', () => {
for (const advanced of [false, true]) {
const f = createMixedPhaseModulesFixture({ factions: ['ixians', 'ecaz', 'moritani'], expansions: ['ix', 'ecaz'],
advanced, tech: true, strongholds: advanced, independent: true });
const render = (game: typeof f.game) => renderToStaticMarkup(createElement(GameTable, {
game: viewGame(game, game.host), busy: false, send: async () => {}, onExit() {},
}));
const html = render(f.lobby);
const variant = [...html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/g)]
.find(([, children]) => children.includes('Ecaz Treachery Cards'));
assert.ok(variant, 'The lobby exposes the original optional independent-card control');
assert.match(variant[1], /<input\b[^>]*checked=""/);
assert.match(variant[1], /Recruits, Reinforcements, Harass &amp; Withdraw/);
assert.match(html, /Tech tokens · optional/);
const label = advanced ? 'Begin Advanced preview' : 'Begin game';
const begin = [...html.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)]
.find(([, , children]) => children.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() === label);
assert.ok(begin, 'The ready host has its original Start control');
assert.doesNotMatch(begin[1], /(?:^|\s)disabled(?:=""|\s|$)/);
assert.equal(f.lobby.lobbyOptions?.ecazTreachery, true);
assert.ok(f.actions.some(step => step.actor === f.initial.host && step.action.type === 'moduleOption' &&
step.action.module === 'ecazTreachery' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === advanced));
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.ecazTreachery, true);
const playing = render(f.game);
assert.match(playing, /Heighliners/);
if (advanced) assert.match(playing, /Inspect Stronghold Cards/);
}
});
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_STRONGHOLD } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import {
createMixedPhaseModulesFixture, createMixedPhaseModulesProgramme,
mixedPhaseModulesClean, mixedPhaseModulesInventory, mixedPhaseModulesPlayer as player,
type MixedPhaseModulesOptions,
} from './fixture-mixed-phase-modules';
function entry(options: MixedPhaseModulesOptions) {
const f = createMixedPhaseModulesFixture({ ...options, independent: true });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.initial.ecazTreachery, undefined);
const selected = f.actions.findIndex(step => step.action.type === 'moduleOption' &&
step.action.module === 'ecazTreachery' && step.action.enabled === true);
const ready = f.actions.findIndex(step => step.action.type === 'ready');
assert.ok(selected >= 0 && selected < ready);
assert.equal(f.actions[selected].actor, f.initial.host);
assert.equal(f.lobby.lobbyOptions?.ecazTreachery, true);
assert.ok(f.lobby.players.every(seat => seat.ready));
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.ecazTreachery, true);
assert.equal(f.game.status, 'playing'); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 1);
assert.equal(f.game.ecazTreachery, true); assert.equal(f.game.advanced, !!options.advanced);
assert.equal(!!f.game.techTokens, !!options.tech); assert.equal(!!f.game.strongholdCards, !!options.strongholds);
assert.deepEqual(f.game.expansions, options.expansions);
assert.ok(!f.game.homeworlds && !f.game.leaderSkills && !f.game.nexusCards &&
!f.game.discoveryEnabled && !f.game.semutaPreview);
assert.equal(f.originalInventory.length, treacheryDeck(options.expansions).length + 3);
assert.ok(ecazTreacheryCards().every(card => f.originalInventory.includes(card.id)));
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory);
assert.ok(f.afterSetup.players.every(seat => seat.hand.length > 0), 'Original native setup deals the actual starting cards');
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(f.staging, []);
return f;
}
void test('public Basic independent cards support paired E3 Tech and bare mixed Ecaz without incidental phase modules', () => {
entry({ factions: ['ecaz', 'moritani', 'guild'], expansions: ['ecaz'], tech: true });
entry({ factions: ['ecaz', 'ixians', 'tleilaxu'], expansions: ['ix', 'choam', 'ecaz'] });
});
void test('public Advanced independent cards support mixed Strongholds and classic-only unused selected decks with both phase modules', () => {
entry({ factions: ['ecaz', 'choam', 'guild'], expansions: ['choam', 'ecaz'], advanced: true, strongholds: true });
entry({ factions: ['atreides', 'emperor', 'guild'], expansions: ['ix', 'choam', 'ecaz'], advanced: true,
tech: true, strongholds: true });
});
for (const advanced of [false, true]) void test(`public all-six independent-card ${advanced ? 'Advanced Tech and Strongholds' : 'Basic Tech'} preserves its earned phase payouts and physical cards`, () => {
const f = createMixedPhaseModulesProgramme({ advanced, independent: true });
assert.equal(f.independent, true); assert.equal(f.setup.ecazTreachery, true);
assert.equal(f.setup.deck.length, 50); assert.equal(f.setup.richeseCache?.length, 10);
assert.equal(f.originalInventory.length, 60);
assert.equal(f.setup.techTokens?.heighliners.owner, 'ixians');
assert.equal(f.setup.techTokens?.axlotl.owner, 'tleilaxu');
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'ecazTreachery'));
assert.ok(f.actions.some(step => step.actor === 'ixians' && step.action.type === 'decision' && typeof step.action.card === 'string'));
assert.ok(f.afterSetup.players.every(seat => seat.hand.length > 0));
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 receipt of [f.choamShipment, f.moritaniShipment]) {
assert.equal(receipt.after.techTokens?.heighliners.spice, 1);
assert.equal(receipt.after.techTokens?.heighliners.triggeredTurn, 1);
assert.equal(player(receipt.after, 'ixians').spice, player(receipt.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');
}
assert.equal(f.storm.before.stormMovementSource?.kind, advanced ? 'card' : 'dials');
assert.equal(f.storm.before.stormPending, advanced ? 1 : 2);
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').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.revivalPayout.after.techTokens?.axlotl.spice, 0);
assert.equal(player(f.revivalPayout.after, 'tleilaxu').spice - player(f.revivalPayout.before, 'tleilaxu').spice, 1);
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 2); assert.ok(mixedPhaseModulesClean(f.game));
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory); assert.deepEqual(f.staging, []);
assert.ok(!f.game.homeworlds && !f.game.leaderSkills && !f.game.nexusCards && !f.game.discoveryEnabled);
// Recruits acquisition and use have their own native lane. This programme
// consumes original phase choices and never selects a new card effect.
assert.ok(!f.actions.some(step => step.action.type === 'card' && step.action.card === 'ecaz-recruits'));
if (advanced) {
const actor = f.game.active; assert.ok(actor);
const view = viewGame(f.game, actor); view.players.find(seat => seat.id === actor)!.bot = 'Easy';
const candidate = botActions(view).find(action => action.type === 'endMovement'); assert.ok(candidate);
assert.doesNotThrow(() => applyAction(f.game, actor, candidate));
}
});
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
nativeFactionTechProfile as tech, selectedFactionStrongholdProfile as strongholds,
selectedIndependentCardsProfile as independent, 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 })),
ecazTreachery: true, techTokens: createTechTokens(), ...extra };
}
void test('selected independent cards retain original Tech across paired, mixed and all-three-family rosters in both modes', () => {
for (const advanced of [false, true]) for (const game of [
profile(undefined, undefined, { advanced }),
profile(['ecaz', 'ixians', 'emperor'], ['ecaz', 'ix'], { advanced }),
profile(['moritani', 'choam', 'guild'], ['choam', 'ecaz'], { advanced }),
profile(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz'], { advanced }),
]) {
const before = structuredClone(game);
assert.equal(independent(game), true); assert.deepEqual(game, before);
assert.equal(tech(game), false, 'The card-free Tech wrapper must keep its existing scope');
assert.equal(strongholds(game), false, 'The card-free Stronghold wrapper must keep its existing scope');
}
});
void test('classic-only and unused selected families remain independent from actual seated native factions', () => {
for (const decks of [['ix'], ['choam'], ['ecaz'], ['ix', 'choam', 'ecaz']])
assert.equal(independent(profile(['atreides', 'emperor', 'guild'], decks)), true);
assert.equal(independent(profile(['ixians', 'guild', 'emperor'], ['ix', 'choam', 'ecaz'])), true);
for (const game of [
profile(['atreides', 'emperor', 'guild'], []),
profile(['ecaz', 'guild', 'emperor'], ['ix']),
profile(['ecaz', 'guild', 'emperor'], ['ecaz', 'ecaz']),
profile(['ecaz', 'guild', 'emperor'], ['ecaz', 'unknown']),
profile(['unknown', 'guild', 'emperor']),
]) assert.equal(independent(game), false);
});
void test('the exact independent card selection permits bare mixed Basic play and optional original phase modules', () => {
assert.equal(independent(profile(undefined, undefined, { techTokens: null, strongholdCards: null })), true);
assert.equal(independent(profile(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix'], {
advanced: false, techTokens: null, strongholdCards: null,
})), true);
for (const ecazTreachery of [undefined, false, 'true', 1])
assert.equal(independent(profile(undefined, undefined, { ecazTreachery })), false);
assert.equal(independent(profile(undefined, undefined, { techTokens: null, strongholdCards: createStrongholdCards() })), true);
assert.equal(independent(profile(undefined, undefined, { strongholdCards: createStrongholdCards() })), true);
});
void test('Tech keeps three-six seats and Strongholds two-six Advanced without inventing a Basic card mode', () => {
const two = profile(['ecaz', 'ixians'], ['ecaz', 'ix']);
assert.equal(independent(two), false);
assert.equal(independent({ ...two, techTokens: null, strongholdCards: createStrongholdCards() }), true);
assert.equal(independent({ ...two, techTokens: null, strongholdCards: createStrongholdCards(), advanced: false }), false);
assert.equal(independent(profile(undefined, undefined, { advanced: false })), true);
assert.equal(independent(profile(undefined, undefined, { advanced: false, strongholdCards: createStrongholdCards() })), false);
assert.equal(independent(profile(['ecaz'])), false);
assert.equal(independent(profile(['ecaz', 'ecaz', 'guild'])), false);
assert.equal(independent(profile(['ecaz', 'moritani', 'guild', 'atreides', 'emperor', 'ixians', 'choam'], ['ix', 'choam', 'ecaz'])), false);
});
void test('unrelated modules, active Sandtrout and reaction-preview selectors cannot enter the independent phase scope', () => {
for (const field of [
'homeworlds', 'leaderSkills', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
'sandtrout', 'spiceBankerIncomePreview', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview',
'guildBetrayalPreview', 'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview',
'nexusHarkonnenBetrayalPreview',
] as const) assert.equal(independent({ ...profile(), [field]: true }), false, field);
});
void test('canonical native runtime stamps require their actual faction and Advanced assassination while captured-target Harkonnen is permitted', () => {
assert.equal(independent(profile(undefined, undefined, { ecazOccupyPreview: true, moritaniAssassinatePreview: true })), true);
assert.equal(independent(profile(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix'], { advanced: false, ecazOccupyPreview: true })), true);
assert.equal(independent(profile(['moritani', 'harkonnen', 'guild'], ['ecaz'], { moritaniAssassinatePreview: true })), true);
for (const game of [
profile(['ixians', 'guild', 'emperor'], ['ix'], { ecazOccupyPreview: true }),
profile(['ecaz', 'guild', 'emperor'], ['ecaz'], { moritaniAssassinatePreview: true }),
profile(undefined, undefined, { advanced: false, moritaniAssassinatePreview: true }),
profile(undefined, undefined, { ecazOccupyPreview: 'forged' }),
profile(undefined, undefined, { moritaniAssassinatePreview: 'forged' }),
]) assert.equal(independent(game), false);
});
void test('default phase wrappers and explicit Semuta retain their previous admission rather than inherit independent cards', () => {
const bare = { ...profile(), ecazTreachery: undefined };
assert.equal(tech(bare), true); assert.equal(strongholds(bare), true); assert.equal(tech(bare, true), false);
const semuta = profile(['richese', 'choam', 'guild'], ['choam'], { ecazTreachery: undefined, semutaPreview: true });
assert.equal(tech(semuta), false); assert.equal(tech(semuta, true), true); assert.equal(strongholds(semuta), false);
assert.equal(independent(semuta), false);
const combined = { ...semuta, ecazTreachery: true };
assert.equal(independent(combined), false); assert.equal(tech(combined, true), false);
});
void test('the predicate accepts frozen public configuration without copying or mutating it and rejects malformed roster shapes', () => {
const game = profile(); Object.freeze(game.expansions); game.players.forEach(Object.freeze); Object.freeze(game.players); Object.freeze(game);
assert.equal(independent(game), true);
for (const value of [null, {}, { ...profile(), players: 'ecaz,moritani,guild' },
{ ...profile(), players: [{ faction: 'ecaz' }, null] }, { ...profile(), expansions: 'ecaz' }])
assert.equal(independent(value as unknown as FactionModuleProfile), false);
});
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { RECRUITS_CARD_ID } from '../game/recruits';
import {
completeSelectedPhaseRecruits, createSelectedPhaseRecruitsFixture,
selectedPhaseRecruitsInventory as inventory, selectedPhaseRecruitsPlayer as player,
} from './fixture-selected-phase-recruits';
for (const advanced of [false, true]) {
void test(`public all-six-native selected-family ${advanced ? 'Advanced Stronghold and Tech' : 'Basic Tech'} Recruits earns an extra free return and separate native/token income`, () => {
const f = createSelectedPhaseRecruitsFixture({ advanced });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.setup.players.map(seat => seat.faction), ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani']);
assert.deepEqual(f.setup.expansions, ['ix', 'choam', 'ecaz']);
assert.ok(f.setup.ecazTreachery && f.setup.techTokens);
assert.equal(!!f.setup.strongholdCards, advanced);
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'ecazTreachery' && step.action.enabled === true));
assert.ok(!f.game.homeworlds && !f.game.leaderSkills && !f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
assert.equal(f.originalInventory.length, 60); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.beforeAuctionOrder.phase, 1); assert.equal(f.unopenedAuctionOrder.card, RECRUITS_CARD_ID);
assert.equal(f.acquisition.before.auction?.index, 0);
assert.equal(f.acquisition.before.auction?.cards[0].id, RECRUITS_CARD_ID);
assert.equal(f.acquisition.bid.actor, f.owner); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.owner).spice - player(f.acquisition.after, f.owner).spice, 1);
assert.ok(player(f.acquisition.after, f.owner).hand.some(card => card.id === RECRUITS_CARD_ID));
assert.equal(player(f.shipment.after, f.owner).forces[f.casualtyLocation], 3);
assert.equal(player(f.shipment.before, f.owner).spice - player(f.shipment.after, f.owner).spice, 6);
assert.equal(f.storm.before.stormMovementSource?.kind, advanced ? 'card' : 'dials');
assert.equal(player(f.storm.after, f.owner).forces[f.casualtyLocation], undefined);
assert.equal(player(f.game, f.owner).tanks, 3); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
const ordinary = viewGame(f.game, f.owner).revival;
assert.equal(ordinary.freeRemaining, 2); assert.equal(ordinary.limit, 3);
if (advanced) {
assert.equal(f.game.revivalOrder?.remaining[0], f.owner);
assert.equal(f.mentat.after.strongholdCards?.claimedTurn, 1);
assert.equal(f.mentat.after.strongholdCards?.owners.carthag, f.owner);
}
const before = structuredClone(f.game);
const result = completeSelectedPhaseRecruits(f);
assert.deepEqual(f.game, before, 'Completing the native programme retains its reusable pre-play source');
assert.deepEqual(result.play.steps[0], { actor: f.owner, action: f.playAction });
assert.equal(result.play.after.recruits?.turn, 2);
assert.equal(player(result.play.after, f.owner).hand.some(card => card.id === RECRUITS_CARD_ID), false);
assert.equal(result.play.after.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
const doubled = viewGame(result.play.after, f.owner).revival;
assert.equal(doubled.freeRemaining, 4); assert.equal(doubled.limit, 7);
assert.equal(player(result.play.after, f.owner).tanks, 3);
assert.equal(player(result.play.after, f.owner).spice, player(f.game, f.owner).spice);
assert.throws(() => applyAction(result.play.after, f.owner, f.playAction));
assert.deepEqual(result.revival.steps[0], { actor: f.owner, action: f.revivalAction });
const returned = player(result.revival.after, f.owner), prior = player(result.revival.before, f.owner);
assert.equal(returned.tanks, 0); assert.equal(returned.reserves - prior.reserves, 3);
assert.equal(returned.revived, 3); assert.equal(returned.freeForcesRevived, 3);
assert.equal(returned.spice, prior.spice, 'The third counter uses the additional free rate rather than a paid return');
assert.equal(player(result.revival.after, f.bankOwner).spice - player(result.revival.before, f.bankOwner).spice, 1,
'Native Tleilaxu free-revival income is one separate bank spice');
assert.equal(result.revival.after.techTokens?.axlotl.owner, f.bankOwner);
assert.equal(result.revival.after.techTokens?.axlotl.spice, 1);
assert.equal(result.revival.after.techTokens?.axlotl.triggeredTurn, 2);
assert.equal(result.revivalPayout.after.techTokens?.axlotl.spice, 0);
assert.equal(player(result.revivalPayout.after, f.bankOwner).spice - player(result.revivalPayout.before, f.bankOwner).spice, 1,
'Original Axlotl payout waits for the actual Revival phase boundary');
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 5);
assert.deepEqual(inventory(result.game), f.originalInventory);
assert.equal(result.game.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
for (const seat of result.game.players)
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, amount) => sum + amount, 0), 20);
});
}