Compose original World Nexus core
This commit is contained in:
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) : [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
Reference in new issue
Block a user