123 lines
7.5 KiB
TypeScript
123 lines
7.5 KiB
TypeScript
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 };
|
|
}
|