Files
Dune/game/nexus-advisors.ts

308 lines
9.6 KiB
TypeScript

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<string, unknown> {
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<NexusAdvisorReceipt, 'signature'>): 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.',
);
}
}