Files
Dune/tests/fixture-native-guild-rate-modules.ts
T

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 };
}