Files
Dune/game/advisor-matching.ts
Quintine 438a5692a6 Counter incoming advisor matching before normal movement and shipment
Preserve residents and unused actions when one selected packet cannot match. Bind public matching receipts to private declarations and share packet-opportunity denials with legal bot filtering.
2026-10-24 21:50:02 +11:00

160 lines
7.6 KiB
TypeScript

import { gameTerritories, splitLocation, type MobileBoard } from './board';
import type { FactionId } from './catalog';
import type { AdvisorStance } from './advisor-automatic-flip';
/** One incoming selected group changes to the resident BG group's stance. */
export type AdvisorMatchingSource = {
event: string;
kind: 'movement' | 'shipment';
owner: string;
turn: number;
move: number;
origin: string;
destination: string;
sector: number;
advisors: boolean;
group: Record<string, number>;
};
export type AdvisorMatchingIntent = Omit<AdvisorMatchingSource, 'event' | 'turn'>;
export type AdvisorMatchingQuote = Omit<AdvisorMatchingSource, 'event'>;
export type AdvisorMatchingPlayer = {
id: string;
faction: FactionId;
forces: Readonly<Record<string, number>>;
advisors?: Readonly<Record<string, AdvisorStance>>;
moved?: number;
reserves?: number;
};
export type AdvisorMatchingContext = MobileBoard & {
advanced: boolean;
status: string;
phase: number;
turn: number;
active: string | null;
players: readonly AdvisorMatchingPlayer[];
advisorMatchingDenied?: readonly string[];
};
export class AdvisorMatchingError extends Error {
constructor(message: string) {
super(message);
this.name = 'AdvisorMatchingError';
}
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const nonnegative = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const positive = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) > 0;
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireMatching(value: unknown, message: string): asserts value {
if (!value) throw new AdvisorMatchingError(message);
}
/** Destination-sector retries and fresh response UUIDs do not change the packet.
* A new turn, native opportunity, selected group or target stance has its own key. */
export function advisorMatchingKey(source: AdvisorMatchingSource | AdvisorMatchingQuote): string {
const group = Object.entries(source.group).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
return JSON.stringify([
'advisorMatching', source.turn, source.owner, source.kind, source.move,
source.origin, source.destination, source.advisors, group,
]);
}
function residentForces(player: AdvisorMatchingPlayer, destination: string) {
return Object.entries(player.forces).some(([key, count]) =>
splitLocation(key).territory === destination && count > 0);
}
function selectedGroup(
player: AdvisorMatchingPlayer,
intent: AdvisorMatchingIntent,
originSectors: readonly number[],
): Record<string, number> {
requireMatching(record(intent.group) && Object.keys(intent.group).length > 0,
'Select one nonempty physical group for advisor matching.');
const group: Record<string, number> = {};
let total = 0;
for (const key of Object.keys(intent.group)) {
if (intent.kind === 'shipment') {
requireMatching(key === 'reserves', 'Normal BG shipment matching uses only the selected reserve forces.');
} else {
const point = splitLocation(key);
requireMatching(key === `${point.territory}:${point.sector}` && point.territory === intent.origin &&
originSectors.includes(point.sector),
'Select physical movement counters from the original board territory.');
}
const count = intent.group[key];
const held = intent.kind === 'shipment' ? player.reserves : player.forces[key];
requireMatching(positive(count) && nonnegative(held) && held >= count,
'The selected incoming physical group is no longer available.');
total += count;
requireMatching(positive(total), 'The selected incoming group exceeds the supported physical count.');
group[key] = count;
}
return group;
}
/** Matching is an incoming-group counter only. The caller keeps normal route,
* arrival permissions, funding and the uncommitted original action authoritative. */
export function quoteAdvisorMatching(
ctx: AdvisorMatchingContext,
intent: AdvisorMatchingIntent,
): AdvisorMatchingQuote | null {
if (ctx.advanced !== true || ctx.status !== 'playing' || ctx.phase !== 5 || !positive(ctx.turn)) return null;
requireMatching(record(intent) && identifier(intent.owner), 'Choose the current advisor-matching owner.');
const owners = ctx.players.filter((player) => player.id === intent.owner);
const owner = owners[0];
if (owners.length !== 1 || owner.faction !== 'beneGesserit' || ctx.active !== owner.id) return null;
requireMatching((intent.kind === 'movement' || intent.kind === 'shipment') && nonnegative(intent.move) &&
identifier(intent.origin) && identifier(intent.destination) && nonnegative(intent.sector) &&
typeof intent.advisors === 'boolean',
'Choose a valid native movement or reserve-shipment matching declaration.');
const places = gameTerritories(ctx);
const destination = places.find((place) => place.id === intent.destination && place.sectors.includes(intent.sector));
const origin = places.find((place) => place.id === intent.origin);
requireMatching(destination && (intent.kind === 'shipment' ? intent.origin === 'reserves' : origin),
'Advisor matching needs its current board destination and original physical source.');
if (!residentForces(owner, intent.destination)) return null;
const targetAdvisors = !!owner.advisors?.[intent.destination];
requireMatching(intent.advisors === targetAdvisors,
'The resident Bene Gesserit group no longer has the declared target stance.');
// Selected ordinary reserve forces provisionally arrive with the normal fighter default.
const incomingAdvisors = intent.kind === 'shipment' ? false : !!owner.advisors?.[intent.origin];
if (incomingAdvisors === targetAdvisors) return null;
requireMatching(intent.kind === 'shipment' ? intent.move === 0 :
nonnegative(owner.moved) && intent.move === owner.moved,
'Advisor matching lost its original owned movement or shipment opportunity.');
const group = selectedGroup(owner, intent, origin?.sectors ?? []);
const quote: AdvisorMatchingQuote = {
kind: intent.kind, owner: owner.id, turn: ctx.turn, move: intent.move,
origin: intent.origin, destination: intent.destination, sector: intent.sector,
advisors: targetAdvisors, group,
};
requireMatching(ctx.advisorMatchingDenied === undefined ||
(Array.isArray(ctx.advisorMatchingDenied) && ctx.advisorMatchingDenied.every((key) => typeof key === 'string')),
'The advisor-matching prevention history is invalid.');
requireMatching(!ctx.advisorMatchingDenied?.includes(advisorMatchingKey(quote)),
'Karama already prevented this selected group from matching in that territory during the current owned opportunity.');
return quote;
}
/** Validate before either response outcome; this never commits or refunds the action. */
export function validateAdvisorMatching(ctx: AdvisorMatchingContext, source: AdvisorMatchingSource): void {
requireMatching(record(source) &&
Object.keys(source).sort().join(',') === 'advisors,destination,event,group,kind,move,origin,owner,sector,turn' &&
identifier(source.event) && positive(source.turn) && source.turn === ctx.turn,
'Advisor matching lost its original current-turn public source.');
const quote = quoteAdvisorMatching(ctx, {
kind: source.kind, owner: source.owner, move: source.move, origin: source.origin,
destination: source.destination, sector: source.sector, advisors: source.advisors, group: source.group,
});
requireMatching(quote, 'There is no current incoming BG stance change for this matching source.');
}