Compose native World cards with original phase modules
This commit is contained in:
1 parent
cda9338f13
commit
d6d71c7c02
18 files changed
+714
-39
No files matched your search
@@ -18,6 +18,9 @@ export { REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD };
|
||||
export type NativeWorldBattleStep = HarassHomeworldStep;
|
||||
export type NativeWorldBattleOptions = {
|
||||
advanced?: boolean;
|
||||
/** Original unused phase modules selected before native setup. */
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
route?: 'ix-visitor' | 'ix-native' | 'choam-native';
|
||||
/** Actual first-turn export, not a reserve assignment. */
|
||||
exportNormal?: number;
|
||||
@@ -76,7 +79,7 @@ function conserveCard(game: Game, actor: string, id: string, staging: string[]):
|
||||
owner.hand.push(game.deck.splice(at, 1)[0]);
|
||||
staging.push(`Conserved original ${id}: undealt deck → ${actor}; not a purchase.`);
|
||||
}
|
||||
function conserveDancer(game: Game, identity: string, staging: string[]): void {
|
||||
export function conserveDancer(game: Game, identity: string, staging: string[]): void {
|
||||
const tleilaxu = nativeWorldBattlePlayer(game, 'tleilaxu');
|
||||
if (tleilaxu.faceDancers!.some(d => d.leader === identity)) return;
|
||||
const dancer = tleilaxu.faceDancers!.find(d => !d.revealed); assert.ok(dancer);
|
||||
@@ -96,7 +99,8 @@ function conserveDancer(game: Game, identity: string, staging: string[]): void {
|
||||
export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions = {}): NativeWorldBattleFixture {
|
||||
const route = options.route ?? 'ix-visitor';
|
||||
const advanced = options.advanced ?? false;
|
||||
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true, tech: false, strongholds: false,
|
||||
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true,
|
||||
tech: options.tech ?? false, strongholds: options.strongholds ?? false,
|
||||
factions: route === 'choam-native' ? ['choam', 'guild', 'tleilaxu', 'ixians'] : ['ixians', 'tleilaxu', 'guild'],
|
||||
expansions: route === 'choam-native' ? ['ix', 'choam'] : ['ix'] });
|
||||
let game = setup.game;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||||
import type { HomeworldForces } from '../game/homeworld-custody';
|
||||
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
|
||||
import { actWorldPhaseBattle, advanceWorldPhaseBattle } from './fixture-world-phase-battle';
|
||||
import { harassHomeworldClean, nextHarassHomeworldStep } from './fixture-harass-homeworld';
|
||||
import { takeHarassCard } from './fixture-harass-withdraw';
|
||||
import {
|
||||
advanceNativeWorldBattle, assertNativeWorldBattleInventory, conserveDancer, createNativeWorldBattleFixture,
|
||||
HARASS_WITHDRAW_CARD, nativeWorldBattleFinished, nativeWorldBattlePlayer,
|
||||
REINFORCEMENTS_CARD, type NativeWorldBattleFixture, type NativeWorldBattleStep,
|
||||
} from './fixture-native-world-battle';
|
||||
|
||||
export type NativeWorldPhaseBattleOptions = {
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Actual Cyborg support, never support for Reinforcements' score-only +2. */
|
||||
support?: 0 | 1 | 3;
|
||||
};
|
||||
export type NativeWorldPhaseBattleFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeFirstMentat: Game;
|
||||
firstMentatStep: NativeWorldBattleStep;
|
||||
afterFirstMentat: Game;
|
||||
shipments: { before: Game; step: NativeWorldBattleStep; after: Game }[];
|
||||
beforeBattle: Game;
|
||||
battleStep: NativeWorldBattleStep;
|
||||
/** Original prepared battle, no sealed plans. */
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
observer: string;
|
||||
location: string;
|
||||
planActions: [NativeWorldBattleStep, NativeWorldBattleStep];
|
||||
sources: NativeReserveSelections;
|
||||
returns: Record<string, HomeworldForces>;
|
||||
nativeSteps: NativeWorldBattleStep[];
|
||||
staging: string[];
|
||||
};
|
||||
|
||||
/** Arrakeen's held card applies only in Arrakeen, never on a foreign World.
|
||||
* Ix therefore really pays to place 3 Suboids + 3 Cyborgs there on turn one,
|
||||
* earns the card at original END Mentat, then meets a paid Guild attack on
|
||||
* turn two. The three-force R fee still comes from original native Ix custody.
|
||||
* A conserved matching Face Dancer offers its original optional continuation,
|
||||
* exposing the settled support invoice before Collection. No army/wallet/phase/
|
||||
* owner or module receipt is assigned.
|
||||
*/
|
||||
export function createNativeWorldPhaseBattleFixture(
|
||||
options: NativeWorldPhaseBattleOptions = {},
|
||||
): NativeWorldPhaseBattleFixture {
|
||||
const advanced = options.advanced ?? true;
|
||||
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true,
|
||||
tech: options.tech ?? true, strongholds: options.strongholds ?? advanced,
|
||||
factions: ['ixians', 'tleilaxu', 'guild'], expansions: ['ix'] });
|
||||
let game = setup.game;
|
||||
const afterSetup = structuredClone(game), nativeSteps = [...setup.steps];
|
||||
const owner = 'ixians', opponent = 'guild', observer = 'tleilaxu', location = 'arrakeen:10';
|
||||
const staging = [
|
||||
'Original setup and first-Storm entropy only are fixed; later Storm Cards and movement stay original.',
|
||||
'Position eight original unopened non-worm Spice Cards for the first two native Spice Blows; no replacement face or phase assignment.',
|
||||
];
|
||||
for (let position = 0; position < 8; position++) {
|
||||
const at = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(at >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
const act = (step: NativeWorldBattleStep) => {
|
||||
game = actWorldPhaseBattle(game, step, nativeSteps); return game;
|
||||
};
|
||||
const reach = (until: (g: Game) => boolean) => { game = advanceWorldPhaseBattle(game, until, nativeSteps); };
|
||||
const shipments: NativeWorldPhaseBattleFixture['shipments'] = [];
|
||||
const ship = (actor: string, amount: number, elite: number) => {
|
||||
const before = structuredClone(game), view = viewGame(game, actor);
|
||||
const sources = nativeShipmentSources(view, amount, elite); assert.ok(sources);
|
||||
const step: NativeWorldBattleStep = { actor, action: {
|
||||
type: 'ship', territory: 'arrakeen', sector: 10, amount, elite, homeworldSources: sources, allyPayment: 0,
|
||||
} };
|
||||
act(step);
|
||||
reach(g => harassHomeworldClean(g) && !g.pendingShipment && !g.pendingHomeworldShipment);
|
||||
shipments.push({ before, step, after: structuredClone(game) });
|
||||
};
|
||||
reach(g => g.turn === 1 && g.phase === 5 && g.active === owner && harassHomeworldClean(g));
|
||||
ship(owner, 6, 3);
|
||||
assert.deepEqual(combatArmy(game, owner, 'arrakeen'), { normal: 3, elite: 3 });
|
||||
reach(g => g.turn === 1 && g.phase === 8 && harassHomeworldClean(g));
|
||||
let beforeFirstMentat: Game | undefined, firstMentatStep: NativeWorldBattleStep | undefined;
|
||||
while (game.turn === 1) {
|
||||
const step = nextHarassHomeworldStep(game); assert.ok(step);
|
||||
const before = structuredClone(game), after = act(step);
|
||||
if (after.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
|
||||
}
|
||||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
reach(g => g.turn === 2 && g.phase === 5 && harassHomeworldClean(g));
|
||||
while (game.phase === 5) {
|
||||
const actor = game.active!;
|
||||
if (harassHomeworldClean(game) && !nativeWorldBattlePlayer(game, actor).shipped && actor === opponent)
|
||||
ship(actor, 3, 0);
|
||||
else {
|
||||
const step = nextHarassHomeworldStep(game); assert.ok(step); act(step);
|
||||
}
|
||||
}
|
||||
reach(g => g.phase === 6 && !!g.active && harassHomeworldClean(g));
|
||||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||||
takeHarassCard(game, owner, REINFORCEMENTS_CARD);
|
||||
takeHarassCard(game, owner, HARASS_WITHDRAW_CARD);
|
||||
staging.push('Return all original hands, including Ix setup stock, to undealt stock; conserve original R/H into Ix’s two real slots. This is not a purchase.');
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === 'arrakeen'); assert.ok(choice);
|
||||
const battleStep: NativeWorldBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'arrakeen',
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
act(battleStep);
|
||||
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
|
||||
const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
|
||||
const ownLeader = living(owner).at(-1)!, otherLeader = living(opponent)[0];
|
||||
assert.ok(ownLeader && otherLeader);
|
||||
conserveDancer(game, ownLeader.id, staging);
|
||||
const support = advanced ? options.support ?? 3 : 0;
|
||||
const sources = { 'homeworld:ixians': { normal: 3, elite: 0 } };
|
||||
// Exactly three Cyborgs are committed in every programme. At half support
|
||||
// they dial three; each actually supported Cyborg adds one, not another token.
|
||||
const planActions: NativeWorldPhaseBattleFixture['planActions'] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial: advanced ? 3 + support : 6, support,
|
||||
leader: ownLeader.id, weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD, reinforcementsSources: sources } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: otherLeader.id } },
|
||||
];
|
||||
const returns = { [location]: { normal: 3, elite: 0 } };
|
||||
assert.deepEqual(game.battle!.plans, {});
|
||||
assertNativeWorldBattleInventory(game, afterSetup);
|
||||
return { initial: setup.initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, shipments,
|
||||
beforeBattle, battleStep, game, owner, opponent, observer, location, planActions, sources, returns, nativeSteps, staging };
|
||||
}
|
||||
|
||||
export function revealNativeWorldPhaseBattle(fixture: NativeWorldPhaseBattleFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.planActions) {
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
game = advanceNativeWorldBattle(game, g => !!g.battle && harassHomeworldClean(g) &&
|
||||
!g.battle.preparation && g.battle.preLeader?.closed !== false, [], { returns: fixture.returns });
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function settleNativeWorldPhaseBattle(game: Game, fixture: NativeWorldPhaseBattleFixture): Game {
|
||||
return advanceNativeWorldBattle(game, g => g.decision?.kind === 'faceDance', [],
|
||||
{ returns: fixture.returns, casualties: { normal: 0, elite: 3 } });
|
||||
}
|
||||
export function finishNativeWorldPhaseBattle(game: Game, fixture: NativeWorldPhaseBattleFixture,
|
||||
steps: NativeWorldBattleStep[] = []): Game {
|
||||
if (game.decision?.kind === 'faceDance' && !game.response && !game.phaseOpening) {
|
||||
const step: NativeWorldBattleStep = { actor: game.decision.player, action: { type: 'decision', reveal: false } };
|
||||
steps.push(step); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
return advanceNativeWorldBattle(game, nativeWorldBattleFinished, steps,
|
||||
{ returns: fixture.returns, casualties: { normal: 0, elite: 3 } });
|
||||
}
|
||||
|
||||
/** Original native programmes with unused modules, not invented module benefits.
|
||||
* The visitor variant retains exact mixed fee custody and optional native
|
||||
* Cyborg/Face Dance aftercare; own Ix retains its original H prohibition.
|
||||
*/
|
||||
export function createNativeWorldPhaseForeignBattleFixture(options: {
|
||||
advanced?: boolean; typedAftercare?: boolean;
|
||||
} = {}): NativeWorldBattleFixture {
|
||||
const advanced = options.advanced ?? true;
|
||||
return createNativeWorldBattleFixture({ advanced, tech: true, strongholds: advanced,
|
||||
...(options.typedAftercare ? { exportNormal: 9, exportElite: 1, ownerDial: advanced ? 1 : 2,
|
||||
harass: false, outcome: 'win' as const, faceDancer: true } : {}) });
|
||||
}
|
||||
export function createNativeWorldPhaseOwnBattleFixture(advanced = true): NativeWorldBattleFixture {
|
||||
return createNativeWorldBattleFixture({ advanced, tech: true, strongholds: advanced, route: 'ix-native' });
|
||||
}
|
||||
@@ -88,10 +88,15 @@ export function createNativeWorldRecruitsFixture(options: {
|
||||
advanced?: boolean;
|
||||
faction?: NativeWorldRecruitsFaction;
|
||||
imperialCasualties?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
} = {}): NativeWorldRecruitsFixture {
|
||||
const native = options.faction ?? 'ixians';
|
||||
const setup = createIndependentCardModulesFixture({ advanced: options.advanced ?? false,
|
||||
homeworlds: true, tech: false, strongholds: false, factions: ['emperor', native, 'guild'],
|
||||
const advanced = options.advanced ?? false;
|
||||
const tech = options.tech ?? false, strongholds = options.strongholds ?? false;
|
||||
assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced');
|
||||
const setup = createIndependentCardModulesFixture({ advanced,
|
||||
homeworlds: true, tech, strongholds, factions: ['emperor', native, 'guild'],
|
||||
expansions: native === 'ixians' || native === 'tleilaxu' ? ['ix'] : ['choam'] });
|
||||
let game = setup.game;
|
||||
const afterSetup = structuredClone(game), steps = [...setup.steps];
|
||||
@@ -99,8 +104,12 @@ export function createNativeWorldRecruitsFixture(options: {
|
||||
assert.ok(setup.initial.homeworlds, 'The undealt ready lobby must have Homeworlds selected');
|
||||
assert.deepEqual(game.expansions, native === 'ixians' || native === 'tleilaxu' ? ['ix'] : ['choam']);
|
||||
assert.equal(game.status, 'playing');
|
||||
for (const [name, value] of [['Tech Tokens', game.techTokens], ['Stronghold Cards', game.strongholdCards],
|
||||
['Nexus Cards', game.nexusCards], ['Leader Skills', game.leaderSkills],
|
||||
if (game.strongholdCards) {
|
||||
assert.equal(game.strongholdCards.claimedTurn, 0);
|
||||
assert.ok(Object.values(game.strongholdCards.owners).every(owner => owner === null),
|
||||
'Only original end-Mentat may earn Stronghold Cards');
|
||||
}
|
||||
for (const [name, value] of [['Nexus Cards', game.nexusCards], ['Leader Skills', game.leaderSkills],
|
||||
['Discovery', game.discoveryEnabled]] as const)
|
||||
assert.equal(value, undefined, `${name} must stay outside a native World Recruits programme`);
|
||||
const visitor = 'emperor', owner = visitor;
|
||||
@@ -110,6 +119,8 @@ export function createNativeWorldRecruitsFixture(options: {
|
||||
assert.ok(home);
|
||||
const location = home.id;
|
||||
const staging = ['Return original setup hands to the unopened Treachery deck; no weapon or defense is used.'];
|
||||
if (tech || strongholds)
|
||||
staging.push('Select requested unused original Tech/Advanced Strongholds before readiness; native first Storm assigns tokens and actual end-Mentat earns cards. No owner or income receipt is assigned.');
|
||||
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
|
||||
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
|
||||
const at = game.spiceDeck.findIndex((card, index) => index >= i && 'territory' in card && !card.discovery);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, RuleError, viewGame } from '../game/engine';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import {
|
||||
advanceNativeWorldBattle, assertNativeWorldBattleInventory, HARASS_WITHDRAW_CARD,
|
||||
nativeWorldBattleFinished, nativeWorldBattlePlayer as player, nativeWorldBattlePool as pool,
|
||||
REINFORCEMENTS_CARD, revealNativeWorldBattle, type NativeWorldBattleStep,
|
||||
} from './fixture-native-world-battle';
|
||||
import {
|
||||
createNativeWorldPhaseBattleFixture, createNativeWorldPhaseForeignBattleFixture,
|
||||
createNativeWorldPhaseOwnBattleFixture, finishNativeWorldPhaseBattle, revealNativeWorldPhaseBattle,
|
||||
settleNativeWorldPhaseBattle,
|
||||
} from './fixture-native-world-phase-battle';
|
||||
|
||||
for (const scenario of [
|
||||
{ advanced: true, tech: true, support: 0 },
|
||||
{ advanced: true, tech: true, support: 1 },
|
||||
{ advanced: true, tech: true, support: 3 },
|
||||
{ advanced: true, tech: false, support: 3 },
|
||||
{ advanced: false, tech: true, support: 0 },
|
||||
] as const) {
|
||||
void test(`native Ix ${scenario.advanced ? 'Advanced earned Arrakeen' : 'Basic'} / Tech ${scenario.tech}: actual support ${scenario.support}, native fee, typed Harass`, () => {
|
||||
const f = createNativeWorldPhaseBattleFixture(scenario);
|
||||
const ownShipment = f.shipments[0];
|
||||
assert.equal(ownShipment.step.actor, f.owner);
|
||||
assert.equal(player(ownShipment.before, f.owner).spice - player(ownShipment.after, f.owner).spice, 6);
|
||||
assert.deepEqual(combatArmy(ownShipment.after, f.owner, 'arrakeen'), { normal: 3, elite: 3 });
|
||||
assert.deepEqual(pool(f.game, f.owner, 'homeworld:ixians'), { normal: 7, elite: 1 });
|
||||
assert.deepEqual(combatArmy(f.game, f.owner, 'arrakeen'), { normal: 3, elite: 3 });
|
||||
if (scenario.advanced) {
|
||||
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
|
||||
assert.deepEqual(combatArmy(f.beforeFirstMentat, f.owner, 'arrakeen'), { normal: 3, elite: 3 });
|
||||
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.owner);
|
||||
assert.equal(f.afterFirstMentat.strongholdCards!.claimedTurn, 1);
|
||||
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen');
|
||||
} else assert.equal(viewGame(f.game, f.owner).strongholdCards, null);
|
||||
assert.deepEqual(f.game.battle!.plans, {}, 'Both original controls start at the unsealed plan boundary');
|
||||
const before = structuredClone(f.game);
|
||||
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, dial: 9.5 }), RuleError,
|
||||
'R adds score, not physical Suboids/Cyborgs available to dial');
|
||||
if (scenario.advanced)
|
||||
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, support: 4 }), RuleError,
|
||||
'Only three actual Cyborgs can be supported; neither R +2 nor bank2 adds counters');
|
||||
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action,
|
||||
reinforcementsSources: { 'homeworld:guild': { normal: 3, elite: 0 } } }), RuleError);
|
||||
assert.deepEqual(f.game, before);
|
||||
|
||||
const revealed = revealNativeWorldPhaseBattle(f);
|
||||
assert.equal(player(revealed, f.owner).reserves, 8, 'Sealing/reveal never spends a forecast fee');
|
||||
assert.equal(player(revealed, f.owner).spice, player(f.game, f.owner).spice,
|
||||
'Holding Arrakeen never credits two personal spice at the plan boundary');
|
||||
const settled = settleNativeWorldPhaseBattle(revealed, f);
|
||||
assert.equal(player(settled, f.owner).spice,
|
||||
player(f.game, f.owner).spice - Math.max(0, scenario.support - 2));
|
||||
const done = finishNativeWorldPhaseBattle(settled, f);
|
||||
assert.equal(done.turn, 2); assert.equal(done.phase, 7);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(player(done, f.owner).tanks, 6, 'Exactly native fee3N and the three physically committed Cyborgs');
|
||||
assert.equal(player(done, f.owner).elites!.tanks, 3, 'No Cyborg is silently used instead of available normal fee counters');
|
||||
assert.equal(player(done, f.owner).battleLosses, 6);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:ixians'), { normal: 7, elite: 1 },
|
||||
'Ix7N1E pays original3N, then all three undialed Suboids return to native Ix');
|
||||
assert.equal(player(done, f.owner).reserves, 8);
|
||||
assert.deepEqual(combatArmy(done, f.owner, 'arrakeen'), { normal: 0, elite: 0 });
|
||||
assert.equal(player(done, f.owner).forces['hidden_mobile_stronghold:0'], 6);
|
||||
assert.equal(player(done, f.owner).elites!.forces['hidden_mobile_stronghold:0'], 3);
|
||||
assert.equal(player(done, f.opponent).tanks, 3);
|
||||
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
|
||||
assert.equal(done.discard.filter(face => face.id === card).length, 1);
|
||||
if (scenario.tech) {
|
||||
assert.equal(f.game.techTokens!.heighliners.spice, 0, 'Original phase-end shipment income has already settled');
|
||||
assert.ok(done.techTokens, 'Original winner reward/cleanup preserves the actual Tech module');
|
||||
} else assert.equal(done.techTokens, undefined);
|
||||
assertNativeWorldBattleInventory(done, f.afterSetup);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced Tech+Strongholds' : 'Basic Tech'} foreign World: exact original mixed fee cannot borrow future Harass returns`, () => {
|
||||
const f = createNativeWorldPhaseForeignBattleFixture({ advanced });
|
||||
assert.ok(f.game.techTokens);
|
||||
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null,
|
||||
'A Stronghold Card never grants its Arrakis-local benefit on a foreign World');
|
||||
assert.deepEqual(pool(f.game, f.owner, 'homeworld:ixians'), { normal: 1, elite: 4 });
|
||||
assert.deepEqual(f.sources, { 'homeworld:ixians': { normal: 1, elite: 2 } });
|
||||
const before = structuredClone(f.game);
|
||||
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action,
|
||||
reinforcementsSources: { 'homeworld:ixians': { normal: 3, elite: 0 } } }), RuleError);
|
||||
assert.deepEqual(f.game, before);
|
||||
const done = advanceNativeWorldBattle(revealNativeWorldBattle(f), nativeWorldBattleFinished, [], { returns: f.returns });
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:ixians'), { normal: 7, elite: 2 });
|
||||
assert.equal(player(done, f.owner).tanks, 5);
|
||||
assert.equal(player(done, f.owner).elites!.tanks, 2,
|
||||
'The future seven normal returns cannot repay the original two-Cyborg fee');
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
assertNativeWorldBattleInventory(done, f.afterSetup);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced Tech+Strongholds' : 'Basic Tech'} own Ix: Harass stays forbidden and local native fee is disjoint from dial`, () => {
|
||||
const f = createNativeWorldPhaseOwnBattleFixture(advanced), before = structuredClone(f.game);
|
||||
assert.deepEqual(pool(f.game, f.owner, f.location), { normal: 1, elite: 4 });
|
||||
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, defense: HARASS_WITHDRAW_CARD }), RuleError);
|
||||
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, dial: 4.5 }), RuleError,
|
||||
'After local1N2E fee, only two real Cyborgs remain eligible for the dial');
|
||||
assert.deepEqual(f.game, before);
|
||||
const revealed = revealNativeWorldBattle(f), forces = viewGame(revealed, f.owner).battle!.ownForces!;
|
||||
assert.equal(forces.normal, 0); assert.equal(forces.elite, 2);
|
||||
assert.equal(forces.normalFixedHalf, true); assert.equal(forces.eliteStrength, 2);
|
||||
assert.equal(forces.freeSupport, !advanced);
|
||||
const done = advanceNativeWorldBattle(revealed, nativeWorldBattleFinished);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(done, f.owner).tanks, advanced ? 5 : 4);
|
||||
assert.equal(player(done, f.owner).elites!.tanks, advanced ? 4 : 3);
|
||||
assert.ok(player(done, f.owner).hand.some(card => card.id === HARASS_WITHDRAW_CARD));
|
||||
assertNativeWorldBattleInventory(done, f.afterSetup);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced original phase modules: native board-only Cyborg substitution resumes real Face Dance without recovering reserve fees', () => {
|
||||
const f = createNativeWorldPhaseForeignBattleFixture({ typedAftercare: true });
|
||||
const steps: NativeWorldBattleStep[] = [];
|
||||
let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'ixSubstitution', steps,
|
||||
{ casualties: { normal: 0, elite: 1 } });
|
||||
assert.equal(player(game, f.owner).tanks, 4);
|
||||
assert.equal(player(game, f.owner).elites!.tanks, 3);
|
||||
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 9, elite: 0 });
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, f.owner, { type: 'decision', sources: { [f.location]: 3 },
|
||||
recover: { [f.location]: 3 } }), RuleError, 'Two native fee Cyborgs are not board casualty origins');
|
||||
assert.deepEqual(game, before);
|
||||
game = applyAction(game, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } });
|
||||
game = advanceNativeWorldBattle(game, g => g.decision?.kind === 'faceDance', steps);
|
||||
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 8, elite: 1 });
|
||||
assert.equal(player(game, f.owner).elites!.tanks, 2);
|
||||
assert.equal(game.decision?.player, f.opponent);
|
||||
assert.equal(game.decision?.kind === 'faceDance' && game.decision.territory, f.location);
|
||||
assert.equal(viewGame(game, f.opponent).decision?.kind, 'faceDance');
|
||||
game = applyAction(game, f.opponent, { type: 'decision', reveal: true, sources: {} });
|
||||
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished, steps);
|
||||
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(pool(game, f.owner, 'homeworld:ixians'), { normal: 8, elite: 2 });
|
||||
assert.equal(player(game, f.owner).tanks, 4); assert.equal(player(game, f.owner).elites!.tanks, 2);
|
||||
assert.equal(player(game, f.owner).leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
|
||||
assert.ok(steps.some(step => step.action.type === 'decision' && step.action.choice !== undefined),
|
||||
'The genuine native typed casualty decision precedes the separate substitution and Face Dance queues');
|
||||
assertNativeWorldBattleInventory(game, f.afterSetup);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, RuleError, viewGame } from '../game/engine';
|
||||
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
|
||||
import {
|
||||
createRicheseReinforcementsFixture, finishRicheseReinforcementsBattle,
|
||||
prepareRicheseReinforcementsPlan, RICHESE_REINFORCEMENTS,
|
||||
richeseReinforcementsPlayer, sealRicheseReinforcementsPlan,
|
||||
} from './fixture-richese-reinforcements';
|
||||
|
||||
function markerFixture(advanced: boolean) {
|
||||
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true,
|
||||
tech: true, strongholds: advanced, expansions: ['choam'], factions: ['richese', 'guild', 'atreides'] });
|
||||
return createRicheseReinforcementsFixture({ advanced, family: 'choam', marker: 3,
|
||||
...(advanced ? {} : { reserves: 5 }), initial: setup.initial });
|
||||
}
|
||||
|
||||
void test('Basic native World Tech income cannot fund a marker fee from future withdrawal counters', () => {
|
||||
const f = markerFixture(false);
|
||||
const before = structuredClone(f.game);
|
||||
const preview = viewGame(f.game, f.richese).battle!.reinforcements!;
|
||||
assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 2 });
|
||||
assert.ok(preview.blocked);
|
||||
const raw = { ...f.plans.reinforcements.action };
|
||||
delete raw.reinforcementsSources;
|
||||
assert.throws(() => applyAction(f.game, f.richese, raw), RuleError);
|
||||
assert.deepEqual(f.game, before);
|
||||
const first = prepareRicheseReinforcementsPlan(f.game, f.plans.harass);
|
||||
const done = finishRicheseReinforcementsBattle(sealRicheseReinforcementsPlan(first, f.plans.opponent));
|
||||
const owner = richeseReinforcementsPlayer(done, f.richese);
|
||||
assert.equal(owner.reserves, 3);
|
||||
assert.equal(owner.tanks, 2);
|
||||
assert.ok(owner.hand.some(card => card.id === RICHESE_REINFORCEMENTS));
|
||||
});
|
||||
|
||||
void test('Advanced native World Tech and Strongholds keep marker materialization and the later reserve fee disjoint', () => {
|
||||
const f = markerFixture(true);
|
||||
const first = prepareRicheseReinforcementsPlan(f.game, f.plans.reinforcements);
|
||||
assert.equal(richeseReinforcementsPlayer(first, f.richese).reserves, 20);
|
||||
assert.ok(richeseReinforcementsPlayer(first, f.richese).noField!.deployed);
|
||||
const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent);
|
||||
assert.equal(richeseReinforcementsPlayer(revealed, f.richese).reserves, 17);
|
||||
assert.equal(richeseReinforcementsPlayer(revealed, f.richese).tanks, 0);
|
||||
const done = finishRicheseReinforcementsBattle(revealed);
|
||||
const owner = richeseReinforcementsPlayer(done, f.richese);
|
||||
assert.equal(owner.reserves, 14);
|
||||
assert.equal(owner.tanks, 6);
|
||||
const home = viewGame(done, f.richese).homeworlds?.worlds?.find(world => world.native === f.richese);
|
||||
assert.deepEqual(home?.forces[f.richese], { normal: 14, elite: 0 });
|
||||
assert.equal(done.discard.filter(card => card.id === RICHESE_REINFORCEMENTS).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, RuleError, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import {
|
||||
advanceNativeWorldRecruits, assertNativeWorldRecruitsInventory, createNativeWorldRecruitsFixture,
|
||||
nativeWorldRecruitsClean, nativeWorldRecruitsPlayer as player, nextNativeWorldRecruitsStep,
|
||||
requestNativeWorldRecruits, type NativeWorldRecruitsStep,
|
||||
} from './fixture-native-world-recruits';
|
||||
|
||||
const play = { type: 'card', card: RECRUITS_CARD_ID } as const;
|
||||
const wallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const before = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, actor, action), RuleError);
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
}
|
||||
function axlotlProgramme(start: Game, native: string) {
|
||||
const token = start.techTokens!.axlotl;
|
||||
const owner = token.owner;
|
||||
assert.ok(owner, 'The actual first Storm must have assigned Axlotl');
|
||||
assert.notEqual(owner, native, 'The invader took any original native token in the real battle');
|
||||
assert.equal(token.spice, 0);
|
||||
assert.notEqual(token.triggeredTurn, start.turn);
|
||||
assert.ok(Object.values(start.techTokens!).every(t => t.owner !== null));
|
||||
const income = ownedTech(start.techTokens, owner).length;
|
||||
assert.ok(income > 0);
|
||||
const ownerWallet = player(start, owner).spice;
|
||||
const accrued = (game: Game) => {
|
||||
assert.equal(game.techTokens!.axlotl.owner, owner);
|
||||
assert.equal(game.techTokens!.axlotl.triggeredTurn, start.turn);
|
||||
assert.equal(game.techTokens!.axlotl.spice, income,
|
||||
'Further eligible free requests must not accrue another original phase payment');
|
||||
assert.equal(player(game, owner).spice, ownerWallet,
|
||||
'Accrued Axlotl income is not spendable personal spice before the phase ends');
|
||||
assertNativeWorldRecruitsInventory(game);
|
||||
};
|
||||
const finish = (game: Game) => {
|
||||
accrued(game);
|
||||
const bribes = viewGame(game, owner).bribeOptions;
|
||||
assert.equal(bribes.blocked, null);
|
||||
assert.equal(bribes.available, ownerWallet);
|
||||
rejectsUnchanged(game, owner, { type: 'bribe', target: native, amount: ownerWallet + income });
|
||||
let beforePhaseEnd: Game | undefined;
|
||||
let phaseEndStep: NativeWorldRecruitsStep | undefined;
|
||||
for (let count = 0; count < 100 && game.phase === 4; count++) {
|
||||
const step = nextNativeWorldRecruitsStep(game);
|
||||
assert.ok(step);
|
||||
const before = structuredClone(game);
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
if (game.phase !== 4) { beforePhaseEnd = before; phaseEndStep = step; }
|
||||
else accrued(game);
|
||||
}
|
||||
assert.ok(beforePhaseEnd && phaseEndStep, 'The actual final ready must end Revival');
|
||||
accrued(beforePhaseEnd);
|
||||
assert.equal(game.turn, 2);
|
||||
assert.equal(game.phase, 5);
|
||||
const afterPhaseEnd = structuredClone(game);
|
||||
const expectedWallets = wallets(beforePhaseEnd);
|
||||
expectedWallets[owner] += income;
|
||||
assert.deepEqual(wallets(afterPhaseEnd), expectedWallets);
|
||||
assert.equal(afterPhaseEnd.techTokens!.axlotl.spice, 0);
|
||||
assert.equal(afterPhaseEnd.techTokens!.axlotl.triggeredTurn, 2);
|
||||
assert.equal(player(afterPhaseEnd, native).revived, player(beforePhaseEnd, native).revived);
|
||||
assert.equal(player(afterPhaseEnd, native).freeForcesRevived,
|
||||
player(beforePhaseEnd, native).freeForcesRevived);
|
||||
const shipmentSteps: { actor: string; action: Action }[] = [];
|
||||
const afterShipment = advanceNativeWorldRecruits(afterPhaseEnd,
|
||||
g => g.turn === 2 && g.phase === 6 && nativeWorldRecruitsClean(g), shipmentSteps);
|
||||
assert.ok(shipmentSteps.some(step => step.action.type === 'endMovement'),
|
||||
'Finish the actual next Shipment, not just its opening boundary');
|
||||
assert.deepEqual(wallets(afterShipment), expectedWallets,
|
||||
'Original Shipment continuation cannot pay Axlotl a second time');
|
||||
assert.equal(afterShipment.techTokens!.axlotl.spice, 0);
|
||||
assert.equal(afterShipment.techTokens!.axlotl.triggeredTurn, 2);
|
||||
for (const boundary of [beforePhaseEnd, afterPhaseEnd, afterShipment])
|
||||
assertNativeWorldRecruitsInventory(boundary);
|
||||
return { beforePhaseEnd, phaseEndStep, afterPhaseEnd, afterShipment };
|
||||
};
|
||||
return { owner, income, accrued, finish };
|
||||
}
|
||||
|
||||
for (const options of [
|
||||
{ name: 'Basic native Ix + Tech', advanced: false, strongholds: false },
|
||||
{ name: 'Advanced native Ix + Tech + original Strongholds', advanced: true, strongholds: true },
|
||||
]) {
|
||||
void test(`${options.name}: current typed rates, paid ledger and delayed once-only Axlotl survive Recruits`, () => {
|
||||
const f = createNativeWorldRecruitsFixture({ ...options, tech: true, faction: 'ixians' });
|
||||
const native = f.native;
|
||||
const programme = axlotlProgramme(f.game, native);
|
||||
assert.deepEqual({ free: viewGame(f.game, native).revival.freeRemaining,
|
||||
bonus: viewGame(f.game, native).revival.homeworldBonus }, { free: 2, bonus: 1 });
|
||||
const paid = requestNativeWorldRecruits(f.game, native, 3, 1, 0);
|
||||
assert.equal(paid.price, 3, 'Two free suboids leave the original three-spice paid cyborg');
|
||||
assert.equal(paid.walletDelta, 3);
|
||||
assert.equal(paid.free, 2);
|
||||
assert.deepEqual(paid.nativeAfter, { normal: 2, elite: 1 });
|
||||
programme.accrued(paid.game);
|
||||
let game = applyAction(paid.game, native, { type: 'ready' });
|
||||
assert.ok(game.ready.includes(native));
|
||||
const beforePlayWallets = wallets(game);
|
||||
game = applyAction(game, f.owner, play);
|
||||
assert.deepEqual(wallets(game), beforePlayWallets, 'Reopening never refunds the prior paid cyborg');
|
||||
assert.equal(player(game, native).revived, 3);
|
||||
assert.equal(player(game, native).freeForcesRevived, 2);
|
||||
assert.equal(viewGame(game, native).revival.freeRemaining, 2);
|
||||
const crossing = requestNativeWorldRecruits(game, native, 2, 2, 2);
|
||||
assert.equal(crossing.price, 0);
|
||||
assert.equal(crossing.walletDelta, 0);
|
||||
assert.equal(crossing.free, 2);
|
||||
assert.deepEqual(crossing.nativeAfter, { normal: 2, elite: 3 });
|
||||
programme.accrued(crossing.game);
|
||||
const rate = viewGame(crossing.game, native).revival;
|
||||
assert.equal(rate.homeworldBonus, 0);
|
||||
assert.equal(rate.freeRemaining, 0, 'A separate request reads the current population-five World');
|
||||
assert.equal(rate.eliteRemaining, 1, 'The native multi-cyborg allowance remains in both modes');
|
||||
rejectsUnchanged(crossing.game, native, { type: 'revive', amount: 2, elite: 2 });
|
||||
const last = requestNativeWorldRecruits(crossing.game, native, 2, 1, 0);
|
||||
assert.equal(last.price, 5);
|
||||
assert.equal(last.walletDelta, 5);
|
||||
assert.equal(last.free, 0);
|
||||
assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 });
|
||||
assert.equal(player(last.game, native).spice, player(f.game, native).spice - 8);
|
||||
assert.equal(player(last.game, native).revived, 7);
|
||||
assert.equal(player(last.game, native).freeForcesRevived, 4);
|
||||
assert.equal(last.game.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
|
||||
rejectsUnchanged(last.game, native, { type: 'revive', amount: 1, elite: 0 });
|
||||
programme.accrued(last.game);
|
||||
const completed = programme.finish(last.game);
|
||||
assert.equal(player(completed.afterPhaseEnd, native).spice, player(f.game, native).spice - 8);
|
||||
if (options.strongholds) {
|
||||
assert.equal(f.afterSetup.strongholdCards!.claimedTurn, 0);
|
||||
assert.ok(Object.values(f.afterSetup.strongholdCards!.owners).every(owner => owner === null));
|
||||
assert.equal(f.game.strongholdCards!.claimedTurn, 1, 'Actual first end-Mentat earns original custody');
|
||||
assert.deepEqual(completed.afterShipment.strongholdCards, f.game.strongholdCards,
|
||||
'No held card is fabricated or reassigned by native Revival or the following Shipment');
|
||||
} else assert.equal(f.game.strongholdCards, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced native CHOAM + Tech: unlimited paid/free returns retain their ledgers and one delayed Axlotl payout', () => {
|
||||
const f = createNativeWorldRecruitsFixture({ advanced: true, faction: 'choam', tech: true });
|
||||
const native = f.native;
|
||||
const programme = axlotlProgramme(f.game, native);
|
||||
const first = requestNativeWorldRecruits(f.game, native, 8);
|
||||
assert.equal(first.price, 7);
|
||||
assert.equal(first.walletDelta, 7);
|
||||
assert.equal(first.free, 1);
|
||||
programme.accrued(first.game);
|
||||
const beforePlayWallets = wallets(first.game);
|
||||
let game = applyAction(first.game, f.owner, play);
|
||||
assert.deepEqual(wallets(game), beforePlayWallets, 'Seven paid returns stay paid when Recruits doubles free use');
|
||||
assert.equal(viewGame(game, native).revival.limit, 20, 'Recruits never reduces native unlimited to seven');
|
||||
assert.equal(viewGame(game, native).revival.forcesRemaining, 12);
|
||||
assert.equal(viewGame(game, native).revival.freeRemaining, 1);
|
||||
const free = requestNativeWorldRecruits(game, native, 1);
|
||||
assert.equal(free.price, 0);
|
||||
assert.equal(free.walletDelta, 0);
|
||||
assert.equal(free.free, 1);
|
||||
programme.accrued(free.game);
|
||||
const paid = requestNativeWorldRecruits(free.game, native, 3);
|
||||
assert.equal(paid.price, 3, 'Native CHOAM keeps its original one-spice price after free use');
|
||||
assert.equal(paid.walletDelta, 3);
|
||||
assert.equal(paid.free, 0);
|
||||
game = paid.game;
|
||||
assert.deepEqual(paid.nativeAfter, { normal: 12, elite: 0 });
|
||||
assert.equal(player(game, native).revived, 12);
|
||||
assert.equal(player(game, native).freeForcesRevived, 2);
|
||||
assert.equal(player(game, native).spice, player(f.game, native).spice - 10);
|
||||
assert.equal(viewGame(game, native).revival.forcesRemaining, 8);
|
||||
assert.equal(viewGame(game, native).revival.homeworldBonus, 0);
|
||||
programme.accrued(game);
|
||||
const completed = programme.finish(game);
|
||||
assert.equal(player(completed.afterPhaseEnd, native).spice, player(f.game, native).spice - 10);
|
||||
});
|
||||
Reference in new issue
Block a user