Compose classic independent cards with World Nexus rules

Retain independent36, original Homeworlds and Nexus12 through classic fresh setup with optional Skills and phase modules. Resolve own-World Reinforcements fees before computing temporary Sardaukar roles, preserving original Cunning declarations, printed traits and physical counters. Record the provisional ordinary-nonrole-first policy and align human/AI quotes and captions.

Validation: types/lint/build, 128 affected tests across 15 files, bounded rule-source review and finite original-action smoke for Recruits, Harass, local-fee Cunning, all four legal policies and rendered source controls. Verified runtime c219ec587a54b44079539168d93835a5af904c8afb4de58bcec0a3d60c809f0c; documentation-only proof update 8ee21598e7be0c89a08fbad50786c346ebc8ae705492d8b31ea00fae0ffefa3c. Full modes, combinations, assurance and deployed acceptance remain open. No saved-room/provider changes.
This commit is contained in:
Quintine committed 2026-10-18 10:20:08 +11:00
1 parent 178251ad35
commit 97c2b67650
18 files changed
+737 -30

No files matched your search

+12 -3
View File
@@ -5,12 +5,20 @@ import { combatArmy } from '../game/combat-location';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
import { advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import { classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export type WorldNexusBattleStep = ClassicIndependentNexusStep;
export type WorldNexusBattleOptions = { advanced?: boolean; skills?: boolean; tech?: boolean; strongholds?: boolean };
export type WorldNexusBattleOptions = {
advanced?: boolean;
skills?: boolean;
tech?: boolean;
strongholds?: boolean;
/** Opt in before the original deal; default fixtures retain base33. */
independent?: boolean;
};
type WorldNexusInvasion = { before: Game; action: Action; cost: number; after: Game };
export const worldNexusClean = (game: Game) => classicDiscoveryNexusClean(game) &&
!game.pendingGuildBetrayal && !game.pendingNexusHarkonnenBetrayal && !game.pendingNexusIxianBetrayal;
@@ -59,7 +67,8 @@ function invade(game: Game, actor: string, location: string, amount: number, act
* route exports four actual stars to an empty desert, never converts them. */
export function createWorldNexusEmperorBattle(options: WorldNexusBattleOptions & { salusa?: 'high' | 'low' } = {}) {
const fixture = drawClassicNexusReaction('emperor', 'emperor', { ...options, advanced: true,
independent: false, homeworlds: true, roster: ['atreides', 'guild', 'emperor'],
independent: options.independent ?? false, homeworlds: true, roster: ['atreides', 'guild', 'emperor'],
startingCards: options.independent ? { emperor: [REINFORCEMENTS_CARD] } : undefined,
training: options.skills ? { emperor: 'planetologist', atreides: 'warmaster', guild: 'sandmaster' } : undefined });
const actions = fixture.actions, location = 'homeworld:emperor', trained = options.skills ? ['emperor'] : [];
let game = advanceWorldNexusBattle(fixture.game, state => state.phase === 5 && worldNexusClean(state), actions);
@@ -114,7 +123,7 @@ export function createWorldNexusEmperorBattle(options: WorldNexusBattleOptions &
* ordinary call, while the invader owns the independently drawn cancel card. */
export function createWorldNexusHarkonnenBattle(options: WorldNexusBattleOptions = {}) {
const fixture = drawClassicNexusReaction('harkonnen', 'atreides', { ...options,
independent: false, homeworlds: true, roster: ['atreides', 'harkonnen', 'guild'],
independent: options.independent ?? false, homeworlds: true, roster: ['atreides', 'harkonnen', 'guild'],
training: options.skills ? { harkonnen: 'planetologist', atreides: 'warmaster', guild: 'sandmaster' } : undefined });
const actions = fixture.actions, location = 'homeworld:harkonnen';
let game = advanceWorldNexusBattle(fixture.game, state => state.phase === 5 && state.active === 'atreides' &&
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import {
createClassicIndependentNexusFixture, advanceClassicIndependentNexus,
type ClassicIndependentNexusOptions, type ClassicIndependentNexusStep,
} from './fixture-classic-independent-nexus';
import { applyClassicDiscoveryNexusStep, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export type WorldIndependentCardStep = ClassicIndependentNexusStep;
export type WorldIndependentCardOptions = Pick<ClassicIndependentNexusOptions, 'advanced' | 'skills' | 'tech' | 'strongholds'>;
export const worldIndependentCardPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const worldIndependentCardsClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
!game.pendingNexusHarkonnenBetrayal && !game.pendingNexusIxianBetrayal;
/** Only still-unopened original Spice Cards are ordered. Neither a clock nor
* any army, payment, card hand or battle outcome is assigned. */
function orderOriginalLandBlows(game: Game, turns: number): void {
for (let index = 0; index < turns * (game.advanced ? 2 : 1); index++) {
const source = game.spiceDeck.findIndex((card, position) => position >= index && 'territory' in card && !card.discovery);
assert.ok(source >= index);
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(source, 1)[0]);
}
}
function nextWorldIndependentCardStep(game: Game): WorldIndependentCardStep | null {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'harassWithdraw') return null;
if (decision?.kind === 'homeworldDefense')
return { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } };
if (decision?.kind === 'homeworldExplosion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, choice: 0 } };
if (decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
}
return nextClassicDiscoveryNexusStep(game);
}
export function actWorldIndependentCard(game: Game, actor: string, action: Action,
actions?: WorldIndependentCardStep[]): Game {
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
}
/** Reuse public native controls and legal Nexus acknowledgement. Sealed plans
* and typed Harass allocations stay explicit human-owned actions. */
export function advanceWorldIndependentCards(game: Game, until: (state: Game) => boolean,
actions: WorldIndependentCardStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original game ended before the requested card action');
if (game.pendingGuildBetrayal) {
game = advanceClassicIndependentNexus(game, state => !state.pendingGuildBetrayal, actions);
continue;
}
const step = nextWorldIndependentCardStep(game);
assert.ok(step, `A real human plan/allocation is required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actWorldIndependentCard(game, step.actor, step.action, actions);
}
throw Error('Original Nexus/World card programme did not reach its requested boundary');
}
/** Fresh selected classic independent36/Nexus12/Homeworlds only. The physical
* card is dealt from the original shuffle, not moved out of a played hand. */
export function createWorldIndependentRecruitsFixture(options: WorldIndependentCardOptions = {}) {
const source = createClassicIndependentNexusFixture({ ...options, independent: true, homeworlds: true,
roster: ['emperor', 'fremen', 'atreides', 'guild'], startingCards: { emperor: [RECRUITS_CARD_ID] } });
let game = structuredClone(source.game);
const afterSetup = structuredClone(game), actions = source.actions;
orderOriginalLandBlows(game, 2);
const staging = [...source.staging, 'Order two original non-worm Spice Blows before they are drawn; hold the genuinely dealt Recruits through real native battles.'];
const act = (actor: string, action: Action) => { game = actWorldIndependentCard(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceWorldIndependentCards(game, until, actions); };
const shipments = new Map<string, { amount: number; elite: number; territory: string; sector: number }>([
['emperor', { amount: 3, elite: 0, territory: 'sietch_tabr', sector: 14 }],
['fremen', { amount: 8, elite: 3, territory: 'sietch_tabr', sector: 14 }],
['guild', { amount: 3, elite: 0, territory: 'arrakeen', sector: 10 }],
]);
reach(state => state.phase === 5 && worldIndependentCardsClean(state));
while (game.phase === 5) {
const actor = game.active!, request = worldIndependentCardsClean(game) ? shipments.get(actor) : undefined;
if (request) {
const sources = nativeShipmentSources(viewGame(game, actor), request.amount, request.elite); assert.ok(sources);
act(actor, { type: 'ship', ...request, homeworldSources: sources });
shipments.delete(actor);
} else {
const step = nextWorldIndependentCardStep(game); assert.ok(step); act(step.actor, step.action);
}
}
assert.equal(shipments.size, 0);
reach(state => state.phase === 6 && worldIndependentCardsClean(state));
while (game.phase === 6) {
if (worldIndependentCardsClean(game) && !game.battle && game.active) {
const choice = viewGame(game, game.active).battleChoices[0]; assert.ok(choice);
act(choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
}
if (game.battle && !game.battle.revealed) {
reach(state => worldIndependentCardsClean(state) && !state.battle?.preparation && state.battle?.preLeader?.closed !== false);
const battle = game.battle!;
for (const actor of [battle.attacker, battle.defender]) {
if (game.battle!.plans[actor]) continue;
// Keep optional assigned skill discs out of this focused printed-rate
// case; they remain genuinely assigned in their original physical pool.
const leaders = worldIndependentCardPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
!game.leaderSkills?.assignments.some(assignment => assignment.owner === actor && assignment.leader === leader.id))
.sort((a, b) => a.strength - b.strength);
const leader = actor === 'emperor' || actor === 'guild' ? leaders.at(-1) : leaders[0]; assert.ok(leader);
act(actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
reach(state => worldIndependentCardsClean(state) && !state.battle?.preparation && state.battle?.preLeader?.closed !== false);
}
} else {
const step = nextWorldIndependentCardStep(game); assert.ok(step); act(step.actor, step.action);
}
}
reach(state => state.turn === 2 && state.phase === 4 && worldIndependentCardsClean(state));
assert.equal(worldIndependentCardPlayer(game, 'fremen').reserves, 2);
assert.equal(worldIndependentCardPlayer(game, 'fremen').tanks, 18);
assert.ok(worldIndependentCardPlayer(game, 'emperor').hand.some(card => card.id === RECRUITS_CARD_ID));
return { ...source, afterSetup, game, actions, staging };
}
/** Original typed invasion of a foreign World, or a native defender's legal
* own-World rejection. The Harass card is held from its native starting deal. */
export function createWorldIndependentHarassFixture(options: WorldIndependentCardOptions & { native?: boolean } = {}) {
const source = createClassicIndependentNexusFixture({ ...options, independent: true, homeworlds: true,
roster: ['emperor', 'guild', 'atreides'], startingCards: { emperor: [HARASS_WITHDRAW_CARD] } });
let game = structuredClone(source.game);
const afterSetup = structuredClone(game), actions = source.actions;
orderOriginalLandBlows(game, 1);
const staging = [...source.staging, 'Order the unopened original first non-worm Spice Blow; use real typed native invasion and sealed plans.'];
const owner = 'emperor', native = options.native ? owner : 'guild', visitor = options.native ? 'guild' : owner;
const location = options.native ? 'homeworld:emperor' : 'homeworld:guild';
game = advanceWorldIndependentCards(game, state => state.phase === 5 && state.active === visitor && worldIndependentCardsClean(state), actions);
const beforeShipment = structuredClone(game);
const sources = nativeShipmentSources(viewGame(game, visitor), options.native ? 5 : 7, options.native ? 0 : 4); assert.ok(sources);
const quote = homeworldShipmentChoice(viewGame(game, visitor), location,
Object.fromEntries(Object.entries(sources).filter(([, force]) => force.normal + force.elite > 0)));
assert.ok(quote.action, quote.blocked ?? 'The original native invasion must have a paid typed route');
const shipmentStep: WorldIndependentCardStep = { actor: visitor, action: quote.action };
game = actWorldIndependentCard(game, visitor, quote.action, actions);
game = advanceWorldIndependentCards(game, state => !state.pendingHomeworldShipment && !state.pendingShipment && worldIndependentCardsClean(state), actions);
const afterShipment = structuredClone(game);
assert.deepEqual(combatArmy(game, visitor, location), options.native ? { normal: 5, elite: 0 } : { normal: 3, elite: 4 });
game = advanceWorldIndependentCards(game, state => state.phase === 6 && !!state.active && worldIndependentCardsClean(state), actions);
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(battle => battle.territory === location);
assert.ok(choice);
const battleStep: WorldIndependentCardStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
game = actWorldIndependentCard(game, battleStep.actor, battleStep.action, actions);
game = advanceWorldIndependentCards(game, state => !!state.battle && !state.battle.preparation &&
state.battle.preLeader?.closed !== false && worldIndependentCardsClean(state), actions);
const leaders = (actor: string) => worldIndependentCardPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
!game.leaderSkills?.assignments.some(assignment => assignment.owner === actor && assignment.leader === leader.id))
.sort((a, b) => a.strength - b.strength);
const ownLeader = leaders(owner)[0], otherLeader = leaders('guild').at(-1); assert.ok(ownLeader && otherLeader);
const plans: WorldIndependentCardStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial: options.native ? 0 : 2, support: options.advanced && !options.native ? 1 : 0,
leader: ownLeader.id, defense: HARASS_WITHDRAW_CARD } },
{ actor: 'guild', action: { type: 'battlePlan', dial: options.native ? 0 : 3, support: options.advanced && !options.native ? 3 : 0,
leader: otherLeader.id } },
];
return { ...source, afterSetup, game, actions, staging, owner, native, visitor, location,
beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep, plans,
returns: { [location]: { normal: 2, elite: 3 } } };
}
export function revealWorldIndependentHarass(game: Game, plans: readonly WorldIndependentCardStep[], actions: WorldIndependentCardStep[] = []) {
for (const plan of plans) {
game = actWorldIndependentCard(game, plan.actor, plan.action, actions);
game = advanceWorldIndependentCards(game, state => !!state.battle && !state.battle.preparation &&
state.battle.preLeader?.closed !== false && (!state.decision || state.decision.kind === 'harassWithdraw') &&
!state.phaseOpening && !state.response, actions);
}
assert.ok(game.battle?.revealed); return game;
}
+2 -1
View File
@@ -137,7 +137,8 @@ void test('public pending and active notices avoid private hands and identify or
const allowed = allowNexusSardaukar(g);
const observer = viewGame(allowed, 'q');
assert.equal(observer.battle!.opponentForces?.temporaryElite, 5);
assert.match(html(observer), /Five ordinary Emperor counters/);
assert.match(html(observer), /Cunning declared five ordinary Emperor counters/);
assert.match(html(observer), /own-Homeworld reserve fee can reduce that group/);
assert.match(html(observer), /ordinary Tanks/);
assert.doesNotMatch(html(observer), /<button/);
});
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { combatArmy } from '../game/combat-location';
import { casualtyOptions, maxCombatDial, maxCombatSupport } from '../game/combat';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldPopulations } from '../game/homeworld-population';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { reinforcementsBattleForces } from '../game/reinforcements-homeworld';
import { reactionAction } from './fixture-classic-independent-nexus-reactions';
import {
advanceWorldNexusBattle, createWorldNexusEmperorBattle, revealWorldNexusPlans,
worldNexusClean, worldNexusPlayer, type WorldNexusBattleStep,
} from './fixture-nexus-core-world-battles';
const KAITAIN = 'homeworld:emperor', SALUSA = 'homeworld:emperor:salusa';
const localFee: NativeReserveSelections = { [KAITAIN]: { normal: 3, elite: 0 } };
const remoteFee: NativeReserveSelections = { [SALUSA]: { normal: 3, elite: 0 } };
function declareCunning(game: Game): Game {
const offer = viewGame(game, 'emperor').nexusSardaukar!.offer!;
assert.equal(offer.blocked, null);
game = reactionAction(game, 'emperor', { type: 'nexusSardaukar', event: offer.event });
game = advanceWorldNexusBattle(game, worldNexusClean);
const receipt = game.nexusSardaukarHistory!.at(-1)!.receipt;
assert.equal(receipt.normal, 7);
assert.equal(receipt.count, 5, 'The original declaration remains five roles, not a rewritten four-counter declaration');
return game;
}
function cardPlan(step: WorldNexusBattleStep, slot: 'weapon' | 'defense', sources: NativeReserveSelections): WorldNexusBattleStep {
return { actor: step.actor, action: { ...step.action, [slot]: REINFORCEMENTS_CARD,
reinforcementsSources: sources } };
}
function finish(game: Game): Game {
return advanceWorldNexusBattle(game, state => !state.battle && !state.homeworldBattleLoss && worldNexusClean(state));
}
function checkPhysicalDeal(game: Game): void {
assert.equal(game.deck.length + game.discard.length + game.players.reduce((sum, player) => sum + player.hand.length, 0), 36);
assert.equal(game.nexusCards!.cards!.deck.length + game.nexusCards!.cards!.discard.length +
Object.values(game.nexusCards!.cards!.hands).filter(Boolean).length, 12);
}
for (const salusa of ['high', 'low'] as const) for (const slot of ['weapon', 'defense'] as const) {
void test(`original independent ${slot}, own-Kaitain Cunning and ${salusa} Salusa pay the local fee before physical combat`, () => {
const f = createWorldNexusEmperorBattle({ independent: true, salusa, skills: true, tech: true, strongholds: true });
assert.equal(f.lobby.lobbyOptions?.ecazTreachery, true);
assert.ok(f.lobby.players.every(player => !player.hand.length), 'The requested physical card was ordered before any starting deal');
assert.equal(worldNexusPlayer(f.game, 'emperor').hand[0].id, REINFORCEMENTS_CARD);
assert.ok(f.staging.some(note => note.includes('original unused Treachery shuffle')));
checkPhysicalDeal(f.game);
let game = declareCunning(f.game);
const before = worldNexusPlayer(game, 'emperor'), other = worldNexusPlayer(game, 'guild');
const stars = structuredClone(before.elites), salusaBefore = structuredClone(game.homeworlds!.custody!.salusa);
const own = viewGame(game, 'emperor').battle!;
assert.deepEqual([own.ownForces!.normal, own.ownForces!.elite, own.ownForces!.temporaryElite], [7, 0, 5]);
assert.equal(own.nativeBattleStrength, 2);
assert.equal(game.battle!.leaderSkillHidden!.emperor, true);
assert.deepEqual(own.strongholdEffects, { guild: null, emperor: null });
assert.equal(own.reinforcements!.blocked, null);
const projected = reinforcementsBattleForces(own.ownForces!, localFee[KAITAIN]);
// Provisional composition: the two non-role counters pay first, followed
// by one temporary-role counter. The four survivors all retain the role.
assert.deepEqual([projected.normal, projected.elite, projected.temporaryElite], [4, 0, 4]);
assert.equal(projected.eliteFreeSupport, own.ownForces!.eliteFreeSupport);
assert.equal(!!projected.eliteFreeSupport, salusa === 'high');
assert.equal(maxCombatDial(projected), 8);
assert.equal(maxCombatSupport(projected), salusa === 'high' ? 0 : 4);
assert.deepEqual(casualtyOptions(projected, 4, salusa === 'high' ? 0 : 2), [
{ normal: 2, elite: 0, paidNormal: salusa === 'high' ? 0 : 2, paidElite: 0 },
]);
const ownPlan = cardPlan(f.plans[0], slot, localFee);
assert.throws(() => applyAction(game, 'emperor', { ...ownPlan.action, dial: 9 }),
'The three fee counters cannot also fund the physical dial');
if (salusa === 'high') assert.throws(() => applyAction(game, 'emperor', { ...ownPlan.action, support: 1 }),
'There are no remaining ordinary non-role counters to pay support');
game = revealWorldNexusPlans(game, [ownPlan, f.plans[1]]);
assert.equal(viewGame(game, 'emperor').battle!.nativeBattleStrength, 2, 'The printed high face belongs to the original battle');
game = finish(game);
assert.equal(game.lastBattleContext!.winner, 'emperor');
assert.equal(game.lastBattleContext!.result, 'normal');
assert.deepEqual(combatArmy(game, 'emperor', KAITAIN), { normal: 2, elite: 0 });
assert.deepEqual(combatArmy(game, 'guild', KAITAIN), { normal: 0, elite: 0 });
const after = worldNexusPlayer(game, 'emperor');
assert.equal(after.reserves, before.reserves - 5);
assert.equal(after.tanks, before.tanks + 5, 'Three native fee counters and two real dial casualties, with no virtual bonus casualties');
assert.equal(after.battleLosses, before.battleLosses + 5);
assert.equal(worldNexusPlayer(game, 'guild').tanks, other.tanks + 3);
assert.equal(after.spice, before.spice - (salusa === 'high' ? 0 : 2));
assert.deepEqual(after.elites, stars, 'Temporary roles never become or kill actual stars');
assert.deepEqual(game.homeworlds!.custody!.salusa, salusaBefore);
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!).find(home => home.location === KAITAIN)!;
assert.deepEqual([home.population, home.side], [2, 'low']);
const record = game.nexusSardaukarHistory!.at(-1)!;
assert.equal(record.receipt.count, 5);
assert.equal(record.receipt.normal, 7);
assert.deepEqual(record.casualties!.reserveFee, { normal: 3, elite: 0 });
assert.deepEqual([record.casualties!.forces.normal, record.casualties!.forces.elite,
record.casualties!.forces.temporaryElite], [4, 0, 4]);
assert.equal(record.casualties!.outcome, 'complete');
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
checkPhysicalDeal(game);
});
}
void test('an original Salusa-only fee leaves all five Kaitain Cunning roles available', () => {
const f = createWorldNexusEmperorBattle({ independent: true, salusa: 'high' });
let game = declareCunning(f.game);
const before = worldNexusPlayer(game, 'emperor'), stars = structuredClone(before.elites);
const salusaBefore = structuredClone(game.homeworlds!.custody!.salusa!);
const own = viewGame(game, 'emperor').battle!.ownForces!;
const projected = reinforcementsBattleForces(own, remoteFee[KAITAIN]);
assert.deepEqual([projected.normal, projected.elite, projected.temporaryElite], [7, 0, 5]);
assert.equal(maxCombatDial(projected), 12);
assert.equal(maxCombatSupport(projected), 2);
game = revealWorldNexusPlans(game, [cardPlan(f.plans[0], 'weapon', remoteFee), f.plans[1]]);
game = finish(game);
assert.equal(game.lastBattleContext!.winner, 'emperor');
assert.deepEqual(combatArmy(game, 'emperor', KAITAIN), { normal: 5, elite: 0 });
assert.deepEqual(game.homeworlds!.custody!.salusa, { normal: salusaBefore.normal - 3, elite: salusaBefore.elite });
assert.equal(worldNexusPlayer(game, 'emperor').tanks, before.tanks + 5);
assert.deepEqual(worldNexusPlayer(game, 'emperor').elites, stars);
const record = game.nexusSardaukarHistory!.at(-1)!;
assert.equal(record.casualties!.forces.temporaryElite, 5);
assert.equal(record.casualties!.reserveFee, undefined, 'An off-World fee never rewrites the original temporary-role army');
assert.equal(record.casualties!.outcome, 'complete');
checkPhysicalDeal(game);
});
for (const salusa of ['high', 'low'] as const) {
void test(`all four minimal legal policies emit positive source-bearing Reinforcements plans with ${salusa} Salusa and original Cunning`, () => {
const f = createWorldNexusEmperorBattle({ independent: true, salusa });
const game = declareCunning(f.game);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, 'emperor');
view.players.find(player => player.id === 'emperor')!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'battlePlan' && Number(action.dial) > 0 &&
(action.weapon === REINFORCEMENTS_CARD || action.defense === REINFORCEMENTS_CARD));
assert.ok(action, `${difficulty} must retain a positive physical card path`);
const sources = action.reinforcementsSources as NativeReserveSelections | undefined;
assert.ok(sources && Object.keys(sources).length, `${difficulty} must declare actual native sources`);
const pool = reinforcementsBattleForces(view.battle!.ownForces!, sources[KAITAIN]);
assert.ok(casualtyOptions(pool, Number(action.dial), Number(action.support ?? 0)).length,
`${difficulty} must fund the physical dial after its own fee`);
const sealed = applyAction(game, 'emperor', action);
assert.ok(sealed.battle!.plans.emperor);
}
});
}
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
import { combatArmy } from '../game/combat-location';
import { RECRUITS_CARD_ID, recruitsPlayAction } from '../game/recruits';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { createClassicIndependentNexusFixture, type ClassicIndependentNexusOptions } from './fixture-classic-independent-nexus';
import {
actWorldIndependentCard, advanceWorldIndependentCards, createWorldIndependentHarassFixture,
createWorldIndependentRecruitsFixture, revealWorldIndependentHarass,
worldIndependentCardPlayer as player, worldIndependentCardsClean,
} from './fixture-nexus-world-independent-cards';
function stock(game: Game): void {
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(card => card.id).sort(),
[...treacheryDeck([]), ...ecazTreacheryCards()].map(card => card.id).sort());
const nexus = game.nexusCards!.cards!;
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(card => card !== null)].sort(),
[...NEXUS_FACTIONS].sort());
if (game.leaderSkills) assert.deepEqual([...game.leaderSkills.deck,
...game.leaderSkills.assignments.map(assignment => assignment.skill),
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort(),
LEADER_SKILL_CARDS.map(card => card.id).sort());
}
function worldPool(game: Game, actor: string, location: string) {
const world = viewGame(game, actor).homeworlds!.worlds!.find(world => world.id === location); assert.ok(world);
return { ...(world.forces[actor] ?? { normal: 0, elite: 0 }) };
}
const setupSamples: Array<ClassicIndependentNexusOptions & { roster: NonNullable<ClassicIndependentNexusOptions['roster']> }> = [
{ roster: ['emperor', 'guild'] },
{ roster: ['emperor', 'guild', 'atreides'], tech: true },
{ roster: ['emperor', 'fremen', 'atreides', 'guild'], advanced: true, skills: true, tech: true, strongholds: true },
{ roster: ['emperor', 'fremen', 'atreides', 'guild', 'harkonnen', 'beneGesserit'], advanced: true, skills: true, tech: true, strongholds: true },
];
for (const sample of setupSamples) {
void test(`${sample.advanced ? 'Advanced all14/phase' : 'Basic'} ${sample.roster.length}-seat native start selects independent36, World custody and Nexus12 without early claims`, () => {
const fixture = createClassicIndependentNexusFixture({ ...sample, independent: true, homeworlds: true });
const { lobby, setup, game } = fixture;
assert.deepEqual(lobby.expansions, []);
assert.equal(lobby.lobbyOptions?.ecazTreachery, true);
assert.deepEqual(lobby.homeworlds, { custody: null });
assert.deepEqual(lobby.nexusCards, { cards: null, phase: null });
assert.equal(lobby.leaderSkills?.deck.length ?? 0, 0, 'No skill is manufactured before the selected native setup');
if (sample.tech) assert.deepEqual(lobby.techTokens, createTechTokens());
if (sample.strongholds) assert.deepEqual(lobby.strongholdCards, createStrongholdCards());
assert.equal(setup.status, 'setup');
assert.equal(game.status, 'playing');
assert.ok(game.homeworlds!.custody);
assert.equal(game.nexusCards!.cards!.deck.length, 12);
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
assert.equal(game.nexusCards!.phase, null);
if (sample.skills) {
assert.equal(game.leaderSkills!.assignments.length, sample.roster.length);
assert.deepEqual(game.leaderSkills!.offers, {});
} else assert.ok(!game.leaderSkills);
if (sample.strongholds) assert.deepEqual(game.strongholdCards, createStrongholdCards());
assert.ok(fixture.actions.some(step => step.action.type === 'start'));
assert.ok(fixture.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'ecazTreachery'));
stock(game);
});
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} genuine Recruits deals recheck the native Fremen population on each return`, () => {
const fixture = createWorldIndependentRecruitsFixture({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
const initial = fixture.game, spice = player(initial, 'fremen').spice;
assert.equal(initial.turn, 2);
assert.equal(initial.phase, 4);
assert.equal(player(initial, 'fremen').tanks, 18, 'Actual zero-dial first-turn defeat supplied the counters');
assert.equal(player(initial, 'fremen').reserves, 2);
assert.deepEqual(worldPool(initial, 'fremen', 'homeworld:fremen'), { normal: 2, elite: 0 });
const action = recruitsPlayAction(viewGame(initial, 'emperor').recruitsPreview); assert.ok(action);
assert.deepEqual(action, { type: 'card', card: RECRUITS_CARD_ID });
const played = actWorldIndependentCard(initial, 'emperor', action);
const before = viewGame(played, 'fremen');
assert.equal(before.revival.homeworldBonus, 1);
assert.equal(before.recruitsPreview!.rates.find(rate => rate.player === 'fremen')!.freeRate, 7);
assert.throws(() => applyAction(played, 'fremen', { type: 'revive', amount: 8, elite: 0 }));
const together = actWorldIndependentCard(played, 'fremen', { type: 'revive', amount: 7, elite: 0 });
assert.deepEqual(worldPool(together, 'fremen', 'homeworld:fremen'), { normal: 9, elite: 0 });
assert.equal(player(together, 'fremen').freeForcesRevived, 7);
assert.equal(player(together, 'fremen').spice, spice);
assert.equal(player(together, 'fremen').tanks, 11);
let separate = actWorldIndependentCard(played, 'fremen', { type: 'revive', amount: 1, elite: 0 });
assert.deepEqual(worldPool(separate, 'fremen', 'homeworld:fremen'), { normal: 3, elite: 0 });
const changed = viewGame(separate, 'fremen');
assert.equal(changed.revival.homeworldBonus, 0);
assert.equal(changed.revival.freeRemaining, 5);
assert.equal(changed.recruitsPreview!.rates.find(rate => rate.player === 'fremen')!.freeRate, 6);
assert.throws(() => applyAction(separate, 'fremen', { type: 'revive', amount: 6, elite: 0 }));
separate = actWorldIndependentCard(separate, 'fremen', { type: 'revive', amount: 5, elite: 0 });
assert.deepEqual(worldPool(separate, 'fremen', 'homeworld:fremen'), { normal: 8, elite: 0 });
assert.equal(player(separate, 'fremen').freeForcesRevived, 6);
assert.equal(player(separate, 'fremen').spice, spice);
assert.equal(viewGame(separate, 'fremen').revival.forcesRemaining, 0);
assert.throws(() => applyAction(separate, 'fremen', { type: 'revive', amount: 1, elite: 0 }));
assert.equal(separate.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
separate = advanceWorldIndependentCards(separate, state => state.phase === 5 && worldIndependentCardsClean(state));
assert.equal(separate.turn, 2);
stock(separate);
});
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} genuinely dealt Harass returns typed foreign World visitors through native destinations`, () => {
const fixture = createWorldIndependentHarassFixture({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
assert.equal(fixture.shipmentStep.action.type, 'homeworldShip');
assert.deepEqual(combatArmy(fixture.afterShipment, fixture.visitor, fixture.location), { normal: 3, elite: 4 });
let game = revealWorldIndependentHarass(fixture.game, fixture.plans);
assert.equal(game.decision?.kind, 'harassWithdraw');
const allocation = viewGame(game, fixture.owner).battle!.harassAllocation!;
assert.deepEqual(allocation.context.locations, { [fixture.location]: { normal: 3, elite: 4 } });
const before = player(game, fixture.owner), kaitain = worldPool(game, fixture.owner, 'homeworld:emperor');
const salusa = advanced ? worldPool(game, fixture.owner, 'homeworld:emperor:salusa') : null;
assert.ok(game.decision && 'event' in game.decision);
game = actWorldIndependentCard(game, fixture.owner, { type: 'decision', event: game.decision.event, returns: fixture.returns });
assert.deepEqual(viewGame(game, fixture.owner).battle!.traitorVoters, [fixture.native]);
game = advanceWorldIndependentCards(game, state => !state.battle && !state.homeworldBattleLoss && worldIndependentCardsClean(state));
const after = player(game, fixture.owner);
assert.equal(game.lastBattleContext!.territory, fixture.location);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(game.lastBattleContext!.winner, fixture.native);
assert.equal(after.reserves, before.reserves + 5);
assert.equal(after.elites!.reserves, before.elites!.reserves + 3);
assert.equal(after.tanks, before.tanks + 2);
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
assert.deepEqual(combatArmy(game, fixture.owner, fixture.location), { normal: 0, elite: 0 });
assert.deepEqual(worldPool(game, fixture.owner, 'homeworld:emperor'), {
normal: kaitain.normal + 2, elite: kaitain.elite + (advanced ? 0 : 3),
});
if (salusa) assert.deepEqual(worldPool(game, fixture.owner, 'homeworld:emperor:salusa'), {
normal: salusa.normal, elite: salusa.elite + 3,
});
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
game = advanceWorldIndependentCards(game, state => state.phase === 7 && worldIndependentCardsClean(state));
stock(game);
});
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} native own World still forbids the genuinely dealt Harass`, () => {
const fixture = createWorldIndependentHarassFixture({ advanced, native: true, skills: advanced, tech: advanced, strongholds: advanced });
assert.equal(fixture.native, fixture.owner);
assert.match(viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!.blocked!, /own.*Homeworld/i);
assert.throws(() => applyAction(fixture.game, fixture.owner, fixture.plans[0].action), /own.*Homeworld/i);
assert.ok(player(fixture.game, fixture.owner).hand.some(card => card.id === HARASS_WITHDRAW_CARD));
assert.deepEqual(fixture.game.battle!.plans, {});
const plans = fixture.plans.map(plan => plan.actor === fixture.owner
? { ...plan, action: { ...plan.action, defense: null } } : plan);
let game = revealWorldIndependentHarass(fixture.game, plans);
assert.deepEqual(viewGame(game, fixture.owner).battle!.traitorVoters, [fixture.owner]);
game = advanceWorldIndependentCards(game, state => !state.battle && !state.homeworldBattleLoss && worldIndependentCardsClean(state));
assert.equal(game.lastBattleContext!.territory, fixture.location);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.ok(player(game, fixture.owner).hand.some(card => card.id === HARASS_WITHDRAW_CARD));
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 0);
stock(game);
});
}
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from '../game/combat';
import { reinforcementsBattleForces } from '../game/reinforcements-homeworld';
function original(normal = 7, freeStars = true): CombatForces {
return {normal,elite:0,temporaryElite:5,eliteStrength:2,freeSupport:false,eliteFreeSupport:freeStars};
}
void test('own-World fee spends unconverted normals first, then removes only paid temporary roles', () => {
const before = original();
assert.deepEqual([1,2,3].map(normal => {
const remaining = reinforcementsBattleForces(before,{normal,elite:0});
return [remaining.normal,remaining.temporaryElite,maxCombatDial(remaining)];
}), [[6,5,11],[5,5,10],[4,4,8]]);
const allConverted = reinforcementsBattleForces(original(5),{normal:3,elite:0});
assert.deepEqual([allConverted.normal,allConverted.temporaryElite,maxCombatDial(allConverted)],[2,2,4]);
assert.deepEqual(before,original(),'The declared five-counter group is unchanged.');
});
void test('reduced temporary force roles retain pre-fee support traits and ordinary physical casualties', () => {
for (const freeStars of [false,true]) {
const remaining = reinforcementsBattleForces(original(7,freeStars),{normal:3,elite:0});
assert.equal(remaining.eliteFreeSupport,freeStars);
assert.equal(maxCombatSupport(remaining),freeStars ? 0 : 4);
const losses = casualtyOptions(remaining,4,freeStars ? 0 : 2);
assert.ok(losses.length);
assert.ok(losses.every(loss => loss.normal === 2 && loss.elite === 0));
}
});
void test('off-World and zero allocations keep the original five roles; impossible local fees reject', () => {
const before = original();
assert.equal(reinforcementsBattleForces(before),before);
assert.equal(reinforcementsBattleForces(before,{normal:0,elite:0}),before);
assert.throws(()=>reinforcementsBattleForces(before,{normal:8,elite:0}));
assert.throws(()=>reinforcementsBattleForces(before,{normal:0,elite:1}));
});