Files
Dune/game/nexus-guild-betrayal.ts
T

273 lines
14 KiB
TypeScript

import type { FactionId } from './catalog';
import {
nexusCardMode, validateNexusCards, validateNexusPlayers,
type NexusPlayer, type NexusState,
} from './nexus-cards';
export type GuildBetrayalSource = 'reserve' | 'guildTransport' | 'homeworld' | 'junction';
export type GuildBetrayalContext = { turn: number; players: readonly NexusPlayer[] };
export type GuildBetrayalFunding = {
shipper: string;
price: number;
ownSpice: number;
allyPayment: number;
allyEscrow: number;
donor: string | null;
players: readonly NexusPlayer[];
};
/** Produced by the native adapter, never reconstructed from the saved invoice. */
export type GuildBetrayalAuthority = Omit<GuildBetrayalFunding, 'players'> & {
source: GuildBetrayalSource;
sourceSignature: string;
originalReceiver: string | null;
};
export type GuildBetrayalInvoice = GuildBetrayalAuthority & {
event: string;
sequence: number;
turn: number;
phase: 5;
ownPayment: number;
signature: string;
};
export type GuildBetrayalCursor = {
sequence: number;
event: string | null;
turn: number | null;
sourceSignature: string | null;
status: 'pending' | 'completed';
};
export type GuildBetrayalReceipt = {
invoice: GuildBetrayalInvoice;
recipient: string | null;
holderFaction: FactionId | null;
holderUnallied: boolean | null;
card: 'guild' | null;
mode: 'betrayal' | null;
nexusDiscardIndex: number | null;
sourceReceipt: string;
signature: string;
};
export type GuildBetrayalOutcome = Pick<GuildBetrayalReceipt,
'recipient' | 'holderFaction' | 'holderUnallied' | 'nexusDiscardIndex' | 'sourceReceipt'>;
export class GuildBetrayalError extends Error {
constructor(message: string) {
super(message);
this.name = 'GuildBetrayalError';
}
}
function requireBetrayal(condition: unknown, message: string): asserts condition {
if (!condition) throw new GuildBetrayalError(message);
}
const whole = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const positive = (value: unknown): value is number => whole(value) && value > 0;
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].includes(value);
const plain = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value));
const exact = (value: unknown, fields: string): boolean =>
plain(value) && Object.keys(value).sort().join(',') === fields;
const sources: readonly GuildBetrayalSource[] = ['reserve', 'guildTransport', 'homeworld', 'junction'];
function validateContext(context: GuildBetrayalContext): void {
requireBetrayal(plain(context) && positive(context.turn), 'Guild Betrayal needs a valid current turn.');
validateNexusPlayers(context.players);
requireBetrayal(context.players.some(player => player.faction === 'guild'),
'Guild Betrayal needs the native Spacing Guild seated.');
}
/** Public waits depend only on held-card presence, not its hidden face. */
export function guildBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[]): string[] {
validateNexusCards(cards, players);
if (!players.some(player => player.faction === 'guild')) return [];
return players.filter(player => !player.ally && player.faction !== 'guild' &&
cards.hands[player.id] !== null).map(player => player.id);
}
/** A saved faction, mode, or owner object is never its own identity authority. */
export function guildBetrayalEligible(
cards: NexusState, players: readonly NexusPlayer[], holder: string,
): boolean {
validateNexusCards(cards, players);
const seat = players.find(player => player.id === holder);
return !!seat && !seat.ally && seat.faction !== 'guild' && cards.hands[seat.id] === 'guild' &&
nexusCardMode('guild', seat.faction, players.map(player => player.faction)) === 'betrayal';
}
function funding(input: GuildBetrayalFunding, liveAlliance: boolean): void {
requireBetrayal(plain(input) && positive(input.price) && whole(input.ownSpice) &&
whole(input.allyPayment) && input.allyPayment <= input.price && whole(input.allyEscrow) &&
input.allyPayment <= input.allyEscrow && input.ownSpice >= input.price - input.allyPayment,
'The original whole shipment payment must be fully funded before any Guild Betrayal receipt.');
const shipper = input.players.find(player => player.id === input.shipper);
const donor = input.players.find(player => player.id === input.donor);
requireBetrayal(shipper && (input.allyPayment === 0
? input.donor === null
: donor && donor.id !== shipper.id && (!liveAlliance ||
(shipper.ally === donor.id && donor.ally === shipper.id))),
'The original shipment contribution needs its authorized seated escrow donor.');
}
/** Escrow has already left the donor's spice. A refund is never upfront funding. */
export function quoteGuildBetrayalFunding(input: GuildBetrayalFunding): void {
requireBetrayal(plain(input), 'Guild Betrayal needs an original funded invoice.');
validateNexusPlayers(input.players);
funding(input, true);
}
/** Opaque public identifier: no route, source, cost, donor, or card face. */
export function nexusGuildBetrayalEvent(turn: number, sequence: number, shipper: string): string {
requireBetrayal(positive(turn) && positive(sequence) && text(shipper),
'Guild Betrayal needs a positive event sequence and seated shipper identity.');
return JSON.stringify(['nexusGuildBetrayal', turn, sequence, shipper]);
}
/** Consistency signatures bind server facts; they are not cryptographic authentication. */
export function signGuildBetrayalInvoice(invoice: GuildBetrayalInvoice): string {
return JSON.stringify(['guildBetrayalInvoice', invoice.event, invoice.sequence, invoice.turn,
invoice.phase, invoice.shipper, invoice.source, invoice.sourceSignature, invoice.price,
invoice.ownPayment, invoice.allyPayment, invoice.donor, invoice.ownSpice,
invoice.allyEscrow, invoice.originalReceiver]);
}
function validateInvoiceFacts(context: GuildBetrayalContext, invoice: GuildBetrayalInvoice): void {
requireBetrayal(exact(invoice,
'allyEscrow,allyPayment,donor,event,originalReceiver,ownPayment,ownSpice,phase,price,sequence,shipper,signature,source,sourceSignature,turn') &&
positive(invoice.turn) && invoice.turn <= context.turn && invoice.phase === 5 &&
positive(invoice.sequence) && text(invoice.shipper) && sources.includes(invoice.source) &&
text(invoice.sourceSignature) && whole(invoice.ownPayment) &&
invoice.ownPayment === invoice.price - invoice.allyPayment &&
(invoice.originalReceiver === null || context.players.some(player => player.id === invoice.originalReceiver)),
'Guild Betrayal lost its original positive native shipment invoice.');
funding({ ...invoice, players: context.players }, false);
requireBetrayal(invoice.event === nexusGuildBetrayalEvent(invoice.turn, invoice.sequence, invoice.shipper) &&
invoice.signature === signGuildBetrayalInvoice(invoice),
'Guild Betrayal lost its event or original funded source signature.');
}
export function createGuildBetrayalInvoice(
context: GuildBetrayalContext, authority: GuildBetrayalAuthority, sequence: number,
): GuildBetrayalInvoice {
validateContext(context);
quoteGuildBetrayalFunding({ ...authority, players: context.players });
const invoice: GuildBetrayalInvoice = {
shipper: authority.shipper, price: authority.price, ownSpice: authority.ownSpice,
allyPayment: authority.allyPayment, allyEscrow: authority.allyEscrow, donor: authority.donor,
source: authority.source, sourceSignature: authority.sourceSignature,
originalReceiver: authority.originalReceiver,
event: nexusGuildBetrayalEvent(context.turn, sequence, authority.shipper),
sequence, turn: context.turn, phase: 5, ownPayment: authority.price - authority.allyPayment,
signature: '',
};
invoice.signature = signGuildBetrayalInvoice(invoice);
validateInvoiceFacts(context, invoice);
return invoice;
}
/** Re-quote native custody/route/tariff separately before passing authority here. */
export function validateGuildBetrayalInvoice(
context: GuildBetrayalContext, invoice: GuildBetrayalInvoice,
authority: GuildBetrayalAuthority, sequence: number,
): void {
validateContext(context);
validateInvoiceFacts(context, invoice);
quoteGuildBetrayalFunding({ ...authority, players: context.players });
requireBetrayal(invoice.turn === context.turn && invoice.sequence === sequence &&
(['shipper', 'price', 'ownSpice', 'allyPayment', 'allyEscrow', 'donor',
'source', 'sourceSignature', 'originalReceiver'] as const)
.every(key => invoice[key] === authority[key]),
'Guild Betrayal no longer matches the independent native shipment and funding.');
}
/** Runtime emits this only after the original native fee and delivery suffix succeeds. */
export function nexusGuildBetrayalSourceReceipt(invoice: GuildBetrayalInvoice): string {
return JSON.stringify(['guildBetrayalSourceCompleted', invoice.event, invoice.sequence,
invoice.turn, invoice.shipper, invoice.source, invoice.sourceSignature,
invoice.price, invoice.ownPayment, invoice.allyPayment, invoice.donor]);
}
export function signGuildBetrayalReceipt(receipt: GuildBetrayalReceipt): string {
return JSON.stringify(['guildBetrayalReceipt', signGuildBetrayalInvoice(receipt.invoice),
receipt.recipient, receipt.holderFaction, receipt.holderUnallied, receipt.card,
receipt.mode, receipt.nexusDiscardIndex, receipt.sourceReceipt]);
}
function validateReceipt(context: GuildBetrayalContext, receipt: GuildBetrayalReceipt): void {
requireBetrayal(exact(receipt,
'card,holderFaction,holderUnallied,invoice,mode,nexusDiscardIndex,recipient,signature,sourceReceipt'),
'Guild Betrayal has an incomplete or foreign completed receipt.');
validateInvoiceFacts(context, receipt.invoice);
const holder = context.players.find(player => player.id === receipt.recipient);
requireBetrayal(receipt.recipient === null
? receipt.holderFaction === null && receipt.holderUnallied === null && receipt.card === null &&
receipt.mode === null && receipt.nexusDiscardIndex === null
: holder && holder.faction === receipt.holderFaction && holder.faction !== 'guild' &&
receipt.holderUnallied === true && receipt.card === 'guild' && receipt.mode === 'betrayal' &&
whole(receipt.nexusDiscardIndex) &&
nexusCardMode('guild', holder.faction, context.players.map(player => player.faction)) === 'betrayal',
'Guild Betrayal history lost the canonical native Guild or original unallied non-native holder.');
requireBetrayal(receipt.sourceReceipt === nexusGuildBetrayalSourceReceipt(receipt.invoice) &&
receipt.signature === signGuildBetrayalReceipt(receipt),
'Guild Betrayal lost its completed original source receipt.');
}
export function createGuildBetrayalReceipt(
context: GuildBetrayalContext, invoice: GuildBetrayalInvoice, outcome: GuildBetrayalOutcome,
): GuildBetrayalReceipt {
validateContext(context);
validateInvoiceFacts(context, invoice);
requireBetrayal(invoice.turn === context.turn && plain(outcome),
'Only the current original shipment may issue a new completed Guild receipt.');
const holder = context.players.find(player => player.id === outcome.recipient);
requireBetrayal(outcome.recipient === null || (holder && !holder.ally),
'Guild Betrayal can only be used by the actual unallied holder.');
const receipt: GuildBetrayalReceipt = {
invoice: { ...invoice }, recipient: outcome.recipient, holderFaction: outcome.holderFaction,
holderUnallied: outcome.holderUnallied, nexusDiscardIndex: outcome.nexusDiscardIndex,
sourceReceipt: outcome.sourceReceipt,
card: outcome.recipient === null ? null : 'guild', mode: outcome.recipient === null ? null : 'betrayal',
signature: '',
};
receipt.signature = signGuildBetrayalReceipt(receipt);
validateReceipt(context, receipt);
return receipt;
}
/** Intrinsic historic facts, not today's forces/spice/alliances/discard or Nexus custody. */
export function validateGuildBetrayalHistory(
context: GuildBetrayalContext, history: readonly GuildBetrayalReceipt[],
cursor: GuildBetrayalCursor, pendingInvoice: GuildBetrayalInvoice | null,
): void {
validateContext(context);
requireBetrayal(Array.isArray(history) && exact(cursor,
'event,sequence,sourceSignature,status,turn') && whole(cursor.sequence) &&
(cursor.status === 'pending' || cursor.status === 'completed'),
'Guild Betrayal lost its independent pending/completed cursor.');
let previousTurn = 0;
for (let index = 0; index < history.length; index++) {
const receipt = history[index];
validateReceipt(context, receipt);
requireBetrayal(receipt.invoice.sequence === index + 1 && receipt.invoice.turn >= previousTurn,
'Guild Betrayal has a duplicate, stale or out-of-order completed source.');
previousTurn = receipt.invoice.turn;
}
if (cursor.sequence === 0) {
requireBetrayal(cursor.status === 'completed' && cursor.event === null && cursor.turn === null &&
cursor.sourceSignature === null && history.length === 0 && pendingInvoice === null,
'Guild Betrayal has an orphan source at its initial cursor.');
return;
}
requireBetrayal(history.length === cursor.sequence - (cursor.status === 'pending' ? 1 : 0) &&
(cursor.status === 'pending' ? pendingInvoice !== null : pendingInvoice === null),
'Guild Betrayal has an orphan, missing or double-settled original source.');
const invoice = cursor.status === 'pending' ? pendingInvoice! : history[history.length - 1].invoice;
validateInvoiceFacts(context, invoice);
requireBetrayal(invoice.sequence === cursor.sequence && invoice.event === cursor.event &&
invoice.turn === cursor.turn && invoice.sourceSignature === cursor.sourceSignature &&
(cursor.status !== 'pending' || (invoice.turn === context.turn && invoice.turn >= previousTurn)),
'Guild Betrayal cursor no longer identifies its original event, turn or native source.');
}