Compose native Guild rate with module and Homeworld price sources
This commit is contained in:
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.'] };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 }));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user