198 lines
11 KiB
TypeScript
198 lines
11 KiB
TypeScript
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, passNativeGuildTransportPermission } 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);
|
|
}
|
|
const permission = passNativeGuildTransportPermission(game);
|
|
game = permission.game; actions.push(...permission.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);
|
|
const permission = passNativeGuildTransportPermission(game);
|
|
game = permission.game; actions.push(...permission.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.'] };
|
|
}
|