Compose native Guild rate with module and Homeworld price sources

This commit is contained in:
Quintine committed 2026-10-15 20:55:05 +11:00
1 parent 0db84c81b7
commit a12250320c
24 files changed
+1176 -50

No files matched your search

@@ -5,13 +5,13 @@ import {
advancePairedE3Nexus, createPairedE3NexusSetup, nextPairedE3NexusStep,
pairedE3NexusClean, pairedE3NexusInventory, pairedE3NexusPlayer,
pairedE3NexusReload, quietPairedE3NexusChoice, stepPairedE3Nexus,
type PairedE3NexusStep, type PairedE3NexusTransition,
type PairedE3NexusOptions, type PairedE3NexusStep, type PairedE3NexusTransition,
} from './fixture-paired-e3-nexus-modules';
import { orderE3NexusEcazSpice } from './fixture-e3-nexus-ecaz';
import { quietPlan } from './fixture-nexus-duke-capture';
/** Original Advanced Ecaz/Moritani/Guild, without optional physical modules.
/** Original Ecaz/Moritani/Guild, with explicit original setup module choices.
* The shared setup selects only original undealt entropy. Here the original
* unplayed Treachery deck puts a Karama in the first real auction; Ecaz buys
* that physical card for one spice. Only the conserved, unplayed Spice deck
@@ -21,10 +21,11 @@ import { quietPlan } from './fixture-nexus-duke-capture';
* A one-force stronghold arrival has no rounded native-rate discount to
* cancel (half/full both cost one). Larger native shipments retain the
* separate prepayment rate window before this fixture's income path. */
export function createGuildAmbassadorIncomeArrival() {
export function createGuildAmbassadorIncomeArrival(options: Pick<PairedE3NexusOptions,
'advanced' | 'skills' | 'training' | 'homeworlds' | 'discovery' | 'tech' | 'strongholds'> = {}) {
const setup = createPairedE3NexusSetup({ roster: ['ecaz', 'moritani', 'guild'],
advanced: true, skills: false, homeworlds: false, discovery: false,
tech: false, strongholds: false });
tech: false, strongholds: false, ...options });
const actions = setup.actions;
const ecaz = setup.ecaz, entrant = setup.moritani;
const guild = setup.afterSetup.players.find(p => p.faction === 'guild')!.id;
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { homeworldShipmentChoice, type HomeworldShipmentSources } from '../game/homeworld-shipment-options';
import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
import { junctionTransportChoice } from '../game/junction-transport-options';
import { createGuildAmbassadorIncomeArrival } from './fixture-guild-ambassador-income-arrival';
import {
advancePairedE3Nexus, nextPairedE3NexusStep, pairedE3NexusClean,
pairedE3NexusPlayer, pairedE3NexusReload, quietPairedE3NexusChoice,
stepPairedE3Nexus, type PairedE3NexusStep,
} from './fixture-paired-e3-nexus-modules';
import {
nexusGuildCunningFixture, originalGuildCunningTurn, nexusGuildCunningRequest,
nexusGuildCunningAllow, holdGuildCunningCard,
} from './fixture-nexus-guild-cunning';
export type NativeGuildHomeworldRoute = 'world' | 'arrakisForeign' | 'return';
export interface NativeGuildHomeworldRateFixture {
route: NativeGuildHomeworldRoute;
source: string;
destination: string;
sources: HomeworldShipmentSources;
guild: string;
entrant: string;
ecaz: string;
karama: string;
beforeAction: Game;
action: Action;
}
/** Original printed setup, original purchased Karama, and actual progression
* from Moritani's real turn to Guild's real turn. The shared fixture discloses
* its conserved undealt entropy ordering. No played custody, funding, control,
* phase or decision is assigned here. A return is Arrakis to native Junction. */
export function createNativeGuildHomeworldRate(route: NativeGuildHomeworldRoute = 'world',
options: { unaffordable?: boolean } = {}) {
const base = createGuildAmbassadorIncomeArrival({ homeworlds: true });
const actions = [...base.actions];
let game = advancePairedE3Nexus(base.beforeAction,
g => g.phase === 5 && g.active === base.guild && pairedE3NexusClean(g),
actions, quietPairedE3NexusChoice);
const beforeFunding = pairedE3NexusReload(game);
if (options.unaffordable) {
const excess = pairedE3NexusPlayer(game, base.guild).spice - 1;
assert.ok(excess >= 0);
if (excess) game = stepPairedE3Nexus(game, { actor: base.guild,
action: { type: 'bribe', target: base.ecaz, amount: excess } }, actions);
}
game = stepPairedE3Nexus(game, { actor: base.entrant,
action: { type: 'pledgeAid', amount: 1 } }, actions);
const p = pairedE3NexusPlayer(game, base.guild);
const source = route === 'world' ? 'homeworld:guild' : Object.keys(p.forces)
.find(key => p.forces[key] >= 3 && Number(key.split(':')[1]) !== game.storm);
assert.ok(source, 'The original Guild retains three physical counters outside storm.');
const destination = route === 'return' ? 'homeworld:guild' : 'homeworld:ecaz';
const sources: HomeworldShipmentSources = { [source]: { normal: 3, elite: 0 } };
const quote = route === 'world'
? homeworldShipmentChoice(viewGame(game, base.guild), destination, sources, 1)
: guildHomeworldShipmentChoice(viewGame(game, base.guild), destination, sources, 1);
assert.equal(quote.blocked, null);
assert.equal(quote.cost, 2);
assert.equal(quote.ownPayment, 1);
assert.ok(quote.action);
return { ...base, actions, route, source, destination, sources,
beforeFunding, beforeAction: pairedE3NexusReload(game), action: quote.action,
contributions: [{ payer: base.guild, amount: 1 }, { payer: base.entrant, amount: 1 }],
provenance: [...base.provenance,
'Actual quiet phase progression to the native Guild turn; original on-board and Junction pools retained.',
'Moritani pledges one actual spice; optional Guild bribe reduces only its real available wallet.',
'The owner-view canonical quote declares three normal counters and an explicit one-spice allied share.'] };
}
export function nativeGuildHomeworldAction(f: NativeGuildHomeworldRateFixture,
game: Game, amount = 3, allyPayment = 1): Action {
const sources = { [f.source]: { normal: amount, elite: 0 } };
const view = viewGame(game, f.guild);
const quote = f.route === 'world'
? homeworldShipmentChoice(view, f.destination, sources, allyPayment)
: guildHomeworldShipmentChoice(view, f.destination, sources, allyPayment);
assert.equal(quote.blocked, null);
assert.ok(quote.action);
return quote.action;
}
/** Retain the original Guild special-interception decision if physically
* offered. It is distinct from the native rate's subsequent prepayment queue. */
export function declareNativeGuildHomeworldRate(f: NativeGuildHomeworldRateFixture) {
const actions: PairedE3NexusStep[] = [];
let game = stepPairedE3Nexus(f.beforeAction, { actor: f.guild, action: f.action }, actions);
if (game.decision?.kind === 'homeworldShipmentGuild') {
game = stepPairedE3Nexus(game, { actor: f.guild,
action: { type: 'decision', event: game.decision.event, allow: true } }, actions);
}
assert.equal(game.response?.kind, 'guildRate');
assert.ok(viewGame(game, f.ecaz).responseControls?.cancelCards.includes(f.karama));
return { game, actions };
}
/** Only pass the actual requested kind; never clear a queue or fabricate a seat. */
export function passNativeGuildHomeworldResponses(state: Game, kind: 'guildRate' | 'guildIncome') {
const actions: PairedE3NexusStep[] = [];
let game = pairedE3NexusReload(state);
for (let n = 0; game.response?.kind === kind && n < 20; n++) {
const step = nextPairedE3NexusStep(game);
assert.ok(step);
assert.equal(step.action.type, 'passResponse');
game = stepPairedE3Nexus(game, step, actions);
}
assert.notEqual(game.response?.kind, kind);
return { game, actions };
}
/** Export a real pending prepayment scene suitable for API/UI loading. */
export function createNativeGuildHomeworldRateScene(route: NativeGuildHomeworldRoute = 'world',
options: { unaffordable?: boolean } = {}) {
const fixture = createNativeGuildHomeworldRate(route, options);
const offered = declareNativeGuildHomeworldRate(fixture);
return { ...fixture, game: offered.game, declarationActions: offered.actions,
cancel: { actor: fixture.ecaz, action: { type: 'card', mode: 'cancel', card: fixture.karama } as Action } };
}
/** Original immune Junction offer, not a staged immunity flag: Moritani first
* loses its ordinary native alliance rate on a genuinely unaffordable six-force
* cross-transport. Its untouched original army then accepts Junction's own
* Homeworld return tariff while that cancellation marker remains active. */
export function createNativeImmuneJunctionRate(rate: 'half' | 'full' = 'half') {
const base = createGuildAmbassadorIncomeArrival({ homeworlds: true });
const actions = [...base.actions];
const excess = pairedE3NexusPlayer(base.beforeAction, base.entrant).spice - 3;
assert.ok(excess >= 0);
let game = pairedE3NexusReload(base.beforeAction);
if (excess) game = stepPairedE3Nexus(game, { actor: base.entrant,
action: { type: 'bribe', target: base.ecaz, amount: excess } }, actions);
game = stepPairedE3Nexus(game, { actor: base.entrant,
action: { ...base.action, amount: 6 } }, actions);
assert.equal(game.response?.kind, 'guildRate');
game = stepPairedE3Nexus(game, { actor: base.ecaz,
action: { type: 'card', mode: 'cancel', card: base.karama } }, actions);
assert.equal(viewGame(game, base.entrant).guildRateCanceled, true);
assert.equal(pairedE3NexusPlayer(game, base.entrant).shipped, false);
const released = pairedE3NexusReload(game);
const sponsor = viewGame(game, base.guild).junctionTransport;
assert.ok(sponsor?.canOffer);
game = stepPairedE3Nexus(game, { actor: base.guild,
action: { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate } }, actions);
const sources = { [base.source]: { normal: 3, elite: 0 } };
const destination = 'homeworld:moritani';
const quote = junctionTransportChoice(viewGame(game, base.entrant), destination, sources, 0);
assert.equal(quote.blocked, null);
assert.equal(quote.cost, rate === 'half' ? 2 : 3);
assert.ok(quote.action);
return { ...base, actions, rate, sources, destination, released,
beforeAction: pairedE3NexusReload(game), action: quote.action,
provenance: [...base.provenance,
'Actual unaffordable six-force alliance-rate cancellation leaves the original Moritani pool untouched.',
'Original high-population Guild offers an independent printed Junction half/full tariff.'] };
}
/** Explicitly controlled older fixture: its wallet/phase/army and conserved
* Nexus identity swap are not original action-only provenance. First combined
* turn, Cunning grant, optional bribe, second declaration and all rate choices
* are actual actions. The observer's conserved Karama is dealt by the existing
* controlled helper, not claimed as an original auction purchase. */
export function createControlledGuildCunningHomeworldRateScene(unaffordable = false) {
const base = nexusGuildCunningFixture({ homeworlds: true });
let game = originalGuildCunningTurn(base);
const karama = holdGuildCunningCard(game, base.observer, 'karama').id;
game = nexusGuildCunningAllow(stepPairedE3Nexus(game,
{ actor: base.owner, action: nexusGuildCunningRequest(base, game) }));
const actions: PairedE3NexusStep[] = [];
if (unaffordable) {
const amount = pairedE3NexusPlayer(game, base.owner).spice - 2;
assert.ok(amount >= 0);
if (amount) game = stepPairedE3Nexus(game, { actor: base.owner,
action: { type: 'bribe', target: base.observer, amount } }, actions);
}
const beforeAction = pairedE3NexusReload(game);
const sources = { 'homeworld:guild': { normal: 3, elite: 0 } };
const destination = 'homeworld:harkonnen';
const choice = homeworldShipmentChoice(viewGame(game, base.owner), destination, sources, 0);
assert.equal(choice.blocked, null);
assert.ok(choice.action);
game = stepPairedE3Nexus(game, { actor: base.owner, action: choice.action }, actions);
if (game.decision?.kind === 'homeworldShipmentGuild')
game = stepPairedE3Nexus(game, { actor: base.owner,
action: { type: 'decision', event: game.decision.event, allow: true } }, actions);
assert.equal(game.response?.kind, 'guildRate');
return { ...base, game, beforeAction, sources, destination, actions, karama,
action: choice.action,
cancel: { actor: base.observer, action: { type: 'card', mode: 'cancel', card: karama } as Action },
provenance: ['Controlled prior Nexus Guild fixture; physical hand acquisition is controlled, not auctioned.',
'Actual completed first combined turn, actual Cunning, and actual second typed Homeworld declaration.'] };
}
+186
View File
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { distance, TERRITORIES } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { quoteSmugglerShipment } from '../game/smuggler-shipment';
import { createGuildAmbassadorIncomeArrival } from './fixture-guild-ambassador-income-arrival';
import {
nextPairedE3NexusStep, pairedE3NexusClean, pairedE3NexusPlayer,
pairedE3NexusReload, quietPairedE3NexusChoice, stepPairedE3Nexus,
type PairedE3NexusStep,
} from './fixture-paired-e3-nexus-modules';
import { smugglerNoFieldAction, smugglerNoFieldGame } from './smuggler-no-field-fixture';
/** Deliberately selected standalone modules and one composition, not a matrix. */
export const NATIVE_GUILD_RATE_MODULE_SELECTIONS = {
skills: { skills: true },
discovery: { discovery: true },
tech: { tech: true },
strongholds: { strongholds: true },
composed: { skills: true, discovery: true, tech: true, strongholds: true },
} as const;
export type NativeGuildRateModuleSelection = keyof typeof NATIVE_GUILD_RATE_MODULE_SELECTIONS;
export type NativeGuildRateRoute = 'reserve' | 'transport';
/** Original setup choices flow through the shared lobby/setup; modules are never
* grafted onto a played game. Original undealt entropy, the paid Karama auction,
* the real Nexus alliance and paid Ambassador retain the shared provenance. */
export function createNativeGuildRateModules(
selection: NativeGuildRateModuleSelection = 'composed',
route: NativeGuildRateRoute = 'reserve',
amount = 3,
) {
const original = createGuildAmbassadorIncomeArrival(NATIVE_GUILD_RATE_MODULE_SELECTIONS[selection]);
const action: Action = route === 'transport' ? { ...original.action, amount } : {
type: 'ship', territory: original.destination, sector: original.sector,
amount, elite: 0, allyPayment: 0,
};
return { ...original, selection, route, amount, action,
provenance: [...original.provenance,
`Original setup selection: ${selection}; ${route} declaration is an actual owner action.`] };
}
/** Stop before paying or moving anything. Advanced reserve permission is a
* separate real Guild choice, never a synthesized or cleared decision. */
export function declareNativeGuildRateModules(fixture: {
beforeAction: Game; entrant: string; guild: string; action: Action;
}) {
const before = pairedE3NexusReload(fixture.beforeAction), actions: PairedE3NexusStep[] = [];
let game = stepPairedE3Nexus(before, { actor: fixture.entrant, action: fixture.action }, actions);
if (game.decision?.kind === 'guildShipment')
game = stepPairedE3Nexus(game, { actor: fixture.guild,
action: { type: 'decision', allow: true } }, actions);
assert.equal(game.response?.kind, 'guildRate');
return { before, actions, game };
}
/** Pass only this rate window; the independent income response stays visible. */
export function allowNativeGuildRateModules(state: Game) {
let game = pairedE3NexusReload(state);
const actions: PairedE3NexusStep[] = [];
for (let n = 0; game.response?.kind === 'guildRate' && n < 20; n++) {
const next = nextPairedE3NexusStep(game); assert.ok(next);
assert.equal(next.action.type, 'passResponse');
game = stepPairedE3Nexus(game, next, actions);
}
assert.notEqual(game.response?.kind, 'guildRate');
return { game, actions };
}
export function cancelNativeGuildRateModules(state: Game, actor: string, card: string) {
assert.equal(state.response?.kind, 'guildRate');
assert.ok(viewGame(state, actor).responseControls?.cancelCards.includes(card));
const step: PairedE3NexusStep = { actor, action: { type: 'card', mode: 'cancel', card } };
return { game: stepPairedE3Nexus(state, step), actions: [step] };
}
/** Drain the actual payment/arrival suffix, declining optional entry effects.
* No second shipment, move, phase advancement or physical repair is issued. */
export function settleNativeGuildRateModules(state: Game) {
let game = pairedE3NexusReload(state);
const actions: PairedE3NexusStep[] = [];
for (let n = 0; n < 100; n++) {
if (pairedE3NexusClean(game) && !game.pendingShipment && !game.pendingGuildTransport)
return { game, actions };
const next = nextPairedE3NexusStep(game) ?? quietPairedE3NexusChoice(game);
assert.ok(next, 'The original payment/arrival owner must have a real continuation.');
game = stepPairedE3Nexus(game, next, actions);
}
throw Error('Original native Guild module arrival did not settle.');
}
/** A real one-force ordinary ground move after shipping proves that neither
* rate choice consumes or replays the native movement allowance. */
export function moveAfterNativeGuildRateModules(fixture: {
entrant: string; source: string; destination: string;
}, state: Game) {
assert.ok(pairedE3NexusClean(state));
const player = pairedE3NexusPlayer(state, fixture.entrant);
assert.ok(player.forces[fixture.source] > 0);
const destination = TERRITORIES.filter(t => t.type !== 'stronghold' &&
t.id !== fixture.source.split(':')[0] && t.id !== fixture.destination &&
state.players.every(p => presenceAt(p, t.id) === 0))
.flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
.find(to => to.sector !== state.storm &&
distance(fixture.source, `${to.territory}:${to.sector}`,
key => Number(key.split(':')[1]) === state.storm) <= 1);
assert.ok(destination, 'The original Polar Sink source must have an empty adjacent terrain.');
const step: PairedE3NexusStep = { actor: fixture.entrant,
action: { type: 'move', from: fixture.source, amount: 1, elite: 0, ...destination } };
const before = pairedE3NexusReload(state);
const afterMove = stepPairedE3Nexus(before, step);
const settled = settleNativeGuildRateModules(afterMove);
return { before, step, game: settled.game, actions: [step, ...settled.actions],
target: `${destination.territory}:${destination.sector}` };
}
/** Original Moritani actually accepts an originally offered Smuggler disc.
* Empty ordinary terrain keeps the printed free counter independent of Guild's
* cancelable rate. The entire group is physical; amount - 1 is priced. */
export function createNativeGuildRateSmugglerShipment(composed = false, amount = 3) {
const original = createGuildAmbassadorIncomeArrival({
...(composed ? NATIVE_GUILD_RATE_MODULE_SELECTIONS.composed : {}),
skills: true, training: { moritani: 'smuggler' },
});
const game = original.beforeAction;
const destination = TERRITORIES.find(t => t.type === 'sand' &&
t.sectors.some(sector => sector !== game.storm) &&
game.players.every(p => presenceAt(p, t.id) === 0));
assert.ok(destination, 'The original board must retain empty ordinary desert.');
const sector = destination.sectors.find(s => s !== game.storm)!;
assert.ok(quoteSmugglerShipment(game, original.entrant, destination.id, amount));
const action: Action = { type: 'ship', territory: destination.id, sector,
amount, elite: 0, allyPayment: 0, smuggler: true };
return { ...original, destination: destination.id, sector,
target: `${destination.id}:${sector}`, action, amount, route: 'reserve' as const,
provenance: [...original.provenance,
'Original undealt training order offers Smuggler; Moritani accepts through leaderSkill.',
'Ordinary reserve shipment into actual empty desert; one printed free physical counter.'] };
}
export interface ControlledGuildRateNoFieldCompanion {
beforeAction: Game; entrant: string; guild: string; canceler: string;
karama: string; action: Action; destination: string; sector: number;
target: string; value: 0 | 3 | 5; provenance: string[];
}
/** Explicitly CONTROLLED regression, NOT an original action-only producer.
* Existing source performs genuine Richese/Skills training, then conserves and
* stages turn/armies/wallets. Here mutual alliance is also staged and one
* original deck Karama is conserved into Harkonnen custody. The original E3
* producer cannot include Richese: its original roster/expansion is Ecaz-only.
* No new training, card identity or force is invented. */
export function createControlledGuildRateNoFieldCompanion(
value: 0 | 3 | 5 = 5,
): ControlledGuildRateNoFieldCompanion {
const game = smugglerNoFieldGame(false);
const entrant = 'r', guild = 'g', canceler = 'h';
pairedE3NexusPlayer(game, entrant).ally = guild;
pairedE3NexusPlayer(game, guild).ally = entrant;
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0];
pairedE3NexusPlayer(game, canceler).hand.push(card);
const destination = 'wind_pass', sector = 14;
const action: Action = { ...smugglerNoFieldAction(game, value), territory: destination, sector };
return { beforeAction: pairedE3NexusReload(game), entrant, guild, canceler,
karama: card.id, action, destination, sector, target: `${destination}:${sector}`, value,
provenance: [
'CONTROLLED existing smugglerNoFieldGame: genuine training, staged shipment turn/forces/wallets.',
'CONTROLLED mutual Richese/Guild alliance; physical deck Karama transferred to Harkonnen.',
'Actual No-Field declaration, response choices, native rate choice and reveal thereafter.',
] };
}
export function declareControlledGuildRateNoFieldCompanion(
fixture: ControlledGuildRateNoFieldCompanion,
) {
const actions: PairedE3NexusStep[] = [];
let game = stepPairedE3Nexus(fixture.beforeAction,
{ actor: fixture.entrant, action: fixture.action }, actions);
for (let n = 0; game.response?.kind === 'richeseNoField' && n < 20; n++) {
const next = nextPairedE3NexusStep(game); assert.ok(next);
assert.equal(next.action.type, 'passResponse');
game = stepPairedE3Nexus(game, next, actions);
}
assert.equal(game.response?.kind, 'guildRate');
return { game, actions };
}
+8 -5
View File
@@ -33,6 +33,7 @@ export interface PairedE3NexusOptions {
roster?: readonly FactionId[];
advanced?: boolean;
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
@@ -98,7 +99,9 @@ export function advancePairedE3Nexus(g: Game, until: (g: Game) => boolean,
* available. Battle choices are not supplied by this traversal. */
export function quietPairedE3NexusChoice(g: Game): PairedE3NexusStep | null {
const d = g.decision; if (!d || g.response || g.phaseOpening) return null;
if (['moritaniPlacement', 'ecazPlacement', 'ecazAmbassador', 'moritaniAssassinate',
if (d.kind === 'ecazAmbassador') return { actor: d.player,
action: { type: 'decision', event: g.pendingAmbassador!.event, decline: true } };
if (['moritaniPlacement', 'ecazPlacement', 'moritaniAssassinate',
'homeworldRevivalDeployment'].includes(d.kind))
return { actor: d.player, action: { type: 'decision', decline: true,
...('event' in d ? { event: d.event } : {}) } };
@@ -135,11 +138,11 @@ function lottery<T>(rolls: readonly number[], operation: () => T): T {
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function selectedSkills(g: Game): LeaderSkillId[] {
function selectedSkills(g: Game, training?: PairedE3NexusOptions['training']): LeaderSkillId[] {
const preferred: Partial<Record<FactionId, LeaderSkillId>> = { ecaz: 'warmaster', moritani: 'master-of-assassins',
guild: 'suk-graduate', emperor: 'swordmaster-of-ginaz', atreides: 'bureaucrat',
fremen: 'killer-medic', beneGesserit: 'prana-bindu-adept', harkonnen: 'smuggler' };
return g.players.map(p => preferred[p.faction]!);
return g.players.map(p => training?.[p.faction] ?? preferred[p.faction]!);
}
function traitorRolls(g: Game, faction: 'guild' | 'ecaz' = 'guild'): number[] {
const original = traitorDeck(g.players), loyal = g.advanced ? chooseEcazLoyalty(original, 0xffffffff / 0x100000000) : null;
@@ -182,7 +185,7 @@ export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): Pa
for (const p of g.players) if (!p.ready) g = stepPairedE3Nexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
if (options.initial) g = skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g);
else {
const sourceSkills = LEADER_SKILL_CARDS.map(c => c.id), selected = selectedSkills(g);
const sourceSkills = LEADER_SKILL_CARDS.map(c => c.id), selected = selectedSkills(g, options.training);
const rest = sourceSkills.filter(c => !selected.includes(c));
const skillOrder = selected.flatMap(c => [c, rest.shift()!]).concat(rest);
const cards = treacheryDeck(['ecaz']), remaining = [...cards];
@@ -203,7 +206,7 @@ export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): Pa
assert.equal(!!g.leaderSkills, skills); assert.equal(!!g.discoveries, discovery);
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled);
assert.ok(g.nexusCards?.cards);
const offered = pairedE3NexusReload(g), selected = selectedSkills(g);
const offered = pairedE3NexusReload(g), selected = selectedSkills(g, options.training);
for (let n = 0; g.status === 'setup' && n < 300; n++) {
let next: PairedE3NexusStep;
if (g.setupStage === 'leaderSkills') {
+212
View File
@@ -0,0 +1,212 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game, type GameView } from '../game/engine';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { quoteHomeworldShipment } from '../game/homeworld-shipment';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
import { junctionTransportChoice } from '../game/junction-transport-options';
import {
createNativeGuildHomeworldRate, createNativeGuildHomeworldRateScene,
createNativeImmuneJunctionRate, nativeGuildHomeworldAction,
passNativeGuildHomeworldResponses, type NativeGuildHomeworldRateFixture,
createControlledGuildCunningHomeworldRateScene,
} from './fixture-native-guild-homeworld-rate';
import { pairedE3NexusPlayer as player, stepPairedE3Nexus } from './fixture-paired-e3-nexus-modules';
const act = (g: Game, actor: string, action: Action) => applyAction(structuredClone(g), actor, action);
function context(g: Game, nativeGuildRateCanceled?: boolean) {
return { ...homeworldContext(g), nativeGuildRateCanceled,
players: homeworldContext(g).players.map(p => ({ ...p, ally: player(g, p.id).ally })),
storm: g.storm, mobileStronghold: g.mobileStronghold?.location ?? null,
discoveries: g.discoveries,
board: Object.fromEntries(g.players.map(p => [p.id, { forces: p.forces, eliteForces: p.elites?.forces ?? {} }])),
};
}
function assertUnpaid(f: NativeGuildHomeworldRateFixture, state: Game) {
const before = f.beforeAction;
assert.deepEqual(state.homeworlds, before.homeworlds);
assert.deepEqual(state.aid, before.aid);
for (const id of [f.guild, f.entrant]) {
assert.equal(player(state, id).spice, player(before, id).spice);
assert.equal(player(state, id).reserves, player(before, id).reserves);
assert.deepEqual(player(state, id).forces, player(before, id).forces);
assert.equal(player(state, id).shipped, player(before, id).shipped);
}
assert.ok(!state.pendingArrivalReaction);
assert.ok(!state.pendingAmbassador);
}
function assertPaid(f: NativeGuildHomeworldRateFixture, paid: Game, amount: number, cost: number) {
const before = f.beforeAction;
const old = player(before, f.guild), guild = player(paid, f.guild);
// A Guild shipper pays its own share to the bank. The approved Moritani
// share independently earns the Guild its existing FAQ contributor income.
const settled = passNativeGuildHomeworldResponses(paid, 'guildIncome').game;
assert.equal(player(settled, f.guild).spice, old.spice - cost + 2);
// Pledging already debited the donor wallet before this declaration.
assert.equal(player(settled, f.entrant).spice, player(before, f.entrant).spice);
assert.equal(settled.aid[f.entrant].amount, 0);
assert.equal(guild.shipped, true);
assert.equal(guild.reserves, old.reserves + (f.route === 'return' ? amount : f.route === 'world' ? -amount : 0));
if (f.route !== 'world')
assert.equal(guild.forces[f.source] ?? 0, old.forces[f.source] - amount);
else assert.deepEqual(guild.forces, old.forces);
if (f.route !== 'return') {
assert.deepEqual(settled.homeworlds!.custody!.visitors[f.destination][f.guild], { normal: amount, elite: 0 });
} else assert.equal(settled.homeworlds!.custody!.visitors[f.destination]?.[f.guild], undefined);
assert.ok(!settled.pendingHomeworldShipment);
assert.ok(!settled.pendingArrivalReaction);
assert.ok(!settled.pendingAmbassador);
homeworldGameIntegrity(settled);
const worlds = homeworldForceGroups(context(settled), settled.homeworlds!.custody!);
const physical = Object.values(player(settled, f.guild).forces).reduce((n, count) => n + count, 0) +
worlds.reduce((n, w) => n + (w.forces[f.guild]?.normal ?? 0) + (w.forces[f.guild]?.elite ?? 0), 0);
assert.equal(physical, 20);
return settled;
}
for (const route of ['world', 'arrakisForeign', 'return'] as const) {
void test(`original native Homeworld ${route}: actual auctioned Ecaz Karama precedes every debit and physical transfer`, () => {
const scene = createNativeGuildHomeworldRateScene(route);
assertUnpaid(scene, scene.game);
assert.equal(scene.game.pendingHomeworldShipment?.cost, 2);
assert.equal(scene.game.pendingHomeworldShipment?.allyPayment, 1);
assert.deepEqual(scene.game.pendingHomeworldShipment?.sources, scene.sources);
const stored = structuredClone(scene.game);
assert.throws(() => act(stored, scene.entrant, scene.cancel.action));
assert.deepEqual(stored, scene.game);
const canceled = stepPairedE3Nexus(scene.game, scene.cancel);
const settled = assertPaid(scene, canceled, 3, 3);
assert.equal(settled.discard.filter(c => c.id === scene.karama).length, 1);
assert.throws(() => act(settled, scene.guild, scene.action));
const next = act(settled, scene.guild, { type: 'endMovement' });
assert.notEqual(next.active, scene.guild);
assert.deepEqual(player(next, scene.guild).forces, player(settled, scene.guild).forces);
});
void test(`original native Homeworld ${route}: passing preserves the rounded half tariff and original approved split`, () => {
const scene = createNativeGuildHomeworldRateScene(route);
assertUnpaid(scene, scene.game);
const allowed = passNativeGuildHomeworldResponses(scene.game, 'guildRate').game;
const settled = assertPaid(scene, allowed, 3, 2);
assert.ok(player(settled, scene.ecaz).hand.some(c => c.id === scene.karama));
});
void test(`original native Homeworld ${route}: unaffordable cancellation releases unchanged typed custody and supports a full-price replacement`, () => {
const scene = createNativeGuildHomeworldRateScene(route, { unaffordable: true });
assert.equal(player(scene.beforeAction, scene.guild).spice, 1);
assertUnpaid(scene, scene.game);
const released = stepPairedE3Nexus(scene.game, scene.cancel);
assertUnpaid(scene, released);
assert.ok(!released.pendingHomeworldShipment);
assert.equal(released.response, null);
assert.equal(released.decision, null);
assert.equal(viewGame(released, scene.guild).guildRateCanceled, true);
assert.equal(viewGame(released, scene.ecaz).guildRateCanceled, false);
const view = viewGame(released, scene.guild);
const consumer = route === 'world' ? homeworldShipmentChoice : guildHomeworldShipmentChoice;
const unchanged = consumer(view, scene.destination, scene.sources, 1);
assert.equal(unchanged.cost, 3);
assert.equal(unchanged.ownPayment, 2);
assert.ok(unchanged.blocked);
const action = nativeGuildHomeworldAction(scene, released, 2, 1);
assert.deepEqual(action.sources, { [scene.source]: { normal: 2, elite: 0 } });
let replacement = act(released, scene.guild, action);
if (replacement.decision?.kind === 'homeworldShipmentGuild')
replacement = act(replacement, scene.guild, { type: 'decision', event: replacement.decision.event, allow: true });
assert.notEqual(replacement.response?.kind, 'guildRate');
const settled = assertPaid(scene, replacement, 2, 2);
assert.equal(settled.discard.filter(c => c.id === scene.karama).length, 1);
});
}
void test('non-Guild world-to-world remains full price; controlled Secret Ally owner quote stays independent of native cancellation', () => {
const f = createNativeGuildHomeworldRate('world'), g = f.beforeAction;
const intent = { player: f.ecaz, destination: 'homeworld:moritani',
sources: { 'homeworld:ecaz': { normal: 3, elite: 0 } } };
assert.equal(quoteHomeworldShipment(context(g, true), g.homeworlds!.custody!, intent).cost, 3);
// Narrow controlled presentation fixture, not an original action producer:
// Secret Ally legally requires Guild absent, no alliance, and an owned card.
const original = viewGame(g, f.ecaz);
const event = JSON.stringify(['nexusGuildSecretAlly', original.turn, f.ecaz]);
const view = { ...original, active: f.ecaz, guildRateCanceled: true,
players: original.players.filter(p => p.id !== f.guild).map(p => ({ ...p, ally: null, shipped: false })),
homeworlds: { ...original.homeworlds!, worlds: original.homeworlds!.worlds!.filter(w => w.native !== f.guild) },
homeworldShipment: { event: 'controlled-secret-world-quote', blocked: null },
nexusCards: { ...original.nexusCards!, card: 'guild', waiting: [] },
nexusGuildSecretAlly: { event, blocked: null },
} as GameView;
const ordinary = homeworldShipmentChoice(view, intent.destination, intent.sources);
const secret = homeworldShipmentChoice(view, intent.destination, intent.sources, 0, event);
assert.equal(ordinary.cost, 3);
assert.equal(secret.cost, 2);
assert.equal(secret.blocked, null);
});
for (const rate of ['half', 'full'] as const) {
void test(`original immune Junction ${rate} offer after actual native cancellation keeps its printed tariff and no second rate queue`, () => {
const f = createNativeImmuneJunctionRate(rate), before = f.beforeAction;
const cost = rate === 'half' ? 2 : 3;
assert.equal(viewGame(before, f.entrant).guildRateCanceled, true);
const view = viewGame(before, f.entrant);
assert.equal(junctionTransportChoice(view, f.destination, f.sources).cost, cost);
const done = act(before, f.entrant, f.action);
assert.notEqual(done.response?.kind, 'guildRate');
assert.equal(done.decision, null);
assert.ok(!done.pendingHomeworldShipment);
assert.equal(player(done, f.entrant).spice, player(before, f.entrant).spice - cost);
assert.equal(player(done, f.guild).spice, player(before, f.guild).spice);
assert.equal(player(done, f.entrant).reserves, player(before, f.entrant).reserves + 3);
assert.equal(player(done, f.entrant).forces[f.source], player(before, f.entrant).forces[f.source] - 3);
assert.equal(player(done, f.entrant).shipped, true);
assert.equal(done.discard.filter(c => c.id === f.karama).length, 1);
homeworldGameIntegrity(done);
});
}
for (const outcome of ['allow', 'cancel', 'unaffordable'] as const) {
void test(`controlled Cunning Homeworld ${outcome}: the native rate retains one original second-shipment allowance`, () => {
const f = createControlledGuildCunningHomeworldRateScene(outcome === 'unaffordable');
const before = f.beforeAction, record = before.nexusGuildCunningHistory!.at(-1)!;
assert.equal(record.stage, 'secondShipment');
assert.equal(record.shipment, undefined);
assert.deepEqual(f.game.homeworlds, before.homeworlds);
assert.equal(player(f.game, f.owner).spice, player(before, f.owner).spice);
assert.equal(player(f.game, f.owner).reserves, player(before, f.owner).reserves);
assert.equal(f.game.nexusGuildCunningHistory!.at(-1)!.shipment?.stage, 'pending');
assert.equal(f.game.pendingHomeworldShipment?.guildNexusEvent, record.receipt.event);
let done = outcome === 'allow'
? passNativeGuildHomeworldResponses(f.game, 'guildRate').game
: stepPairedE3Nexus(f.game, f.cancel);
if (outcome === 'unaffordable') {
assert.equal(player(done, f.owner).spice, 2);
assert.deepEqual(done.homeworlds, before.homeworlds);
assert.equal(player(done, f.owner).reserves, player(before, f.owner).reserves);
assert.equal(done.nexusGuildCunningHistory!.at(-1)!.stage, 'secondShipment');
assert.equal(done.nexusGuildCunningHistory!.at(-1)!.shipment, undefined);
assert.ok(!done.pendingHomeworldShipment);
const choice = homeworldShipmentChoice(viewGame(done, f.owner), f.destination,
{ 'homeworld:guild': { normal: 2, elite: 0 } }, 0);
assert.equal(choice.cost, 2);
assert.ok(choice.action);
done = act(done, f.owner, choice.action);
assert.notEqual(done.response?.kind, 'guildRate');
}
const amount = outcome === 'unaffordable' ? 2 : 3;
const cost = outcome === 'cancel' ? 3 : 2;
assert.equal(player(done, f.owner).spice, player(before, f.owner).spice - cost);
assert.equal(player(done, f.owner).reserves, player(before, f.owner).reserves - amount);
assert.deepEqual(done.homeworlds!.custody!.visitors[f.destination][f.owner], { normal: amount, elite: 0 });
assert.equal(done.nexusGuildCunningHistory!.at(-1)!.shipment?.stage, 'complete');
assert.equal(done.nexusGuildCunningHistory!.at(-1)!.receipt.event, record.receipt.event);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
assert.equal(done.discard.filter(card => card.id === f.karama).length, outcome === 'allow' ? 0 : 1);
const another = homeworldShipmentChoice(viewGame(done, f.owner), f.destination,
{ 'homeworld:guild': { normal: 1, elite: 0 } }, 0);
assert.equal(another.action, null);
assert.ok(another.blocked);
homeworldGameIntegrity(done);
});
}
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, type Game } from '../game/engine';
import { createGuildBetrayalFixture, settleGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
for (const source of ['reserve', 'guildTransport', 'homeworld'] as const) {
for (const use of [false, true]) {
void test(`native ${source} full-price cancellation survives deferred Guild Betrayal ${use ? 'Use' : 'Pass'} until physical settlement`, () => {
const f = createGuildBetrayalFixture({ source, payer: 'guild', homeworlds: source === 'homeworld' });
const before = structuredClone(f.before);
// Explicit controlled extension of the existing native source fixture:
// conserve a real undealt Karama into the future invoice holder's hand.
const index = before.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0);
const card = before.deck.splice(index, 1)[0];
player(before, f.holder).hand.push(card);
let g = applyAction(structuredClone(before), f.guild, f.declaration);
if (g.decision?.kind === 'guildShipment' || g.decision?.kind === 'homeworldShipmentGuild')
g = applyAction(g, f.guild, { type: 'decision', allow: true,
...(g.decision.kind === 'homeworldShipmentGuild' ? { event: g.decision.event } : {}) });
assert.equal(g.response?.kind, 'guildRate');
const amount = g.pendingShipment?.amount ?? g.pendingGuildTransport?.amount ?? g.pendingHomeworldShipment!.amount;
assert.ok(amount > 1);
assert.ok(player(before, f.guild).spice >= amount);
assert.equal(player(g, f.guild).spice, player(before, f.guild).spice);
g = applyAction(g, f.holder, { type: 'card', mode: 'cancel', card: card.id });
const frame = g.pendingGuildBetrayal!;
assert.ok(frame);
assert.equal(frame.invoice.price, amount);
assert.equal(player(g, f.guild).spice, player(before, f.guild).spice);
assert.deepEqual(player(g, f.guild).forces, player(before, f.guild).forces);
assert.equal(player(g, f.guild).reserves, player(before, f.guild).reserves);
assert.equal(player(g, f.guild).shipped, false);
assert.deepEqual(g.homeworlds, before.homeworlds);
if (use) g = applyAction(g, f.holder, { type: 'guildBetrayalUse', event: frame.invoice.event });
g = settleGuildBetrayalFixture(g);
assert.equal(player(g, f.guild).spice, player(before, f.guild).spice - amount);
assert.equal(player(g, f.holder).spice, player(before, f.holder).spice + (use ? amount : 0));
assert.equal(player(g, f.guild).shipped, true);
assert.deepEqual(player(g, f.guild).forces, player(f.original, f.guild).forces);
assert.equal(player(g, f.guild).reserves, player(f.original, f.guild).reserves);
assert.deepEqual(g.homeworlds, f.original.homeworlds);
assert.equal(g.guildBetrayal?.status, 'completed');
assert.ok(!g.pendingGuildBetrayal);
assert.equal(g.discard.filter(c => c.id === card.id).length, 1);
assert.throws(() => applyAction(structuredClone(g), f.holder,
{ type: 'guildBetrayalUse', event: frame.invoice.event }));
});
}
}
+192
View File
@@ -0,0 +1,192 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { presenceAt } from '../game/force-presence';
import { quoteSmugglerShipment } from '../game/smuggler-shipment';
import { conserveNoFieldForces } from './smuggler-no-field-fixture';
import {
pairedE3NexusInventory, pairedE3NexusPlayer as player,
} from './fixture-paired-e3-nexus-modules';
import {
allowNativeGuildRateModules, cancelNativeGuildRateModules,
createControlledGuildRateNoFieldCompanion, createNativeGuildRateModules,
createNativeGuildRateSmugglerShipment, declareControlledGuildRateNoFieldCompanion,
declareNativeGuildRateModules, moveAfterNativeGuildRateModules,
settleNativeGuildRateModules,
type NativeGuildRateModuleSelection, type NativeGuildRateRoute,
} from './fixture-native-guild-rate-modules';
const act = (game: Game, actor: string, action: Action) =>
applyAction(structuredClone(game), actor, action);
function reject(game: Game, actor: string, action: Action) {
const copy = structuredClone(game), before = structuredClone(copy);
assert.throws(() => applyAction(copy, actor, action));
assert.deepEqual(copy, before);
}
function assertUnpaid(before: Game, offered: Game, entrant: string, guild: string) {
for (const actor of [entrant, guild]) {
const old = player(before, actor), current = player(offered, actor);
assert.equal(current.spice, old.spice);
assert.equal(current.reserves, old.reserves);
assert.deepEqual(current.forces, old.forces);
assert.equal(current.shipped, old.shipped);
assert.equal(current.moved, old.moved);
}
}
function assertPhysical(fixture: {
entrant: string; guild: string; source: string; target: string;
route: NativeGuildRateRoute; amount: number;
}, before: Game, done: Game, cost: number) {
const old = player(before, fixture.entrant), current = player(done, fixture.entrant);
assert.equal(current.spice, old.spice - cost);
assert.equal(player(done, fixture.guild).spice, player(before, fixture.guild).spice + cost);
assert.equal(current.reserves, old.reserves - (fixture.route === 'reserve' ? fixture.amount : 0));
assert.equal(current.forces[fixture.source],
old.forces[fixture.source] - (fixture.route === 'transport' ? fixture.amount : 0));
assert.equal(current.forces[fixture.target], (old.forces[fixture.target] ?? 0) + fixture.amount);
assert.equal(current.shipped, true);
assert.equal(current.moved, old.moved);
assert.equal(current.reserves + current.tanks + Object.values(current.forces).reduce((n, amount) => n + amount, 0),
old.reserves + old.tanks + Object.values(old.forces).reduce((n, amount) => n + amount, 0));
}
// Each standalone selection contributes one real route/outcome. The two
// composed examples cover both routes without claiming an option matrix.
const cases: { selection: NativeGuildRateModuleSelection; route: NativeGuildRateRoute; cancel: boolean }[] = [
{ selection: 'skills', route: 'reserve', cancel: true },
{ selection: 'discovery', route: 'transport', cancel: false },
{ selection: 'tech', route: 'reserve', cancel: false },
{ selection: 'strongholds', route: 'transport', cancel: true },
{ selection: 'composed', route: 'reserve', cancel: false },
{ selection: 'composed', route: 'transport', cancel: true },
];
for (const { selection, route, cancel } of cases) {
void test(`original ${selection} ${route}: ${cancel ? 'cancel' : 'allow'} rate, pay and transport once, then native movement`, () => {
const f = createNativeGuildRateModules(selection, route), before = f.beforeAction;
assert.equal(before.turn, 2);
assert.equal(before.phase, 5);
assert.equal(before.active, f.entrant);
assert.equal(player(before, f.entrant).ally, f.guild);
if (selection === 'skills' || selection === 'composed') {
assert.ok(before.leaderSkills?.assignments.length);
assert.ok(f.actions.some(step => step.action.type === 'leaderSkill'));
}
if (selection === 'discovery' || selection === 'composed') assert.ok(before.discoveries?.tokens.length);
if (selection === 'tech' || selection === 'composed') assert.ok(before.techTokens);
if (selection === 'strongholds' || selection === 'composed') assert.ok(before.strongholdCards);
const offered = declareNativeGuildRateModules(f).game;
assertUnpaid(before, offered, f.entrant, f.guild);
assert.ok(viewGame(offered, f.ecaz).responseControls?.cancelCards.includes(f.karama));
reject(offered, f.entrant, f.action);
const priced = cancel
? cancelNativeGuildRateModules(offered, f.ecaz, f.karama).game
: allowNativeGuildRateModules(offered).game;
const cost = cancel ? 3 : 2;
// A still-held Karama preserves the separate income response. After the
// canceling card is spent, an unanswerable income window may auto-resolve.
if (!cancel) assert.equal(priced.response?.kind, 'guildIncome');
assert.equal(player(priced, f.entrant).spice, player(before, f.entrant).spice - cost);
assert.equal(player(priced, f.guild).spice, player(before, f.guild).spice +
(priced.response?.kind === 'guildIncome' ? 0 : cost));
const done = settleNativeGuildRateModules(priced).game;
assertPhysical(f, before, done, cost);
assert.equal(done.discard.filter(card => card.id === f.karama).length, cancel ? 1 : 0);
assert.equal(player(done, f.ecaz).hand.some(card => card.id === f.karama), !cancel);
reject(done, f.entrant, f.action);
const moved = moveAfterNativeGuildRateModules(f, done);
assert.equal(player(moved.game, f.entrant).moved, player(done, f.entrant).moved + 1);
assert.equal(player(moved.game, f.entrant).forces[f.source], player(done, f.entrant).forces[f.source] - 1);
assert.equal(player(moved.game, f.entrant).forces[moved.target], 1);
assert.equal(player(moved.game, f.entrant).spice, player(done, f.entrant).spice);
assert.equal(player(moved.game, f.guild).spice, player(done, f.guild).spice);
assert.equal(player(moved.game, f.entrant).reserves, player(done, f.entrant).reserves);
assert.equal(player(moved.game, f.entrant).shipped, true);
reject(moved.game, f.entrant, moved.step.action);
const next = act(moved.game, f.entrant, { type: 'endMovement' });
assert.notEqual(next.active, f.entrant);
assert.equal(player(next, f.entrant).spice, player(moved.game, f.entrant).spice);
assert.deepEqual(player(next, f.entrant).forces, player(moved.game, f.entrant).forces);
pairedE3NexusInventory(moved.game);
});
}
for (const cancel of [false, true]) {
void test(`original ${cancel ? 'composed' : 'Skills'} Smuggler reserve: ${cancel ? 'canceling' : 'allowing'} Guild rate prices two paid counters and transports all three`, () => {
const f = createNativeGuildRateSmugglerShipment(cancel), before = f.beforeAction;
assert.ok(before.players.every(p => presenceAt(p, f.destination) === 0));
const trainer = before.leaderSkills!.assignments.find(a => a.owner === f.entrant && a.skill === 'smuggler');
assert.ok(trainer);
assert.ok(f.actions.some(step => step.actor === f.entrant && step.action.type === 'leaderSkill' &&
step.action.skill === 'smuggler' && step.action.leader === trainer.leader));
assert.deepEqual(quoteSmugglerShipment(before, f.entrant, f.destination, 3), { leader: trainer.leader, amount: 3 });
const offered = declareNativeGuildRateModules(f).game;
assertUnpaid(before, offered, f.entrant, f.guild);
const priced = cancel
? cancelNativeGuildRateModules(offered, f.ecaz, f.karama).game
: allowNativeGuildRateModules(offered).game;
const done = settleNativeGuildRateModules(priced).game;
// Desert ordinary full price is two per PAID counter: (3 - 1) * 2.
// Charging all three would incorrectly cost six after cancellation.
assertPhysical(f, before, done, cancel ? 4 : 2);
assert.equal(done.discard.filter(card => card.id === f.karama).length, cancel ? 1 : 0);
reject(done, f.entrant, f.action);
pairedE3NexusInventory(done);
});
}
void test('original Smuggler canceled rate retains the free counter on an affordable full-price replacement, with no debt or replay', () => {
const f = createNativeGuildRateSmugglerShipment(true);
const before = act(f.beforeAction, f.entrant, { type: 'bribe', target: f.ecaz,
amount: player(f.beforeAction, f.entrant).spice - 3 });
assert.equal(player(before, f.entrant).spice, 3);
const offered = declareNativeGuildRateModules({ ...f, beforeAction: before }).game;
const released = cancelNativeGuildRateModules(offered, f.ecaz, f.karama).game;
assertUnpaid(before, released, f.entrant, f.guild);
assert.equal(player(released, f.entrant).shipped, false);
assert.equal(viewGame(released, f.entrant).guildRateCanceled, true);
assert.equal(released.discard.filter(card => card.id === f.karama).length, 1);
reject(released, f.entrant, f.action);
const replacement: Action = { ...f.action, amount: 2 };
let paid = act(released, f.entrant, replacement);
if (paid.decision?.kind === 'guildShipment') paid = act(paid, f.guild, { type: 'decision', allow: true });
assert.notEqual(paid.response?.kind, 'guildRate');
const done = settleNativeGuildRateModules(paid).game;
assertPhysical({ ...f, amount: 2 }, before, done, 2);
assert.equal(player(done, f.entrant).spice, 1);
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
reject(done, f.entrant, replacement);
});
// Bounded existing controlled Richese source, deliberately separate from the
// original-action producers above. Hidden values are not a pricing parameter.
for (const value of [0, 5] as const) {
void test(`CONTROLLED genuine-trained No-Field ${value}: rate cancellation charges one marker and leaves its one physical Smuggler companion free`, () => {
const f = createControlledGuildRateNoFieldCompanion(value), before = f.beforeAction;
const offered = declareControlledGuildRateNoFieldCompanion(f).game;
assertUnpaid(before, offered, f.entrant, f.guild);
const priced = cancelNativeGuildRateModules(offered, f.canceler, f.karama).game;
const done = settleNativeGuildRateModules(priced).game;
assert.equal(player(done, f.entrant).spice, player(before, f.entrant).spice - 2);
assert.equal(player(done, f.guild).spice, player(before, f.guild).spice + 2);
assert.equal(player(done, f.entrant).reserves, player(before, f.entrant).reserves - 1);
assert.equal(player(done, f.entrant).forces[f.target], 1);
assert.equal(presenceAt(player(done, f.entrant), f.destination), 2);
assert.equal(player(done, f.entrant).noField!.deployed!.tokenId, f.action.noField);
assert.equal(player(done, f.entrant).shipped, true);
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
// Consumer-visible privacy: opponents still see only the concealed marker,
// not its physical token identity or declaration proof.
const otherView = JSON.stringify(viewGame(done, f.canceler));
assert.equal(otherView.includes(String(f.action.noField)), false);
assert.equal(otherView.includes('noFieldSkillProof'), false);
reject(done, f.entrant, f.action);
const revealed = act(done, f.entrant, { type: 'revealNoField',
event: player(done, f.entrant).noFieldEvent, token: f.action.noField });
assert.equal(player(revealed, f.entrant).forces[f.target], value + 1);
assert.equal(player(revealed, f.entrant).reserves, player(before, f.entrant).reserves - value - 1);
assert.equal(player(revealed, f.entrant).spice, player(done, f.entrant).spice);
assert.equal(player(revealed, f.guild).spice, player(done, f.guild).spice);
conserveNoFieldForces(revealed);
reject(revealed, f.entrant, { type: 'card', mode: 'cancel', card: f.karama });
});
}