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