Files
Dune/tests/fixture-semuta-guild-transport.ts

124 lines
7.4 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type SemutaGuildRoute = 'reserve' | 'cross' | 'return' | 'world' | 'homeworldReturn' | 'homeworldForeign';
export type SemutaGuildStep = { actor: string; action: Action };
const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!;
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard;
/** No played hand/army/wallet/phase is staged: real cache purchase gives Guild
* Semuta, a real normal auction gives Emperor Karama, and native play reaches
* the original Guild turn. Only original undealt lotteries/deck order are selected. */
export function createSemutaGuildTransport(options: {
route?: SemutaGuildRoute;
cancel?: 'rate' | 'permission';
poor?: boolean;
advanced?: boolean;
} = {}) {
const route = options.route ?? 'reserve', kind = options.cancel ?? 'rate';
const homeworlds = route === 'world' || route === 'homeworldReturn' || route === 'homeworldForeign';
let game = createGame('SEMUTAGUILDNATIVE', newPlayer('r', 'Richese', 'richese'),
options.advanced ?? true, ['choam']);
for (const [id, name, faction] of [['g', 'Guild', 'guild'], ['a', 'Atreides', 'atreides'], ['e', 'Emperor', 'emperor']] as const)
joinGame(game, newPlayer(id, name, faction));
if (homeworlds) game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
const actions: SemutaGuildStep[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action }); game = applyAction(game, actor, action);
};
const reach = (until: (state: Game) => boolean) => {
for (let n = 0; !until(game) && n < 1600; n++) {
assert.notEqual(game.status, 'finished');
const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action);
}
assert.ok(until(game), `Original Semuta programme stalled at ${game.turn}/${game.phase}/${game.decision?.kind}`);
};
reach(state => state.status === 'playing');
const setup = structuredClone(game);
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const karama = game.deck.splice(index, 1)[0]; game.deck.unshift(karama);
for (const position of [0, 1]) {
const at = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
assert.ok(at >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
reach(state => state.decision?.kind === 'richeseCache');
act('r', { type: 'decision', event: game.richeseBidding!.event,
card: 'richese-semuta-drug', method: 'onceAround', direction: 'counterclockwise' });
reach(state => !!state.richeseAuction && !state.richeseAuction.outcome &&
state.richeseAuction.active === 'g' && clean(state));
act('g', { type: 'richeseBid', event: game.richeseAuction!.event, amount: 1 });
reach(state => player(state, 'g').hand.some(card => card.id === 'richese-semuta-drug') && clean(state));
const boughtSemuta = structuredClone(game);
reach(state => !!state.auction && state.auction.index === 0 && state.auction.active === 'e' && clean(state));
act('e', { type: 'bid', amount: 1 });
reach(state => player(state, 'e').hand.some(card => card.id === karama.id) && clean(state));
const boughtKarama = structuredClone(game);
reach(state => state.phase === 5 && state.active === 'g' && clean(state));
if (options.poor) {
const excess = player(game, 'g').spice - 2; assert.ok(excess >= 0);
if (excess) act('g', { type: 'bribe', target: 'e', amount: excess });
}
const beforeAction = structuredClone(game);
const destination = TERRITORIES.find(t => t.type === 'sand' &&
t.sectors.some(s => s !== game.storm) && game.players.every(p => presenceAt(p, t.id) === 0));
assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!;
const source = Object.entries(player(game, 'g').forces).find(([key, amount]) =>
amount >= 2 && Number(key.split(':')[1]) !== game.storm)?.[0];
assert.ok(source);
let target = `${destination.id}:${sector}`, action: Action;
if (route === 'reserve') action = { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 };
else if (route === 'cross' || route === 'return') {
if (route === 'return') target = 'reserves';
action = { type: 'guildShip', from: source, territory: route === 'return' ? 'reserves' : destination.id,
sector: route === 'return' ? 0 : sector, amount: 2, elite: 0, allyPayment: 0 };
} else {
target = route === 'homeworldReturn' ? 'homeworld:guild' : 'homeworld:atreides';
const sources = { [route === 'world' ? 'homeworld:guild' : source]: { normal: 2, elite: 0 } };
const quote = route === 'world' ? homeworldShipmentChoice(viewGame(game, 'g'), target, sources)
: guildHomeworldShipmentChoice(viewGame(game, 'g'), target, sources);
assert.ok(quote.action, quote.blocked ?? undefined); action = quote.action;
}
act('g', action);
if (game.decision?.kind === 'guildShipment') act('g', { type: 'decision', allow: true });
else if (game.decision?.kind === 'homeworldShipmentGuild')
act('g', { type: 'decision', event: game.decision.event, allow: true });
if (kind === 'rate') {
for (let n = 0; game.response?.kind === 'guildTransportPermission' && n < 20; n++) {
const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action);
}
}
assert.equal(game.response?.kind, kind === 'rate' ? 'guildRate' : 'guildTransportPermission');
assert.ok(viewGame(game, 'e').responseControls?.cancelCards.includes(karama.id));
return { setup, boughtSemuta, boughtKarama, beforeAction, beforeCost: structuredClone(game),
game, route, kind, source, target, destination: destination.id, sector, action, actions,
guild: 'g', holder: 'g', spender: 'e', karama: karama.id, semuta: 'richese-semuta-drug',
cancel: { type: 'card', mode: 'cancel', card: karama.id } as Action,
provenance: [
'Original Semuta setup; selected original undealt lottery and conserved unplayed Spice/Treachery deck order.',
'Guild buys actual public Semuta cache lot for1; Emperor buys actual normal Karama auction for1.',
'Original native Guild turn, original source forces and actual permission/interception; optional real outgoing bribe only.',
] };
}
export function reactSemutaGuildTransport(game: Game, claim: boolean) {
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
const reaction = pending.reaction;
assert.ok(reaction?.stage === 'offer');
const event = pending.batch.event;
if (claim) return applyAction(game, 'g', { type: 'semutaCommit', event });
for (const seat of game.players.filter(p => !reaction.passed.includes(p.id)))
game = applyAction(game, seat.id, { type: 'semutaPass', event });
return game;
}