195 lines
6.0 KiB
TypeScript
195 lines
6.0 KiB
TypeScript
import { TERRITORIES } from './board';
|
|
import type { FactionId } from './catalog';
|
|
import { TERROR_KINDS, type TerrorState } from './moritani-terror';
|
|
|
|
export type NexusMoritaniContext = {
|
|
turn: number;
|
|
players: readonly { id: string; faction: FactionId }[];
|
|
};
|
|
export type NexusMoritaniReceipt = {
|
|
version: 1;
|
|
event: string;
|
|
source: 'mentat';
|
|
phase: 8;
|
|
turn: number;
|
|
owner: string;
|
|
token: string;
|
|
territory: string;
|
|
roster: { id: string; faction: FactionId }[];
|
|
signature: string;
|
|
};
|
|
function requireMoritani(value: unknown, message: string): asserts value {
|
|
if (!value) throw new Error(message);
|
|
}
|
|
function identifier(value: unknown): value is string {
|
|
return (
|
|
typeof value === 'string' &&
|
|
value.trim().length > 0 &&
|
|
!['__proto__', 'constructor', 'prototype'].includes(value)
|
|
);
|
|
}
|
|
function whole(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
}
|
|
function staticTerritory(value: unknown): value is string {
|
|
return typeof value === 'string' && TERRITORIES.some((t) => t.id === value);
|
|
}
|
|
function knownKeys(value: unknown, allowed: string[]): boolean {
|
|
return (
|
|
!!value &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
|
|
Object.keys(value).every((key) => allowed.includes(key))
|
|
);
|
|
}
|
|
function roster(context: NexusMoritaniContext) {
|
|
requireMoritani(
|
|
whole(context.turn) &&
|
|
Array.isArray(context.players) &&
|
|
context.players.length > 0 &&
|
|
context.players.every(
|
|
(p) => p && identifier(p.id) && identifier(p.faction),
|
|
) &&
|
|
new Set(context.players.map((p) => p.id)).size ===
|
|
context.players.length &&
|
|
new Set(context.players.map((p) => p.faction)).size ===
|
|
context.players.length,
|
|
'Moritani Nexus needs its original faction roster and turn.',
|
|
);
|
|
return context.players
|
|
.map(({ id, faction }) => ({ id, faction }))
|
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
}
|
|
function signature(receipt: NexusMoritaniReceipt): string {
|
|
return JSON.stringify([
|
|
receipt.version,
|
|
receipt.event,
|
|
receipt.source,
|
|
receipt.phase,
|
|
receipt.turn,
|
|
receipt.owner,
|
|
receipt.token,
|
|
receipt.territory,
|
|
receipt.roster.map((p) => [p.id, p.faction]),
|
|
]);
|
|
}
|
|
/** Historical evidence does not infer authorization from current token custody.
|
|
* The engine owns live placement eligibility, cancellation and progress binding. */
|
|
export function validateNexusMoritani(
|
|
context: NexusMoritaniContext,
|
|
receipt: NexusMoritaniReceipt,
|
|
): void {
|
|
const seats = roster(context);
|
|
requireMoritani(
|
|
knownKeys(receipt, [
|
|
'version',
|
|
'event',
|
|
'source',
|
|
'phase',
|
|
'turn',
|
|
'owner',
|
|
'token',
|
|
'territory',
|
|
'roster',
|
|
'signature',
|
|
]) &&
|
|
Object.keys(receipt).length === 10 &&
|
|
receipt.version === 1 &&
|
|
receipt.source === 'mentat' &&
|
|
receipt.phase === 8 &&
|
|
whole(receipt.turn) &&
|
|
receipt.turn <= context.turn &&
|
|
identifier(receipt.token) &&
|
|
staticTerritory(receipt.territory) &&
|
|
seats.some((p) => p.id === receipt.owner && p.faction === 'moritani') &&
|
|
receipt.event ===
|
|
JSON.stringify([
|
|
'nexusMoritani',
|
|
receipt.turn,
|
|
receipt.owner,
|
|
receipt.token,
|
|
receipt.territory,
|
|
]) &&
|
|
JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
|
|
receipt.signature === signature(receipt),
|
|
'Moritani Nexus has lost its original placement, timing or owner.',
|
|
);
|
|
}
|
|
export function createNexusMoritani(
|
|
context: NexusMoritaniContext,
|
|
owner: string,
|
|
token: string,
|
|
territory: string,
|
|
): NexusMoritaniReceipt {
|
|
const receipt: NexusMoritaniReceipt = {
|
|
version: 1,
|
|
event: JSON.stringify([
|
|
'nexusMoritani',
|
|
context.turn,
|
|
owner,
|
|
token,
|
|
territory,
|
|
]),
|
|
source: 'mentat',
|
|
phase: 8,
|
|
turn: context.turn,
|
|
owner,
|
|
token,
|
|
territory,
|
|
roster: roster(context),
|
|
signature: '',
|
|
};
|
|
receipt.signature = signature(receipt);
|
|
validateNexusMoritani(context, receipt);
|
|
return receipt;
|
|
}
|
|
|
|
/** One earned Mentat supply placement. Static territory validity is intrinsic;
|
|
* the engine separately proves prior Cunning placements in unusual locations. */
|
|
export function quoteNexusMoritaniPlacement(
|
|
state: TerrorState,
|
|
tokenId: string,
|
|
territory: string,
|
|
turn: number,
|
|
): TerrorState {
|
|
requireMoritani(
|
|
whole(turn) &&
|
|
staticTerritory(territory) &&
|
|
knownKeys(state, ['tokens', 'placementTurn', 'supplyEpoch']) &&
|
|
Array.isArray(state.tokens) &&
|
|
state.tokens.length === TERROR_KINDS.length &&
|
|
(state.placementTurn === undefined ||
|
|
(whole(state.placementTurn) && state.placementTurn < turn)) &&
|
|
(state.supplyEpoch === undefined ||
|
|
(Number.isSafeInteger(state.supplyEpoch) && state.supplyEpoch >= 0)) &&
|
|
new Set(state.tokens.map((t) => t?.id)).size === TERROR_KINDS.length &&
|
|
new Set(state.tokens.map((t) => t?.kind)).size === TERROR_KINDS.length &&
|
|
state.tokens.every(
|
|
(t) =>
|
|
knownKeys(t, ['id', 'kind', 'location', 'status']) &&
|
|
identifier(t.id) &&
|
|
TERROR_KINDS.includes(t.kind) &&
|
|
['available', 'placed', 'removed', 'extortion'].includes(t.status) &&
|
|
(t.status === 'placed'
|
|
? staticTerritory(t.location)
|
|
: t.location === null) &&
|
|
(t.status !== 'extortion' || t.kind === 'extortion'),
|
|
),
|
|
'Moritani Nexus needs six physical Terror tokens, a static Arrakis destination and an unused Mentat placement.',
|
|
);
|
|
requireMoritani(
|
|
state.tokens.some((t) => t.id === tokenId && t.status === 'available'),
|
|
'Moritani Nexus must place a Terror token from available supply; enhanced relocation is unresolved.',
|
|
);
|
|
return {
|
|
...state,
|
|
tokens: state.tokens.map((t) =>
|
|
t.id === tokenId
|
|
? { ...t, status: 'placed', location: territory }
|
|
: { ...t },
|
|
),
|
|
placementTurn: turn,
|
|
};
|
|
}
|