350 lines
18 KiB
TypeScript
350 lines
18 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {applyAction, createGame, initializeGuildBetrayalGameForAudit, joinGame, newPlayer, viewGame,
|
|
type Action, type Game} from '../game/engine';
|
|
import {botActions} from '../game/bots';
|
|
import type {FactionId} from '../game/catalog';
|
|
import {TERRITORIES, splitLocation} from '../game/board';
|
|
import {botEntryAllowed, fremenReserveEntry} from '../game/bot-mobility';
|
|
import {guildTransportQuote} from '../game/transport-quote';
|
|
import {junctionTransportChoice} from '../game/junction-transport-options';
|
|
import {homeworldShipmentChoice} from '../game/homeworld-shipment-options';
|
|
import {drawNexusCard, validateNexusCards} from '../game/nexus-cards';
|
|
import {homeworldGameIntegrity} from '../game/homeworld-game';
|
|
import {orderNexusSpice} from './fixture-nexus-cards';
|
|
|
|
export type GuildBetrayalFixtureOptions = {
|
|
/** Newly admitted saved setup; preserve its identities and physical setup. */
|
|
initial?: Game;
|
|
advanced?: boolean;
|
|
homeworlds?: boolean;
|
|
source?: 'reserve' | 'guildTransport' | 'homeworld' | 'junction';
|
|
payer?: 'holder' | 'guild' | 'other';
|
|
allyPayment?: number;
|
|
seatIds?: string[];
|
|
nexusFace?: 'guild' | 'choam';
|
|
zero?: boolean;
|
|
occupiedJunction?: boolean;
|
|
};
|
|
export type GuildBetrayalFixture = {
|
|
game: Game;
|
|
before: Game;
|
|
original: Game;
|
|
declaration: Action;
|
|
holder: string;
|
|
guild: string;
|
|
shipper: string;
|
|
donor: string | null;
|
|
event: string;
|
|
price: number;
|
|
source: 'reserve' | 'guildTransport' | 'homeworld' | 'junction';
|
|
};
|
|
|
|
/** Complete only already declared native permission, acknowledgement and income
|
|
* windows. Never creates or replays a shipment action. */
|
|
export function settleGuildBetrayalFixture(state: Game): Game {
|
|
let game = state;
|
|
for (let step = 0; step < 100; step++) {
|
|
const frame = game.pendingGuildBetrayal;
|
|
if (frame) {
|
|
const id = frame.required.find(seat => !frame.passed.includes(seat))!;
|
|
game = applyAction(game, id, {type: 'guildBetrayalPass', event: frame.invoice.event});
|
|
} else if (game.decision?.kind === 'guildShipment') {
|
|
game = applyAction(game, game.decision.player, {type: 'decision', allow: true});
|
|
} else if (game.decision?.kind === 'homeworldShipmentGuild') {
|
|
game = applyAction(game, game.decision.player,
|
|
{type: 'decision', allow: true, event: game.decision.event});
|
|
} else if (game.response) {
|
|
const id = game.players.find(p => !game.response!.passed.includes(p.id))!.id;
|
|
game = applyAction(game, id, {type: 'passResponse'});
|
|
} else return game;
|
|
}
|
|
throw new Error('Native Guild fixture settlement did not complete.');
|
|
}
|
|
|
|
function stepGuildFixture(game: Game): Game {
|
|
for (const p of game.players) {
|
|
const view = viewGame(game, p.id);
|
|
view.players.find(seat => seat.id === p.id)!.bot = 'Easy';
|
|
const action = botActions(view)[0];
|
|
if (action) return applyAction(game, p.id, action);
|
|
}
|
|
throw new Error(`Native fixture has no legal action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
|
}
|
|
function enterGuildFixtureMovement(state: Game, minimumTurn = 1): Game {
|
|
let game = state;
|
|
let orderedTurn = 0;
|
|
for (let step = 0; step < 700; step++) {
|
|
if (game.status === 'playing' && game.turn >= minimumTurn && game.phase === 5 &&
|
|
!game.phaseOpening && !game.response && !game.decision) return game;
|
|
if (game.status === 'playing' && game.phase === 1 && orderedTurn !== game.turn) {
|
|
orderNexusSpice(game, ['land', 'land']);
|
|
orderedTurn = game.turn;
|
|
}
|
|
game = stepGuildFixture(game);
|
|
}
|
|
throw new Error('Genuine Guild fixture did not reach Shipment and Movement.');
|
|
}
|
|
function holdGuildFixtureFace(game: Game, holder: string, face: 'guild' | 'choam'): void {
|
|
const extraFaces: FactionId[] = ['fremen', 'harkonnen', 'ixians', 'choam', 'ecaz', 'moritani', 'beneGesserit', 'atreides'];
|
|
let extraIndex = 0;
|
|
// Arrange only the conserved physical Nexus inventory, before any invoice.
|
|
// Public held-card presence is identical across hidden-face fixture variants.
|
|
let cards = game.nexusCards!.cards!;
|
|
for (const p of game.players) {
|
|
const held = cards.hands[p.id];
|
|
if (held !== null) {cards.deck.push(held); cards.hands[p.id] = null;}
|
|
}
|
|
for (const p of game.players.filter(seat => !seat.ally)) {
|
|
let desired: FactionId;
|
|
if (p.id === holder) desired = face;
|
|
else if (p.faction === 'guild') desired = 'atreides';
|
|
else {
|
|
// Distinct conserved faces for every additional unallied seat; the first
|
|
// extra keeps the established 'fremen' assignment.
|
|
desired = extraFaces[extraIndex % extraFaces.length];
|
|
extraIndex++;
|
|
}
|
|
let index = cards.deck.indexOf(desired);
|
|
if (index < 0) {
|
|
index = cards.discard.indexOf(desired);
|
|
assert.ok(index >= 0, 'The actual singleton face is available.');
|
|
cards.deck.unshift(cards.discard.splice(index, 1)[0]);
|
|
} else cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
|
cards = drawNexusCard(cards, p.id, game.players, () => 0);
|
|
}
|
|
game.nexusCards!.cards = cards;
|
|
validateNexusCards(cards, game.players);
|
|
}
|
|
|
|
function clearGuildFixtureHands(game: Game): void {
|
|
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
|
}
|
|
|
|
function reserveDeclaration(game: Game, shipper: string, amount: number, allyPayment = 0): Action {
|
|
const view = viewGame(game, shipper);
|
|
const payer = view.players.find(p => p.id === shipper)!;
|
|
const candidates = TERRITORIES.filter(t => payer.faction === 'fremen' ?
|
|
fremenReserveEntry(t.id) : t.type === 'stronghold');
|
|
// Prefer a peaceful destination if this shipment prepares a later turn.
|
|
candidates.sort((a, b) => Number(game.players.some(p => p.id !== shipper &&
|
|
Object.keys(p.forces).some(key => splitLocation(key).territory === a.id && p.forces[key] > 0))) -
|
|
Number(game.players.some(p => p.id !== shipper &&
|
|
Object.keys(p.forces).some(key => splitLocation(key).territory === b.id && p.forces[key] > 0))));
|
|
for (const t of candidates) for (const sector of t.sectors) {
|
|
if (sector !== game.storm && botEntryAllowed(view, payer, t.id, sector, 'ship'))
|
|
return {type: 'ship', territory: t.id, sector, amount, allyPayment};
|
|
}
|
|
throw new Error('No current storm-safe native reserve destination.');
|
|
}
|
|
|
|
function fundGuildFixtureSpice(game: Game, id: string, minimum: number, excluded: string | null = null): void {
|
|
const recipient = game.players.find(p => p.id === id)!;
|
|
for (const giver of game.players.filter(p => p.id !== id && p.id !== excluded)) {
|
|
const amount = Math.min(Math.max(0, minimum - recipient.spice), giver.spice);
|
|
giver.spice -= amount;
|
|
recipient.spice += amount;
|
|
}
|
|
assert.ok(recipient.spice >= minimum, 'Upfront spice is transferred, not minted.');
|
|
}
|
|
|
|
function transportDeclaration(game: Game, shipper: string, amount: number, allyPayment: number): Action | null {
|
|
const payer = game.players.find(p => p.id === shipper)!;
|
|
const view = viewGame(game, shipper);
|
|
for (const [key, count] of Object.entries(payer.forces)) {
|
|
if (count < amount || splitLocation(key).sector === game.storm) continue;
|
|
const forces = {[key]: amount};
|
|
if (!game.homeworlds && payer.faction === 'guild') {
|
|
const action: Action = {type: 'guildShip', forces, territory: 'reserves', allyPayment};
|
|
if (!guildTransportQuote(view, action).unavailableReasons.length) return action;
|
|
} else for (const t of TERRITORIES.filter(t => t.type !== 'stronghold')) {
|
|
for (const sector of t.sectors) {
|
|
const action: Action = {type: 'guildShip', forces, territory: t.id, sector, allyPayment};
|
|
if (!guildTransportQuote(view, action).unavailableReasons.length) return action;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function prepareGuildFixtureTransport(state: Game, shipper: string, amount: number): Game {
|
|
let game = state;
|
|
for (let opportunity = 0; opportunity < 10; opportunity++) {
|
|
while (game.active !== shipper) game = applyAction(game, game.active!, {type: 'endMovement'});
|
|
// Native bidding on a later real turn may spend the earlier preparation
|
|
// budget. Re-establish conserved upfront funds before quoting transport.
|
|
fundGuildFixtureSpice(game, shipper, amount);
|
|
if (transportDeclaration(game, shipper, amount, 0)) return game;
|
|
// The actual storm may cover every initial group. Establish a real source
|
|
// with an ordinary paid shipment, then advance the actual turn and storm.
|
|
const turn = game.turn;
|
|
game = settleGuildBetrayalFixture(applyAction(game, shipper,
|
|
reserveDeclaration(game, shipper, amount)));
|
|
while (game.phase === 5) game = applyAction(game, game.active!, {type: 'endMovement'});
|
|
game = enterGuildFixtureMovement(game, turn + 1);
|
|
clearGuildFixtureHands(game);
|
|
}
|
|
throw new Error('Native turn progression did not produce a legal Guild transport source.');
|
|
}
|
|
|
|
/** Final identities precede actual setup. All source frames below are declared
|
|
* through native actions; no fee, card, force, signed frame or escrow is minted. */
|
|
export function createGuildBetrayalFixture(options: GuildBetrayalFixtureOptions = {}): GuildBetrayalFixture {
|
|
const source = options.source ?? 'reserve';
|
|
const ids = options.seatIds ?? options.initial?.players.map(p => p.id) ?? ['holder', 'guild', 'other', 'donor'];
|
|
const guild = options.initial?.players.find(p => p.faction === 'guild')?.id ?? ids[1];
|
|
const holder = options.initial?.players.find(p => p.id !== guild && (!options.zero || p.faction !== 'fremen'))?.id ??
|
|
options.initial?.players.find(p => p.id !== guild)?.id ?? ids[0];
|
|
const other = options.initial ? options.initial.players.find(p => options.zero ? p.faction === 'fremen' :
|
|
p.id !== guild && p.id !== holder)?.id ?? holder : ids[2];
|
|
assert.ok(holder && guild && other);
|
|
const donor = (options.allyPayment ?? 0) > 0 ? options.initial?.players.find(p =>
|
|
p.id !== holder && p.id !== guild && p.id !== other)?.id ?? ids[3] ?? 'donor' : null;
|
|
const roster: [string, FactionId][] = options.initial ? options.initial.players.map(p => [p.id, p.faction]) :
|
|
[[holder, 'atreides'], [guild, 'guild'], [other, options.zero ? 'fremen' : 'emperor'],
|
|
...(donor ? [[donor, 'harkonnen'] as [string, FactionId]] : [])];
|
|
let game: Game;
|
|
if (options.initial) {
|
|
game = structuredClone(options.initial);
|
|
assert.equal(game.status, 'setup', 'Only the newly admitted setup can be prepared.');
|
|
assert.equal(game.guildBetrayalPreview, true);
|
|
assert.equal(game.guildBetrayal?.sequence, 0);
|
|
assert.equal(game.pendingGuildBetrayal, null);
|
|
if (options.seatIds) assert.deepEqual(game.players.map(p => p.id), options.seatIds);
|
|
if (donor) assert.ok(game.players.some(p => p.id === donor), 'The admitted roster must contain the actual donor.');
|
|
if (options.zero) assert.equal(game.players.find(p => p.id === other)!.faction, 'fremen');
|
|
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
|
if (options.homeworlds !== undefined) assert.equal(!!game.homeworlds, options.homeworlds);
|
|
if (options.occupiedJunction || source === 'homeworld' || source === 'junction')
|
|
assert.ok(game.homeworlds, 'This adapter needs genuinely seeded Homeworlds.');
|
|
for (const p of game.players) viewGame(game, p.id);
|
|
} else {
|
|
game = createGame('GUILDBETRAYALAUDIT', newPlayer(holder, 'Atreides', 'atreides'),
|
|
options.advanced ?? false, []);
|
|
for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, faction, faction));
|
|
if (options.homeworlds || options.occupiedJunction || source === 'homeworld' || source === 'junction')
|
|
game = applyAction(game, holder, {type: 'homeworlds', enabled: true});
|
|
game.nexusCards = {cards: null, phase: null};
|
|
for (const p of game.players) game = applyAction(game, p.id, {type: 'ready'});
|
|
game = initializeGuildBetrayalGameForAudit(game);
|
|
}
|
|
game = enterGuildFixtureMovement(game);
|
|
// Returning dealt physical hands to the same deck removes unrelated optional
|
|
// card responses, without changing setup, physical identities or deck size.
|
|
clearGuildFixtureHands(game);
|
|
|
|
if (options.occupiedJunction) {
|
|
assert.notEqual(source, 'junction', 'Low occupied Junction cannot invent a high-population sponsorship.');
|
|
// Native Guild empties Junction through a real reserve shipment, and the
|
|
// holder physically enters through a real Homeworld shipment. No preplaced
|
|
// invader or forged occupation record is used.
|
|
const guildPlayer = game.players.find(p => p.id === guild)!;
|
|
const giver = game.players.find(p => p.id === other)!;
|
|
const needed = Math.max(0, Math.ceil(guildPlayer.reserves / 2) - guildPlayer.spice);
|
|
assert.ok(giver.spice >= needed);
|
|
giver.spice -= needed;
|
|
guildPlayer.spice += needed;
|
|
const turn = game.turn;
|
|
while (game.phase === 5) {
|
|
const actor = game.active!;
|
|
if (actor === guild) {
|
|
game = settleGuildBetrayalFixture(applyAction(game, actor,
|
|
reserveDeclaration(game, actor, guildPlayer.reserves)));
|
|
} else if (actor === holder) {
|
|
const invader = game.players.find(p => p.id === actor)!;
|
|
const choice = homeworldShipmentChoice(viewGame(game, actor), 'homeworld:guild',
|
|
{[`homeworld:${invader.faction}`]: {normal: 1, elite: 0}});
|
|
assert.ok(choice.action, choice.blocked ?? 'A genuine Guild invasion must be legal.');
|
|
game = settleGuildBetrayalFixture(applyAction(game, actor, choice.action));
|
|
}
|
|
game = applyAction(game, actor, {type: 'endMovement'});
|
|
}
|
|
game = enterGuildFixtureMovement(game, turn + 1);
|
|
clearGuildFixtureHands(game);
|
|
assert.equal(game.players.find(p => p.id === guild)!.reserves, 0);
|
|
assert.equal(game.homeworlds!.custody!.visitors['homeworld:guild'][holder].normal, 1);
|
|
homeworldGameIntegrity(game);
|
|
}
|
|
|
|
const shipper = source === 'guildTransport' ? options.payer === 'other' ? other : guild : options.zero ? other :
|
|
options.payer === 'holder' ? holder : options.payer === 'guild' ? guild : other;
|
|
const amount = options.occupiedJunction ? 3 : 2;
|
|
if (source === 'guildTransport' && shipper !== guild) {
|
|
assert.equal(donor, null, 'A native Guild ally uses the actual Guild grant, not a second alliance.');
|
|
game.players.find(p => p.id === shipper)!.ally = guild;
|
|
game.players.find(p => p.id === guild)!.ally = shipper;
|
|
const cards = game.nexusCards!.cards!;
|
|
for (const id of [shipper, guild]) {
|
|
const face = cards.hands[id];
|
|
if (face !== null) {cards.discard.push(face); cards.hands[id] = null;}
|
|
}
|
|
}
|
|
for (const [id, minimum] of [[shipper, options.zero ? 0 : amount],
|
|
...(donor ? [[donor, options.allyPayment ?? 0]] : [])] as [string, number][])
|
|
fundGuildFixtureSpice(game, id, minimum, donor);
|
|
if (source === 'guildTransport') game = prepareGuildFixtureTransport(game, shipper, amount);
|
|
else while (game.active !== shipper) game = applyAction(game, game.active!, {type: 'endMovement'});
|
|
if (donor) {
|
|
assert.notEqual(shipper, holder, 'An allied own payer cannot hold a Nexus card.');
|
|
// Arrange a conserved reciprocal alliance before creating any source frame.
|
|
// The native pledge action performs the actual donor debit/escrow custody.
|
|
const payer = game.players.find(p => p.id === shipper)!;
|
|
const ally = game.players.find(p => p.id === donor)!;
|
|
payer.ally = donor; ally.ally = shipper;
|
|
const cards = game.nexusCards!.cards!;
|
|
for (const id of [shipper, donor]) {
|
|
const face = cards.hands[id];
|
|
if (face !== null) {cards.discard.push(face); cards.hands[id] = null;}
|
|
}
|
|
game = applyAction(game, donor, {type: 'pledgeAid', amount: options.allyPayment});
|
|
}
|
|
holdGuildFixtureFace(game, holder, options.nexusFace ?? 'guild');
|
|
if (source === 'junction') {
|
|
const option = viewGame(game, guild).junctionTransport!;
|
|
game = applyAction(game, guild, {type: 'offerJunctionTransport', event: option.offerEvent, rate: 'full'});
|
|
}
|
|
const payment = options.allyPayment ?? 0;
|
|
const payer = game.players.find(p => p.id === shipper)!;
|
|
let declaration: Action;
|
|
if (source === 'reserve') declaration = reserveDeclaration(game, shipper, amount, payment);
|
|
else if (source === 'guildTransport') {
|
|
const action = transportDeclaration(game, shipper, amount, payment);
|
|
assert.ok(action, 'The current native Guild quote must authorize the exact source and route.');
|
|
declaration = action;
|
|
} else if (source === 'homeworld') {
|
|
const choice = homeworldShipmentChoice(viewGame(game, shipper),
|
|
shipper === guild ? 'homeworld:atreides' : 'homeworld:guild',
|
|
{[`homeworld:${payer.faction}`]: {normal: amount, elite: 0}}, payment);
|
|
assert.ok(choice.action, choice.blocked ?? 'The current paid Homeworld route must be legal.');
|
|
declaration = choice.action;
|
|
} else {
|
|
const view = viewGame(game, shipper);
|
|
let action: Action | null = null;
|
|
for (const t of TERRITORIES.filter(t => t.type === 'stronghold')) {
|
|
for (const sector of t.sectors) {
|
|
if (sector === game.storm) continue;
|
|
const choice = junctionTransportChoice(view, `${t.id}:${sector}`,
|
|
{[`homeworld:${payer.faction}`]: {normal: amount, elite: 0}}, payment);
|
|
if (choice.action) {action = choice.action; break;}
|
|
}
|
|
if (action) break;
|
|
}
|
|
assert.ok(action, 'A current storm-safe Junction quote must authorize the route.');
|
|
declaration = action;
|
|
}
|
|
const before = structuredClone(game);
|
|
game = applyAction(game, shipper, declaration);
|
|
for (let step = 0; !game.pendingGuildBetrayal && !game.players.find(p => p.id === shipper)!.shipped && step < 20; step++) {
|
|
const decision = game.decision;
|
|
assert.ok(decision && ['guildShipment', 'homeworldShipmentGuild'].includes(decision.kind), 'Native permission precedes payment.');
|
|
game = applyAction(game, decision.player, {type: 'decision', allow: true,
|
|
...(decision.kind === 'homeworldShipmentGuild' ? {event: decision.event} : {})});
|
|
}
|
|
const event = game.pendingGuildBetrayal?.invoice.event ?? '';
|
|
const price = game.pendingGuildBetrayal?.invoice.price ?? 0;
|
|
assert.equal(!!game.pendingGuildBetrayal, !options.zero);
|
|
validateNexusCards(game.nexusCards!.cards!, game.players);
|
|
if (game.homeworlds) homeworldGameIntegrity(game);
|
|
const original = settleGuildBetrayalFixture(game);
|
|
return {game, before, original, declaration, holder, guild, shipper, donor, event, price, source};
|
|
}
|