Files
Dune/game/moritani-alliance-cancellation.ts

212 lines
7.3 KiB
TypeScript

import { terrorEntryLocationAllowed, type TerrorLocationContext } from './terror-location';
import {
validateGuildAmbassadorArrivalContext,
GuildAmbassadorContinuationError,
} from './guild-ambassador-continuation';
import type { Game, ResponseWindow } from './engine';
import { validLocation } from './board';
import {
validateAmbassadorRelocationContext,
MovementCancellationError,
} from './karama-movement-cancellation';
import { TERROR_KINDS } from './moritani-terror';
import { validateTerrorEntrySignature } from './terror-entry-receipt';
import { overlapOwnsWormTerror } from './arrival-overlap';
import { validateRevivalTerrorEntry, type RevivalArrivalContext } from './revival-terror-entry';
export class MoritaniAllianceCancellationError extends Error {
constructor(message: string) {
super(message);
this.name = 'MoritaniAllianceCancellationError';
}
}
export type MoritaniAllianceCancellationContext = Pick<
Game,
| 'status'
| 'turn'
| 'phase'
| 'players'
| 'pendingTerrorEntry'
| 'pendingArrivalOverlap'
| 'moritaniTerror'
| 'pendingAmbassador'
| 'ecazAmbassadors'
> & TerrorLocationContext & RevivalArrivalContext;
export type MoritaniAllianceCancellationQuote = {
kind: 'moritaniAlliance';
owner: string;
entry: NonNullable<Game['pendingTerrorEntry']> & {
stage: 'offer';
allianceBlocked: true;
};
decision: {
kind: 'moritaniTerror';
player: string;
entrant: string;
territory: string;
};
};
function requireSource(condition: unknown, message: string): asserts condition {
if (!condition) throw new MoritaniAllianceCancellationError(message);
}
const whole = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) >= 0;
/** Prove only cancellation's return to the existing hidden-token choice.
* Do not replay entry, form an alliance, reveal/return a token, or pre-accept
* its future effect. Board occupation, resources and leader custody can change
* during an interruption without changing the original entry receipt. */
export function quoteMoritaniAllianceCancellation(
g: MoritaniAllianceCancellationContext,
response: ResponseWindow,
): MoritaniAllianceCancellationQuote | null {
if (response.kind !== 'moritaniAlliance') return null;
requireSource(
g.status === 'playing' &&
whole(g.turn) &&
g.turn > 0 &&
Array.isArray(g.players),
'Enemy of My Enemy cancellation needs its current active game.',
);
const owners = g.players.filter((p) => p.id === response.owner);
requireSource(
owners.length === 1 &&
owners[0].faction === 'moritani' &&
g.players.filter((p) => p.faction === 'moritani').length === 1,
'Enemy of My Enemy cancellation needs its seated Moritani owner.',
);
const entry = g.pendingTerrorEntry;
requireSource(
entry &&
entry.stage === 'allianceResponse' &&
entry.turn === g.turn &&
entry.phase === g.phase &&
(entry.allianceBlocked === undefined || entry.allianceBlocked === false),
'This Enemy of My Enemy opportunity is no longer current.',
);
const entrants = g.players.filter((p) => p.id === entry.entrant);
try {
validateTerrorEntrySignature(entry);
if (entry.revivalArrival !== undefined) validateRevivalTerrorEntry(g, entry);
} catch (error) {
throw new MoritaniAllianceCancellationError((error as Error).message);
}
requireSource(
entrants.length === 1 &&
entrants[0].id !== response.owner &&
entrants[0].faction !== 'ecaz',
'The alliance opportunity needs its original opposing entrant.',
);
requireSource(
typeof entry.territory === 'string' &&
terrorEntryLocationAllowed(g,entry) &&
whole(entry.sector) &&
validLocation(entry.territory, entry.sector) &&
whole(entry.amount) &&
whole(entry.elite) &&
entry.elite <= entry.amount,
'The alliance opportunity has an invalid original territory or entry amount.',
);
if (entry.cause === 'ambassador') {
if (g.pendingAmbassador?.effect === 'guild') {
try {
const next = g.pendingAmbassador.shipmentReceipt?.next;
requireSource(
next === 'accompany' || next === 'finish',
'The Guild Ambassador Terror child has an invalid continuation.',
);
const receipt = validateGuildAmbassadorArrivalContext(
g,
entry.ambassadorEvent,
next,
);
const arrival =
next === 'finish' ? receipt.advisorArrival! : receipt.order;
requireSource(
entry.resume === 'ambassador' &&
entry.entrant === arrival.player &&
entry.territory === arrival.territory &&
entry.sector === arrival.sector &&
entry.amount === arrival.amount &&
entry.elite === arrival.elite,
'The alliance opportunity does not match its Guild Ambassador arrival.',
);
} catch (error) {
if (error instanceof GuildAmbassadorContinuationError)
throw new MoritaniAllianceCancellationError(error.message);
throw error;
}
} else {
let order;
try {
order = validateAmbassadorRelocationContext(
g,
entry.ambassadorEvent,
'finish',
);
} catch (error) {
if (error instanceof MovementCancellationError)
throw new MoritaniAllianceCancellationError(error.message);
throw error;
}
requireSource(
entry.resume === 'ambassador' &&
entry.entrant === order.player &&
entry.territory === order.to &&
entry.sector === order.sector &&
entry.amount === order.total &&
entry.elite === order.elite &&
order.origin !== order.to,
'The alliance opportunity does not match its completed Ambassador relocation.',
);
}
} else {
requireSource(
entry.ambassadorEvent === undefined,
'Only an Ambassador entry may carry its event.',
);
requireSource(
entry.revivalArrival !== undefined
? entry.resume === 'none'
: entry.cause === 'wormRide'
? g.phase === 1 &&
(entry.resume === 'wormRide' || overlapOwnsWormTerror(g, entry)) &&
entrants[0].faction === 'fremen'
: g.phase === 5 &&
entry.resume === 'none' &&
['shipment', 'movement', 'guildTransport', 'advisor'].includes(
entry.cause,
) &&
(entry.cause !== 'advisor' ||
entrants[0].faction === 'beneGesserit'),
'The alliance opportunity has an invalid entry continuation.',
);
}
const tokens = g.moritaniTerror?.tokens;
requireSource(
Array.isArray(tokens),
'The alliance opportunity has no Terror token pool.',
);
const matches = tokens.filter((token) => token.id === entry.token);
requireSource(
typeof entry.token === 'string' &&
entry.token.length > 0 &&
matches.length === 1 &&
matches[0].status === 'placed' &&
matches[0].location === entry.territory &&
TERROR_KINDS.includes(matches[0].kind),
'The alliance opportunity no longer has its original hidden Terror token.',
);
return {
kind: 'moritaniAlliance',
owner: response.owner,
entry: { ...entry, stage: 'offer', allianceBlocked: true },
decision: {
kind: 'moritaniTerror',
player: response.owner,
entrant: entry.entrant,
territory: entry.territory,
},
};
}