Compose original World Nexus core

This commit is contained in:
Quintine committed 2026-10-18 06:54:54 +11:00
1 parent ce716f3438
commit 8d5c8da6c4
18 files changed
+754 -16

No files matched your search

@@ -49,7 +49,10 @@ export function drawClassicNexusReaction(face: FactionId, owner: string,
return { ...fixture, game, owner, actions, beforeDraw, afterDraw: structuredClone(game) };
}
export function createIndependentHarkonnenCall(options: { advanced?: boolean; skills?: boolean; remote?: boolean; independent?: boolean; tech?: boolean; strongholds?: boolean } = {}) {
type ClassicNexusReactionOptions = Pick<ClassicIndependentNexusOptions,
'advanced' | 'skills' | 'independent' | 'tech' | 'strongholds' | 'homeworlds'>;
export function createIndependentHarkonnenCall(options: ClassicNexusReactionOptions & { remote?: boolean } = {}) {
const counter = baseDeck().find(card => card.effect === 'karama')!.id;
const starting = options.independent === false ? baseDeck().find(card => card.kind === 'worthless')!.id : HARASS_WITHDRAW_CARD;
const fixture = drawClassicNexusReaction('harkonnen', 'atreides', { ...options,
@@ -98,7 +101,7 @@ export function createIndependentHarkonnenCall(options: { advanced?: boolean; sk
return { ...fixture, game, beforeCall, beforeShipment, afterShipment, identity, opponent, key, counter };
}
export function createIndependentIxianPurchase(options: { advanced?: boolean; skills?: boolean; owner?: 'atreides' | 'harkonnen'; independent?: boolean; tech?: boolean; strongholds?: boolean } = {}) {
export function createIndependentIxianPurchase(options: ClassicNexusReactionOptions & { owner?: 'atreides' | 'harkonnen' } = {}) {
const owner = options.owner ?? 'atreides';
const starting = options.independent === false ? baseDeck().find(card => card.kind === 'worthless')!.id : 'ecaz-recruits';
const fixture = drawClassicNexusReaction('ixians', owner, { ...options,
@@ -16,6 +16,7 @@ export type ClassicIndependentNexusOptions = {
independent?: boolean;
tech?: boolean;
strongholds?: boolean;
homeworlds?: boolean;
roster?: readonly FactionId[];
training?: Partial<Record<FactionId, LeaderSkillId>>;
/** Only original, still-undealt starting Treachery slots are ordered. */
@@ -40,6 +41,7 @@ export function createClassicIndependentNexusFixture(options: ClassicIndependent
act(game.host, { type: 'moduleOption', module: option, enabled: true });
if (options.tech) act(game.host, { type: 'techTokens', enabled: true });
if (options.strongholds) act(game.host, { type: 'strongholdCards', enabled: true });
if (options.homeworlds) act(game.host, { type: 'homeworlds', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
+1 -1
View File
@@ -7,7 +7,7 @@ import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixt
import { advanceClassicIndependentNexus, type ClassicIndependentNexusOptions, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
export type ClassicNexusCoreOptions = Pick<ClassicIndependentNexusOptions, 'advanced' | 'skills' | 'independent' | 'training' | 'tech' | 'strongholds'>;
export type ClassicNexusCoreOptions = Pick<ClassicIndependentNexusOptions, 'advanced' | 'skills' | 'independent' | 'training' | 'tech' | 'strongholds' | 'homeworlds'>;
function drawSecondFace(game: Game, owner: string, face: FactionId, actions: ClassicIndependentNexusStep[]) {
assert.equal(game.nexusCards!.phase!.stage, 'drawing');
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face);
+147
View File
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
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 { 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 };
type WorldNexusInvasion = { before: Game; action: Action; cost: number; after: Game };
export const worldNexusClean = (game: Game) => classicDiscoveryNexusClean(game) &&
!game.pendingGuildBetrayal && !game.pendingNexusHarkonnenBetrayal && !game.pendingNexusIxianBetrayal;
export { reactionPlayer as worldNexusPlayer };
/** Continue only existing public controls. Human sealed plans are never invented.
* The named trained disc is concealed through the actual visibility choice. */
export function advanceWorldNexusBattle(game: Game, until: (game: Game) => boolean,
actions: WorldNexusBattleStep[] = [], trained: readonly string[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
if (game.pendingGuildBetrayal) {
game = advanceClassicIndependentNexus(game, state => !state.pendingGuildBetrayal, actions);
continue;
}
const step = nextClassicDiscoveryNexusStep(game);
if (step?.action.type === 'leaderSkillVisibility') step.action.hide = trained.includes(step.actor);
if (step) game = reactionAction(game, step.actor, step.action, actions);
else game = advanceClassicIndependentNexus(game, until, actions);
}
throw Error('Original World Nexus battle did not reach its owned boundary');
}
function chooseWorldBattle(game: Game, location: string, actions: WorldNexusBattleStep[], trained: readonly string[]) {
const choice = viewGame(game, game.active!).battleChoices.find(battle => battle.territory === location);
assert.ok(choice, 'The paid invasion must produce its actual World battle');
game = reactionAction(game, choice.chooser, { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, actions);
return advanceWorldNexusBattle(game, state => !!state.battle && !state.battle.revealed &&
!state.battle.preparation && state.battle.preLeader?.closed !== false && worldNexusClean(state), actions, trained);
}
function invade(game: Game, actor: string, location: string, amount: number, actions: WorldNexusBattleStep[]): WorldNexusInvasion {
const before = structuredClone(game), view = viewGame(game, actor);
const sources = nativeShipmentSources(view, amount, 0); assert.ok(sources);
const quote = homeworldShipmentChoice(view, location,
Object.fromEntries(Object.entries(sources).filter(([, force]) => force.normal + force.elite > 0)));
assert.ok(quote.action, quote.blocked ?? 'Use the original paid native invasion');
game = reactionAction(game, actor, quote.action, actions);
game = advanceWorldNexusBattle(game, state => !state.pendingHomeworldShipment && worldNexusClean(state), actions);
return { before, action: quote.action, cost: quote.cost, after: game };
}
/** Genuine closing draw, paid Guild invasion and Emperor's legal inter-World
* movement leave seven ordinary Kaitain counters. The optional low Salusa
* 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'],
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);
let arrival: WorldNexusInvasion | undefined;
let moved = false, exported = false;
while (game.phase === 5) {
const actor = game.active!;
if (worldNexusClean(game) && actor === 'guild' && !reactionPlayer(game, actor).shipped) {
arrival = invade(game, actor, location, 3, actions); game = arrival.after;
} else if (worldNexusClean(game) && actor === 'emperor' && !moved) {
if (options.salusa === 'low' && !exported) {
const desert = TERRITORIES.find(territory => territory.type === 'sand' && !territory.sectors.includes(game.storm) &&
!game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${territory.id}:`))));
assert.ok(desert);
const sources = nativeShipmentSources(viewGame(game, actor), 4, 4); assert.ok(sources);
game = reactionAction(game, actor, { type: 'ship', territory: desert.id, sector: desert.sectors[0],
amount: 4, elite: 4, homeworldSources: sources, allyPayment: 0 }, actions);
exported = true;
game = advanceWorldNexusBattle(game, worldNexusClean, actions);
} else {
const movement = viewGame(game, actor).homeworldMove; assert.ok(movement && !movement.blocked);
game = reactionAction(game, actor, { type: 'emperorHomeworldMove', event: movement.event,
origin: location, normal: 8, elite: 0 }, actions);
moved = true;
}
} else {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = reactionAction(game, step.actor, step.action, actions);
}
}
assert.ok(arrival && moved);
game = advanceWorldNexusBattle(game, state => state.phase === 6 && worldNexusClean(state), actions);
const beforeBattle = structuredClone(game);
game = chooseWorldBattle(game, location, actions, trained);
const leaders = reactionPlayer(game, 'emperor').leaders.filter(leader => !leader.dead && !leader.usedAt);
const trainer = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'emperor');
const leader = trainer ? leaders.find(leader => leader.id === trainer.leader)! : leaders.at(-1)!;
const opponentLeader = reactionPlayer(game, 'guild').leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && opponentLeader);
assert.deepEqual(combatArmy(game, 'emperor', location), { normal: 7, elite: 0 });
homeworldGameIntegrity(game);
const plans: WorldNexusBattleStep[] = [
{ actor: 'emperor', action: { type: 'battlePlan', dial: 4, support: options.salusa === 'low' ? 2 : 0, leader: leader.id } },
{ actor: 'guild', action: { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader.id } },
];
return { ...fixture, game, beforeBattle, arrival, location, plans, trainer, leader: leader.id };
}
/** The original Harkonnen native pool defends Giedi Prime. Its actual offered
* Atreides Traitor is chosen as the invader's disc; only Harkonnen receives an
* 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'],
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' &&
worldNexusClean(state), actions);
const identity = reactionPlayer(game, 'atreides').leaders.find(leader => reactionPlayer(game, 'harkonnen').traitors.includes(leader.id))?.id;
assert.ok(identity, 'Use an actually offered native Traitor identity');
const arrival = invade(game, 'atreides', location, 1, actions); game = arrival.after;
game = advanceWorldNexusBattle(game, state => state.phase === 6 && worldNexusClean(state), actions);
const beforeBattle = structuredClone(game), trained = options.skills ? ['harkonnen'] : [];
game = chooseWorldBattle(game, location, actions, trained);
const trainer = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'harkonnen');
const leader = trainer ? reactionPlayer(game, 'harkonnen').leaders.find(leader => leader.id === trainer.leader)!
: reactionPlayer(game, 'harkonnen').leaders.toSorted((a, b) => b.strength - a.strength)[0];
const plans: WorldNexusBattleStep[] = [
{ actor: 'atreides', action: { type: 'battlePlan', dial: 0, support: 0, leader: identity } },
{ actor: 'harkonnen', action: { type: 'battlePlan', dial: 0, support: 0, leader: leader.id } },
];
homeworldGameIntegrity(game);
return { ...fixture, game, beforeBattle, arrival, location, identity, plans, trainer, leader: leader.id };
}
export function revealWorldNexusPlans(game: Game, plans: readonly WorldNexusBattleStep[], actions: WorldNexusBattleStep[] = []) {
for (const plan of plans) {
game = reactionAction(game, plan.actor, plan.action, actions);
game = advanceWorldNexusBattle(game, state => worldNexusClean(state) && !!state.battle &&
!state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
}
assert.ok(game.battle?.revealed);
return game;
}
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
import { advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import type { ClassicNexusCoreOptions } from './fixture-classic-nexus-core';
import { classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export type WorldNexusPaymentOptions = Pick<ClassicNexusCoreOptions, 'advanced' | 'skills' | 'tech' | 'strongholds'>;
/** Select only the still-undealt original singleton, then use its actual closing draw. */
function drawWorldPaymentFace(game: Game, owner: string, face: FactionId, actions: ClassicIndependentNexusStep[]) {
assert.equal(game.nexusCards!.phase!.stage, 'drawing');
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face);
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
return reactionAction(game, owner, { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 }, actions);
}
export function worldPaymentWorld(game: Game, actor: string, native: string) {
const worlds = viewGame(game, actor).homeworlds?.worlds ?? [];
const world = worlds.find(home => home.native === native && !home.secondary);
assert.ok(world, `Seat ${native} must hold its actual primary World descriptor.`);
return world;
}
/** Basic original base33 chronology by default: an actual Junction departure,
* actual Cunning, then an actual visitor return from Giedi Prime to Junction.
* The second route therefore needs native return permission as well as the
* native rate; neither a scalar reserve nor an Arrakis sector is relabelled. */
export function createWorldNexusCunningPayment(options: WorldNexusPaymentOptions = {}) {
const counter = baseDeck().find(card => card.effect === 'karama')!.id;
const fixture = drawClassicNexusReaction('guild', 'guild', { ...options,
independent: false, homeworlds: true, roster: ['atreides', 'harkonnen', 'guild', 'emperor'],
startingCards: { atreides: [counter] } });
const actions = fixture.actions;
let game = drawWorldPaymentFace(fixture.game, 'emperor', 'harkonnen', actions);
game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'guild' &&
classicDiscoveryNexusClean(state), actions);
const source = worldPaymentWorld(game, 'guild', 'guild');
const destination = worldPaymentWorld(game, 'guild', 'harkonnen');
const amount = 3, sources = { [source.id]: { normal: amount, elite: 0 } };
assert.ok(source.forces.guild.normal >= amount);
const firstQuote = homeworldShipmentChoice(viewGame(game, 'guild'), destination.id, sources, 0);
assert.equal(firstQuote.blocked, null);
assert.ok(firstQuote.action);
const beforeFirst = structuredClone(game), firstAction = firstQuote.action;
game = reactionAction(game, 'guild', firstAction, actions);
game = advanceClassicIndependentNexus(game, state => reactionPlayer(state, 'guild').shipped &&
!state.pendingGuildBetrayal && classicDiscoveryNexusClean(state), actions);
const afterFirst = structuredClone(game), offer = viewGame(game, 'guild').nexusGuildCunning!.offer!;
assert.equal(offer.blocked, null);
game = reactionAction(game, 'guild', { type: 'endMovement', nexus: offer.event }, actions);
game = advanceClassicIndependentNexus(game, state => state.nexusGuildCunningHistory?.at(-1)?.stage === 'secondShipment' &&
classicDiscoveryNexusClean(state), actions);
const beforeSecond = structuredClone(game);
const returnSource = worldPaymentWorld(game, 'guild', 'harkonnen');
const returnDestination = worldPaymentWorld(game, 'guild', 'guild');
assert.equal(returnSource.forces.guild.normal, amount, 'Only the first shipment\'s actual visitors occupy Giedi Prime.');
const returnSources = { [returnSource.id]: { normal: amount, elite: 0 } };
const quote = homeworldShipmentChoice(viewGame(game, 'guild'), returnDestination.id, returnSources, 0);
assert.equal(quote.blocked, null);
assert.ok(quote.action);
return { ...fixture, game: beforeSecond, beforeFirst, afterFirst, beforeSecond, actions, counter, amount,
source: source.id, destination: destination.id, sources, firstAction, firstQuote,
returnSources, quote, action: quote.action, actor: 'guild', holder: 'emperor' };
}
/** Actual normal/Richese shipment: Caladan's selected native counters enter
* Carthag, while Emperor holds the distinct original Guild singleton. Advanced
* all14/Tech/Strongholds is the default second chronology, not a copied scene. */
export function createWorldNexusBorrowedPayment(options: WorldNexusPaymentOptions & { discounted?: boolean } = {}) {
const discounted = options.discounted ?? true;
const fixture = drawClassicNexusReaction(discounted ? 'richese' : 'harkonnen', 'atreides', {
advanced: true, skills: true, tech: true, strongholds: true, ...options,
independent: false, homeworlds: true, roster: ['atreides', 'guild', 'harkonnen', 'emperor'],
training: options.skills === false ? undefined
: { atreides: 'diplomat', guild: 'mentat', harkonnen: 'warmaster', emperor: 'sandmaster' },
});
const actions = fixture.actions;
let game = drawWorldPaymentFace(fixture.game, 'emperor', 'guild', actions);
game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'atreides' &&
classicDiscoveryNexusClean(state), actions);
const amount = 5, place = territory('carthag');
assert.ok(!place.sectors.includes(game.storm));
const native = worldPaymentWorld(game, 'atreides', 'atreides');
const sources = nativeShipmentSources(viewGame(game, 'atreides'), amount, 0);
assert.ok(sources);
assert.deepEqual(sources[native.id], { normal: amount, elite: 0 });
const action: Action = { type: 'ship', territory: place.id, sector: place.sectors[0],
amount, elite: 0, allyPayment: 0, homeworldSources: sources };
if (discounted) {
const offer = viewGame(game, 'atreides').nexusRichese!;
assert.equal(offer.blocked, null);
action.nexus = offer.event;
}
return { ...fixture, game, beforeShipment: structuredClone(game), actions, action, amount,
sources, source: native.id, key: `${place.id}:${place.sectors[0]}`, actor: 'atreides',
holder: 'emperor', discounted };
}
/** The original native gates between a World declaration and its fee: each
* recorded kind is a real live window stepped by the shared controller, never
* a synthetic phase label. Stops before the owner fee acknowledgement. */
export function worldNexusPaymentGates(game: Game, actor: string, action: Action,
actions?: ClassicIndependentNexusStep[]) {
const kinds: string[] = [];
game = reactionAction(game, actor, action, actions);
for (let count = 0; !game.pendingGuildBetrayal && count < 40; count++) {
kinds.push(game.response?.kind ?? game.decision?.kind ?? `${game.phase}/${game.active}`);
const step = nextClassicDiscoveryNexusStep(game);
assert.ok(step, 'The original controller must own every native gate or arrival choice.');
game = reactionAction(game, step.actor, step.action, actions);
}
assert.ok(game.pendingGuildBetrayal, 'The original declaration must reach its Guild fee gate.');
return { game, kinds };
}
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import { casualtyOptions, maxCombatDial, maxCombatSupport } from '../game/combat';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldPopulations } from '../game/homeworld-population';
import { reactionAction } from './fixture-classic-independent-nexus-reactions';
import {
advanceWorldNexusBattle, createWorldNexusEmperorBattle, createWorldNexusHarkonnenBattle,
revealWorldNexusPlans, worldNexusClean, worldNexusPlayer,
} from './fixture-nexus-core-world-battles';
for (const salusa of ['high', 'low'] as const) {
void test(`original own-Kaitain Cunning with ${salusa} Salusa spends support but kills only physical ordinary counters`, () => {
const f = createWorldNexusEmperorBattle({ salusa, skills: true, tech: true, strongholds: true });
let game = f.game;
const emperor = worldNexusPlayer(game, 'emperor'), guild = worldNexusPlayer(game, 'guild');
const beforeSpice = emperor.spice, beforeStars = structuredClone(emperor.elites);
const beforeSalusa = structuredClone(game.homeworlds!.custody!.salusa);
const populations = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!);
const home = populations.find(home => home.location === f.location)!;
assert.deepEqual([home.population, home.side, home.nativeBattleStrength], [7, 'high', 2]);
assert.deepEqual(beforeSalusa, { normal: 8, elite: salusa === 'high' ? 5 : 1 });
assert.equal(populations.find(home => home.card === 'salusa_secundus')!.side, salusa);
assert.equal(f.trainer!.leader, f.leader);
assert.equal(game.battle!.leaderSkillHidden!.emperor, true);
assert.equal(f.plans[0].action.leader, f.trainer!.leader);
// Arrakeen and Tuek's Sietch were actually earned at END Mentat; neither
// becomes generic off-planet strength or support in this native battle.
assert.equal(game.strongholdCards!.owners.arrakeen, 'atreides');
assert.equal(game.strongholdCards!.owners.tueks_sietch, 'guild');
assert.deepEqual(viewGame(game, 'emperor').battle!.strongholdEffects, { guild: null, emperor: null });
const offer = viewGame(game, 'emperor').nexusSardaukar!.offer!;
assert.equal(offer.blocked, null);
game = reactionAction(game, 'emperor', { type: 'nexusSardaukar', event: offer.event });
game = advanceWorldNexusBattle(game, state => worldNexusClean(state));
const forces = viewGame(game, 'emperor').battle!.ownForces!;
assert.deepEqual([forces.normal, forces.elite, forces.temporaryElite], [7, 0, 5]);
assert.equal(!!forces.eliteFreeSupport, salusa === 'high');
assert.equal(maxCombatDial(forces), 12);
assert.equal(maxCombatSupport(forces), salusa === 'high' ? 2 : 7);
assert.ok(casualtyOptions(forces, 4, salusa === 'high' ? 0 : 2).some(loss => loss.normal === 2 && loss.elite === 0));
// High Salusa's five real stars grant free elite support, so only the two
// ordinary non-temporary counters can pay (shared cap 2). Low Salusa lifts
// the shared counter cap to 7, but the desert export has already left the
// Emperor 2 spice, so the live plan bound stays 0–2 and anything above it
// is rejected unchanged.
const badSupport = salusa === 'high' ? 3 : 8;
assert.throws(() => applyAction(game, 'emperor', { ...f.plans[0].action, support: badSupport }));
assert.throws(() => applyAction(game, 'emperor', { type: 'nexusSardaukar', event: offer.event }));
game = revealWorldNexusPlans(game, f.plans);
assert.deepEqual(viewGame(game, 'emperor').battle!.traitorVoters, ['emperor']);
assert.throws(() => applyAction(game, 'guild', { type: 'traitorCall', call: false }));
game = advanceWorldNexusBattle(game, state => !state.battle && !state.homeworldBattleLoss && worldNexusClean(state));
assert.equal(game.lastBattleContext!.winner, 'emperor');
assert.equal(game.lastBattleContext!.result, 'normal');
assert.deepEqual(combatArmy(game, 'emperor', f.location), { normal: 5, elite: 0 });
assert.deepEqual(combatArmy(game, 'guild', f.location), { normal: 0, elite: 0 });
assert.equal(worldNexusPlayer(game, 'emperor').tanks, emperor.tanks + 2);
assert.equal(worldNexusPlayer(game, 'guild').tanks, guild.tanks + 3);
assert.equal(worldNexusPlayer(game, 'emperor').spice, beforeSpice - (salusa === 'high' ? 0 : 2));
assert.deepEqual(worldNexusPlayer(game, 'emperor').elites, beforeStars);
assert.deepEqual(game.homeworlds!.custody!.salusa, beforeSalusa);
const record = game.nexusSardaukarHistory!.at(-1)!;
assert.equal(record.casualties!.outcome, 'complete');
assert.ok(record.casualties!.options.every(loss => loss.elite === 0));
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
assert.equal(viewGame(game, 'emperor').nexusSardaukar!.active, false);
homeworldGameIntegrity(game);
});
}
for (const advanced of [false, true]) {
for (const cancel of [false, true]) {
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} Giedi Prime native call ${cancel ? 'is canceled with retirement and replacement' : 'retains its original traitor outcome'}`, () => {
const f = createWorldNexusHarkonnenBattle({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
let game = revealWorldNexusPlans(f.game, f.plans);
const native = worldNexusPlayer(game, 'harkonnen'), invader = worldNexusPlayer(game, 'atreides');
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!).find(home => home.location === f.location)!;
assert.deepEqual([home.population, home.side, home.nativeBattleStrength], [10, 'high', 2]);
assert.deepEqual(viewGame(game, 'atreides').battle!.traitorVoters, ['harkonnen']);
assert.throws(() => applyAction(game, 'atreides', { type: 'traitorCall', call: true }));
if (advanced) {
assert.equal(game.strongholdCards!.owners.carthag, 'harkonnen');
assert.deepEqual(viewGame(game, 'harkonnen').battle!.strongholdEffects, { harkonnen: null, atreides: null });
assert.equal(game.battle!.plans.harkonnen.leader, f.trainer!.leader);
}
game = reactionAction(game, 'harkonnen', { type: 'traitorCall', call: true });
const event = game.pendingNexusHarkonnenBetrayal!.source.event;
assert.equal(viewGame(game, 'atreides').nexusHarkonnenBetrayalReaction!.canUse, true);
assert.equal(game.pendingNexusHarkonnenBetrayal!.source.declaration.identity, f.identity);
game = reactionAction(game, 'atreides', { type: cancel ? 'nexusHarkonnenBetrayalUse' : 'nexusHarkonnenBetrayalPass', event });
if (cancel) {
assert.equal(worldNexusPlayer(game, 'harkonnen').traitors.includes(f.identity), false);
assert.equal(game.traitorReserve!.filter(identity => identity === f.identity).length, 1);
assert.equal(game.pendingNexusHarkonnenReplacement!.identity, f.identity);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'harkonnen').length, 1);
}
let captive: string | undefined;
if (advanced) {
game = advanceWorldNexusBattle(game, state => state.decision?.kind === 'captureOffer' && !state.response);
assert.equal(game.decision!.kind, 'captureOffer');
game = reactionAction(game, 'harkonnen', { type: 'decision', accept: true });
game = advanceWorldNexusBattle(game, state => state.decision?.kind === 'capturedLeader' && !state.response);
assert.ok(game.decision?.kind === 'capturedLeader');
captive = game.decision.leader;
assert.equal(worldNexusPlayer(game, 'atreides').leaders.find(leader => leader.id === captive)!.capturedBy, 'harkonnen');
const spice = worldNexusPlayer(game, 'harkonnen').spice;
game = reactionAction(game, 'harkonnen', { type: 'decision', mode: 'execute' });
assert.equal(worldNexusPlayer(game, 'harkonnen').spice, spice + 2);
const executed = worldNexusPlayer(game, 'atreides').leaders.find(leader => leader.id === captive)!;
assert.equal(executed.dead, true);
assert.equal(executed.deaths, 2);
assert.equal(executed.capturedBy, undefined);
}
const replacementTop = cancel ? game.traitorReserve![0] : null;
game = advanceWorldNexusBattle(game, state => state.phase === 8 && worldNexusClean(state));
assert.equal(game.lastBattleContext!.territory, f.location);
assert.equal(game.lastBattleContext!.winner, 'harkonnen');
assert.equal(game.lastBattleContext!.result, cancel ? 'normal' : 'traitor');
assert.deepEqual(combatArmy(game, 'harkonnen', f.location), { normal: 10, elite: 0 });
assert.deepEqual(combatArmy(game, 'atreides', f.location), { normal: 0, elite: 0 });
assert.equal(worldNexusPlayer(game, 'harkonnen').tanks, native.tanks);
assert.equal(worldNexusPlayer(game, 'atreides').tanks, invader.tanks + 1);
assert.equal(game.pendingNexusHarkonnenReplacement, null);
assert.equal(game.nexusHarkonnenReplacementHistory!.length, cancel ? 1 : 0);
if (cancel) {
assert.equal(game.nexusHarkonnenReplacementHistory![0].drawn, replacementTop);
assert.equal(worldNexusPlayer(game, 'harkonnen').traitors.length, 4);
} else {
assert.equal(game.nexusCards!.cards!.hands.atreides, 'harkonnen');
assert.ok(worldNexusPlayer(game, 'harkonnen').revealedTraitors!.includes(f.identity));
assert.equal(worldNexusPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)!.dead, true);
}
assert.throws(() => applyAction(game, 'atreides', { type: 'nexusHarkonnenBetrayalUse', event }));
homeworldGameIntegrity(game);
});
}
}
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
import {
createWorldNexusBorrowedPayment,
createWorldNexusCunningPayment,
worldNexusPaymentGates,
} from './fixture-nexus-core-world-payments';
const custody = (game: Game, id: string) => {
const seat = reactionPlayer(game, id);
return { forces: seat.forces, reserves: seat.reserves, elites: seat.elites ?? null, tanks: seat.tanks };
};
const worldForces = (game: Game, actor: string, id: string) => {
const world = (viewGame(game, actor).homeworlds?.worlds ?? []).find(entry => entry.id === id);
assert.ok(world);
return world.forces;
};
const guildCounterTotal = (game: Game) => {
const guild = reactionPlayer(game, 'guild');
const visitors = (viewGame(game, 'guild').homeworlds?.worlds ?? [])
.filter(world => world.native !== 'guild');
return guild.reserves + Object.values(guild.forces).reduce((sum, n) => sum + n, 0) +
visitors.reduce((sum, world) => sum + (world.forces.guild?.normal ?? 0) + (world.forces.guild?.elite ?? 0), 0);
};
void test('Basic base33 World Cunning return passes native permission, native rate and the funded Guild fee gate', () => {
const f = createWorldNexusCunningPayment();
assert.equal(f.game.phase, 5);
assert.equal(f.game.active, 'guild');
assert.equal(reactionPlayer(f.game, 'guild').shipped, true, 'The first Junction departure already used the shipment.');
assert.equal(f.firstQuote.cost, 2);
assert.equal(f.quote.cost, 2);
assert.equal(f.quote.amount, 3);
assert.equal(f.quote.ownPayment, 2);
assert.equal(f.action.type, 'homeworldShip');
assert.equal(f.action.destination, 'homeworld:guild');
assert.deepEqual(f.action.sources, { 'homeworld:harkonnen': { normal: 3, elite: 0 } });
assert.equal(f.game.nexusGuildCunningHistory!.at(-1)!.stage, 'secondShipment');
assert.equal(viewGame(f.game, 'guild').nexusGuildCunning!.active!.movesLeft, 0,
'The granted Cunning move is reserved for this second shipment, not normal movement.');
const unrelated = ['atreides', 'harkonnen', 'emperor'].map(id => [id, custody(f.game, id)] as const);
const gates = worldNexusPaymentGates(f.game, 'guild', f.action);
assert.deepEqual(gates.kinds, ['guildTransportPermission', 'guildRate'],
'The return route opens both native gates before any fee.');
const invoice = gates.game.pendingGuildBetrayal!.invoice;
assert.equal(invoice.price, 2);
assert.equal(invoice.ownSpice, 3, 'No escrow is taken before the fee acknowledgement.');
assert.deepEqual(gates.game.pendingGuildBetrayal!.required, ['emperor']);
assert.equal(reactionPlayer(gates.game, 'guild').reserves, 12);
assert.deepEqual(worldForces(gates.game, 'guild', 'homeworld:harkonnen').guild, { normal: 3, elite: 0 });
assert.equal(viewGame(gates.game, 'emperor').guildBetrayalReaction!.canUse, false,
'The original Guild singleton was already spent on Cunning itself.');
assert.throws(() => reactionAction(gates.game, 'emperor',
{ type: 'guildBetrayalUse', event: invoice.event }), /cannot take this shipment payment/);
let game = reactionAction(gates.game, 'emperor', { type: 'guildBetrayalPass', event: invoice.event });
const guild = reactionPlayer(game, 'guild');
assert.equal(worldForces(game, 'guild', 'homeworld:harkonnen').guild, undefined,
'The actual visitor pool leaves Giedi Prime.');
assert.deepEqual(worldForces(game, 'guild', 'homeworld:guild').guild, { normal: 15, elite: 0 });
assert.equal(guild.reserves, 15);
assert.equal(guildCounterTotal(game), guildCounterTotal(gates.game), 'No physical Guild counter is created or lost.');
assert.deepEqual(game.nexusCards!.cards!.discard, ['guild']);
assert.equal(game.nexusCards!.cards!.hands.emperor, 'harkonnen');
for (const [id, before] of unrelated) assert.deepEqual(custody(game, id), before);
game = reactionAction(game, 'guild', { type: 'endMovement' });
assert.equal(game.nexusGuildCunningHistory!.at(-1)!.stage, 'completed');
assert.equal(viewGame(game, 'guild').nexusGuildCunning ?? null, null);
assert.notEqual(game.active, 'guild');
assert.throws(() => reactionAction(game, 'guild', f.action), /unused shipment/,
'The once-only card, payment and physical continuation cannot repeat.');
});
void test('Advanced all14 Richese borrowed Caladan shipment pays the distinct Guild holder its one-spice fee', () => {
const f = createWorldNexusBorrowedPayment();
assert.equal(f.source, 'homeworld:atreides');
assert.deepEqual(f.sources, { 'homeworld:atreides': { normal: 5, elite: 0 } });
assert.equal(f.action.type, 'ship');
assert.equal(typeof f.action.nexus, 'string');
assert.deepEqual(worldForces(f.game, 'atreides', 'homeworld:atreides').atreides, { normal: 10, elite: 0 });
const unrelated = ['guild', 'harkonnen', 'emperor'].map(id => [id, custody(f.game, id)] as const);
const gates = worldNexusPaymentGates(f.game, 'atreides', f.action);
assert.deepEqual(gates.kinds, ['guildShipment']);
const invoice = gates.game.pendingGuildBetrayal!.invoice;
assert.equal(invoice.price, 1);
assert.equal(invoice.originalReceiver, 'guild');
assert.deepEqual(gates.game.pendingGuildBetrayal!.required, ['emperor']);
assert.equal(reactionPlayer(gates.game, 'atreides').reserves, 10, 'Typed custody waits for the fee.');
assert.equal(reactionPlayer(gates.game, 'atreides').forces[f.key] ?? 0, 0);
assert.equal(viewGame(gates.game, 'emperor').guildBetrayalReaction!.canUse, true,
'The distinct actual holder may still claim this fee.');
const game = reactionAction(gates.game, 'emperor', { type: 'guildBetrayalUse', event: invoice.event });
assert.equal(reactionPlayer(game, 'atreides').spice, reactionPlayer(gates.game, 'atreides').spice - 1);
assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(gates.game, 'emperor').spice + 1);
assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(gates.game, 'guild').spice);
assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 5);
assert.equal(reactionPlayer(game, 'atreides').reserves, 5);
assert.deepEqual(worldForces(game, 'atreides', 'homeworld:atreides').atreides, { normal: 5, elite: 0 });
assert.deepEqual(game.nexusCards!.cards!.discard, ['richese', 'guild']);
assert.equal(game.nexusRicheseHistory!.at(-1)!.stage, 'shipped');
for (const [id, before] of unrelated) assert.deepEqual(custody(game, id), before);
});
void test('the ordinary Advanced all14 Caladan shipment pays its full five-spice fee to the distinct Guild holder', () => {
const f = createWorldNexusBorrowedPayment({ discounted: false });
assert.equal(f.action.nexus, undefined);
const gates = worldNexusPaymentGates(f.game, 'atreides', f.action);
assert.deepEqual(gates.kinds, ['guildShipment']);
const invoice = gates.game.pendingGuildBetrayal!.invoice;
assert.equal(invoice.price, 5);
assert.deepEqual(gates.game.pendingGuildBetrayal!.required, ['atreides', 'emperor']);
assert.equal(viewGame(gates.game, 'emperor').guildBetrayalReaction!.canUse, true);
const game = reactionAction(gates.game, 'emperor', { type: 'guildBetrayalUse', event: invoice.event });
assert.equal(reactionPlayer(game, 'atreides').spice, reactionPlayer(gates.game, 'atreides').spice - 5);
assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(gates.game, 'emperor').spice + 5);
assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(gates.game, 'guild').spice);
assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 5);
assert.equal(reactionPlayer(game, 'atreides').reserves, 5);
assert.deepEqual(game.nexusCards!.cards!.discard, ['guild']);
assert.equal(game.nexusCards!.cards!.hands.emperor, null,
'The used Guild singleton leaves its holder once; no duplicate exists.');
assert.equal(game.nexusCards!.cards!.hands.atreides, 'harkonnen',
'The rival Harkonnen face stays held by its own drawing seat.');
});