Compose independent Ecaz cards with selected phase modules
This commit is contained in:
1 parent
68a7870e2e
commit
0a98a92f4b
17 files changed
+733
-15
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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); };
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 & 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));
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user