import { gameTerritories, location, splitLocation, territory, validLocation, } from './board'; import { settledBoard, type BoardContext, type BoardSeat, } from './board-resolution-quote'; import { presenceAt } from './force-presence'; import { isBoardNoFieldLocation } from './richese-no-field'; import { territoryEntryBlock } from './occupancy'; import { ecazOccupancyIdentity } from './ecaz-occupy'; export type NexusAdvisorContext = BoardContext & { turn: number }; export type NexusAdvisorSelection = { territory: string; count: number; locations: { location: string; count: number }[]; noField: string | null; stance: { lockedTurn?: number }; }; export type NexusAdvisorReceipt = { version: 1; event: string; owner: string; turn: number; selections: NexusAdvisorSelection[]; signature: string; }; function requireAdvisors( condition: unknown, message: string, ): asserts condition { if (!condition) throw new Error(message); } function record(value: unknown): value is Record { return ( !!value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value)) ); } function exact(value: unknown, keys: string[]): boolean { return ( record(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',') ); } const whole = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0; const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; function validStance( stance: unknown, turn: number, ): stance is { lockedTurn?: number } { return ( record(stance) && Object.keys(stance).every((key) => key === 'lockedTurn') && (stance.lockedTurn === undefined || (whole(stance.lockedTurn) && stance.lockedTurn <= turn)) ); } function board(context: NexusAdvisorContext, owner: string) { requireAdvisors( whole(context.turn) && context.turn > 0 && typeof context.advanced === 'boolean' && Array.isArray(context.players), 'Nexus advisors need a valid public board and turn.', ); ecazOccupancyIdentity(context.players, owner, { kind: 'territory', id: 'polar_sink', }); const player = context.players.find((p) => p.id === owner)!; requireAdvisors( player.faction === 'beneGesserit', 'Only native Bene Gesserit may convert Nexus advisors.', ); const sites = gameTerritories(context); for (const p of context.players) if (p.advisors !== undefined) { requireAdvisors( record(p.advisors), 'Advisor stances need a valid territory map.', ); for (const [id, stance] of Object.entries(p.advisors)) { // Homeworld invasions are outside the advisor territory subsystem. if (id.startsWith('homeworld:')) continue; requireAdvisors( sites.some((site) => site.id === id) && validStance(stance, context.turn), 'Invalid original advisor territory stance.', ); } } const settled = settledBoard(context); return { player, sites, settled }; } function selection(player: BoardSeat, to: string): NexusAdvisorSelection { const locations = Object.entries(player.forces) .filter(([key, count]) => count > 0 && splitLocation(key).territory === to) .sort(([one], [two]) => one.localeCompare(two)) .map(([key, count]) => ({ location: key, count })); const marker = player.noField?.deployed?.location; const noField = marker && isBoardNoFieldLocation(marker) && marker.territory === to ? location(marker.territory, marker.sector) : null; const count = locations.reduce((sum, group) => sum + group.count, 0) + (noField ? 1 : 0); return { territory: to, count, locations, noField, stance: player.advisors![to].lockedTurn === undefined ? {} : { lockedTurn: player.advisors![to].lockedTurn }, }; } export function quoteNexusAdvisors( context: NexusAdvisorContext, owner: string, ): { territories: { territory: string; count: number; blocked: string | null }[]; } { const { player, sites, settled } = board(context, owner); if (!context.advanced) return { territories: [] }; const territories = sites .filter( (site) => player.advisors?.[site.id] && presenceAt(player, site.id) > 0 && !settled.released.some( (release) => release.player === owner && release.territory === site.id, ), ) .map((site) => ({ territory: site.id, count: presenceAt(player, site.id), blocked: territoryEntryBlock(settled.players, owner, site.id), })); return { territories }; } function signature(receipt: Omit): string { return JSON.stringify([ 'nexusAdvisors', receipt.version, receipt.event, receipt.owner, receipt.turn, receipt.selections.map((s) => [ s.territory, s.count, s.locations.map((group) => [group.location, group.count]), s.noField, Object.hasOwn(s.stance, 'lockedTurn') ? [s.stance.lockedTurn] : null, ]), ]); } function validateReceipt(receipt: NexusAdvisorReceipt): void { requireAdvisors( exact(receipt, [ 'version', 'event', 'owner', 'turn', 'selections', 'signature', ]) && receipt.version === 1 && text(receipt.event) && text(receipt.owner) && whole(receipt.turn) && receipt.turn > 0 && Array.isArray(receipt.selections) && receipt.selections.length > 0 && new Set(receipt.selections.map((s) => s.territory)).size === receipt.selections.length, 'Invalid saved Nexus advisor selection.', ); for (const chosen of receipt.selections) { requireAdvisors( exact(chosen, ['territory', 'count', 'locations', 'noField', 'stance']) && text(chosen.territory) && validLocation( chosen.territory, territory(chosen.territory).sectors[0], ) && whole(chosen.count) && chosen.count > 0 && Array.isArray(chosen.locations) && validStance(chosen.stance, receipt.turn) && (!Object.hasOwn(chosen.stance, 'lockedTurn') || chosen.stance.lockedTurn !== undefined), 'Invalid original Nexus advisor group.', ); const validKey = (key: unknown) => { if (!text(key)) return false; const loc = splitLocation(key); return ( loc.territory === chosen.territory && key === location(loc.territory, loc.sector) && validLocation(loc.territory, loc.sector) ); }; requireAdvisors( chosen.locations.every( (group) => exact(group, ['location', 'count']) && validKey(group.location) && whole(group.count) && group.count > 0, ) && new Set(chosen.locations.map((group) => group.location)).size === chosen.locations.length && (chosen.noField === null || validKey(chosen.noField)) && chosen.count === chosen.locations.reduce((sum, group) => sum + group.count, 0) + (chosen.noField ? 1 : 0), 'The Nexus advisor receipt needs exact original public force counts.', ); } requireAdvisors( receipt.signature === signature(receipt), 'The Nexus advisor selection has lost its original stance or force counts.', ); } export function createNexusAdvisors( context: NexusAdvisorContext, owner: string, event: string, territories: string[], ): NexusAdvisorReceipt { const quote = quoteNexusAdvisors(context, owner); requireAdvisors( text(event) && Array.isArray(territories) && territories.length > 0 && territories.every(text) && new Set(territories).size === territories.length, 'Choose a nonempty distinct set of advisor territories.', ); for (const to of territories) { const offer = quote.territories.find((offer) => offer.territory === to); requireAdvisors( offer && !offer.blocked, offer?.blocked ?? 'Choose an original advisor territory.', ); } const player = context.players.find((p) => p.id === owner)!; const receipt: NexusAdvisorReceipt = { version: 1, event, owner, turn: context.turn, selections: territories.map((to) => selection(player, to)), signature: '', }; receipt.signature = signature(receipt); validateNexusAdvisors(context, receipt, true); return receipt; } export function validateNexusAdvisors( context: NexusAdvisorContext, receipt: NexusAdvisorReceipt, pending: boolean, ): void { validateReceipt(receipt); requireAdvisors( typeof pending === 'boolean' && whole(context.turn) && context.turn >= receipt.turn && context.players.some( (p) => p.id === receipt.owner && p.faction === 'beneGesserit', ), 'The Nexus advisor receipt needs its original owner and turn.', ); if (!pending) return; requireAdvisors( context.turn === receipt.turn, 'Complete the Nexus advisor conversion in its original turn.', ); const quote = quoteNexusAdvisors(context, receipt.owner), player = context.players.find((p) => p.id === receipt.owner)!; for (const chosen of receipt.selections) { const current = quote.territories.find( (offer) => offer.territory === chosen.territory, ); requireAdvisors( current && !current.blocked, current?.blocked ?? 'The original advisor group is no longer available.', ); const actual = selection(player, chosen.territory); requireAdvisors( JSON.stringify(actual) === JSON.stringify(chosen), 'The pending Nexus conversion must retain its exact original forces and stance.', ); } }