Files
Dune/game/nexus-fremen-protection.ts

242 lines
9.3 KiB
TypeScript

import { TERRITORIES, splitLocation, validLocation } from './board';
import { SPICE_CARDS } from './cards';
import { presenceAt, type ForcePresence } from './force-presence';
/** Public identity of one accepted worm, captured before its original devouring. */
export type NexusFremenProtectionContext = Readonly<{
event: string;
turn: number;
territory: string;
origin: 'natural' | 'additional' | 'summoned';
pile?: 0 | 1;
index?: number;
parent?: string;
}>;
/** Deliberately excludes card faces, No-Field values and token inventory. */
export type NexusFremenProtectionPlayer = ForcePresence & Readonly<{
id: string;
faction: string;
ally?: string | null;
hasNexus: boolean;
}>;
export type NexusFremenProtectionInput = NexusFremenProtectionContext & Readonly<{
order: readonly string[];
players: readonly NexusFremenProtectionPlayer[];
}>;
export type NexusFremenProtectionFrame = NexusFremenProtectionContext & Readonly<{
stage: 'choose' | 'complete';
order: readonly string[];
passed: readonly string[];
protected: string | null;
}>;
function requireProtection(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const identity = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const object = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
function validateContext(context: NexusFremenProtectionContext): void {
requireProtection(
object(context) && identity(context.event) &&
Number.isSafeInteger(context.turn) && context.turn > 0 &&
TERRITORIES.some((territory) => territory.id === context.territory &&
(territory.type === 'sand' || SPICE_CARDS.some(([id]) => id === territory.id))) &&
['natural', 'additional', 'summoned'].includes(context.origin),
'Invalid original Fremen protection occurrence.',
);
requireProtection(
context.origin === 'summoned'
? identity(context.parent) && context.pile === undefined && context.index === undefined
: context.parent === undefined &&
(context.pile === undefined || context.pile === 0 || context.pile === 1) &&
(context.index === undefined ||
(Number.isSafeInteger(context.index) && context.index >= 0)) &&
(context.origin !== 'natural' ||
(context.pile !== undefined && context.index !== undefined)),
'Invalid original Fremen protection source binding.',
);
}
function validateIds(ids: readonly string[]): void {
requireProtection(Array.isArray(ids), 'Invalid Fremen protection decision order.');
const seen = new Set<string>();
for (const id of ids) {
requireProtection(
identity(id) && !seen.has(id),
'Invalid Fremen protection decision order.',
);
seen.add(id);
}
}
function validateInput(input: NexusFremenProtectionInput): void {
validateContext(input);
validateIds(input.order);
requireProtection(Array.isArray(input.players), 'Invalid public protection seats.');
const ids = new Set<string>();
for (const player of input.players) {
requireProtection(
object(player) && identity(player.id) && !ids.has(player.id) &&
identity(player.faction) && typeof player.hasNexus === 'boolean' &&
object(player.forces),
'Invalid public protection seat.',
);
ids.add(player.id);
for (const [key, count] of Object.entries(player.forces)) {
const at = splitLocation(key);
if (at.territory !== input.territory) continue;
requireProtection(
key === `${at.territory}:${at.sector}` &&
validLocation(at.territory, at.sector) &&
typeof count === 'number' && Number.isSafeInteger(count) && count >= 0,
'Invalid public protection force presence.',
);
}
if (player.noField !== undefined && player.noField !== null) {
requireProtection(object(player.noField), 'Invalid public No-Field presence.');
const deployed = player.noField.deployed;
requireProtection(
deployed === null ||
(object(deployed) && object(deployed.location) &&
identity(deployed.location.territory) &&
typeof deployed.location.sector === 'number' &&
Number.isSafeInteger(deployed.location.sector) &&
(deployed.location.territory !== input.territory ||
validLocation(deployed.location.territory, deployed.location.sector))),
'Invalid public No-Field location.',
);
}
}
requireProtection(
input.order.length === ids.size && input.order.every((id) => ids.has(id)),
'Fremen protection needs the original storm order of every seat.',
);
for (const player of input.players)
requireProtection(
player.ally === undefined || player.ally === null ||
(identity(player.ally) && player.ally !== player.id && ids.has(player.ally)),
'Invalid public protection alliance.',
);
}
function candidates(input: NexusFremenProtectionInput): string[] {
if (input.players.some((player) => player.faction === 'fremen')) return [];
const eligible = new Set<string>();
for (const player of input.players)
if (!player.ally && player.hasNexus && presenceAt(player, input.territory) > 0)
eligible.add(player.id);
return input.order.filter((id) => eligible.has(id));
}
function validateFrame(frame: NexusFremenProtectionFrame): void {
validateContext(frame);
const keys = ['event', 'turn', 'territory', 'origin', 'stage', 'order', 'passed', 'protected'];
if (frame.pile !== undefined) keys.push('pile');
if (frame.index !== undefined) keys.push('index');
if (frame.parent !== undefined) keys.push('parent');
requireProtection(
Object.keys(frame).sort().join(',') === keys.sort().join(',') &&
(frame.stage === 'choose' || frame.stage === 'complete'),
'Invalid saved Fremen protection frame.',
);
validateIds(frame.order);
validateIds(frame.passed);
requireProtection(
frame.order.length > 0 && frame.passed.length <= frame.order.length &&
frame.passed.every((id, index) => id === frame.order[index]),
'Fremen protection acknowledgements must be an original ordered prefix.',
);
requireProtection(
frame.protected === null ||
(identity(frame.protected) && frame.passed.includes(frame.protected)),
'Fremen protection must belong to one acknowledged original owner.',
);
requireProtection(
(frame.stage === 'complete') === (frame.passed.length === frame.order.length),
'Fremen protection must close the entire original queue.',
);
}
/** Public candidates only; a wrong private card face still receives the same queue. */
export function createNexusFremenProtection(
input: NexusFremenProtectionInput,
): NexusFremenProtectionFrame | null {
validateInput(input);
const order = candidates(input);
if (!order.length) return null;
return {
event: input.event,
turn: input.turn,
territory: input.territory,
origin: input.origin,
...(input.pile === undefined ? {} : { pile: input.pile }),
...(input.index === undefined ? {} : { index: input.index }),
...(input.parent === undefined ? {} : { parent: input.parent }),
stage: 'choose',
order,
passed: [],
protected: null,
};
}
export function currentNexusFremenProtectionOwner(
frame: NexusFremenProtectionFrame,
): string | null {
validateFrame(frame);
return frame.stage === 'complete' ? null : frame.order[frame.passed.length];
}
/** The parent must authenticate and spend the actual Fremen card before committing
* this result. `passed` is the acknowledgement prefix, including a Use answer.
* Protection does not skip any remaining acknowledgement or grant worm riding. */
export function answerNexusFremenProtection(
frame: NexusFremenProtectionFrame,
actor: string,
use: boolean,
): NexusFremenProtectionFrame {
validateFrame(frame);
requireProtection(typeof use === 'boolean', 'Choose Use or Pass for Fremen protection.');
requireProtection(
frame.stage === 'choose' && actor === frame.order[frame.passed.length],
'Only the current original owner may answer Fremen protection.',
);
requireProtection(!use || frame.protected === null, 'This worm already has its protected owner.');
const passed = [...frame.passed, actor];
return {
...frame,
passed,
protected: use ? actor : frame.protected,
stage: passed.length === frame.order.length ? 'complete' : 'choose',
};
}
/** Validate against the original public pre-devour snapshot, not live hands after
* expenditure or board presence after devouring. This is not card entitlement,
* an event-history receipt, or authority to retrofit an already-devoured worm. */
export function validateNexusFremenProtection(
input: NexusFremenProtectionInput,
frame: NexusFremenProtectionFrame,
): void {
validateInput(input);
validateFrame(frame);
requireProtection(
frame.event === input.event && frame.turn === input.turn &&
frame.territory === input.territory && frame.origin === input.origin &&
frame.pile === input.pile && frame.index === input.index && frame.parent === input.parent,
'Fremen protection has lost its original occurrence identity.',
);
const order = candidates(input);
requireProtection(
order.length === frame.order.length &&
order.every((id, index) => id === frame.order[index]),
'Fremen protection has lost its original public candidates and storm order.',
);
}