Files
Dune/game/nexus-moritani.ts
T

283 lines
8.9 KiB
TypeScript

import { TERRITORIES } from './board';
import type { FactionId } from './catalog';
import {
TERROR_KINDS,
TERROR_STRONGHOLDS,
type TerrorState,
} from './moritani-terror';
export type NexusMoritaniContext = {
turn: number;
players: readonly { id: string; faction: FactionId }[];
};
type NexusMoritaniEvidence = {
version: 1;
event: string;
turn: number;
owner: string;
token: string;
territory: string;
roster: { id: string; faction: FactionId }[];
signature: string;
};
export type NexusMoritaniGrummanSource = {
source: 'grumman';
sourceEvent: string;
prerequisite: string;
};
export type NexusMoritaniReceipt = NexusMoritaniEvidence &
(
| { source: 'mentat'; phase: 8 }
| (NexusMoritaniGrummanSource & { phase: 7 })
);
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 {
const evidence = [
receipt.version,
receipt.event,
receipt.source,
receipt.phase,
receipt.turn,
receipt.owner,
receipt.token,
receipt.territory,
receipt.roster.map((p) => [p.id, p.faction]),
];
if (receipt.source === 'grumman')
evidence.push(receipt.sourceEvent, receipt.prerequisite);
return JSON.stringify(evidence);
}
/** 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',
...(receipt?.source === 'grumman'
? ['sourceEvent', 'prerequisite']
: []),
]) &&
Object.keys(receipt).length ===
(receipt.source === 'grumman' ? 12 : 10) &&
receipt.version === 1 &&
(receipt.source === 'mentat'
? receipt.phase === 8
: receipt.source === 'grumman' &&
receipt.phase === 7 &&
identifier(receipt.sourceEvent) &&
TERROR_STRONGHOLDS.includes(receipt.prerequisite)) &&
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(
receipt.source === 'grumman'
? [
'nexusMoritaniGrumman',
receipt.turn,
receipt.owner,
receipt.token,
receipt.territory,
receipt.sourceEvent,
receipt.prerequisite,
]
: [
'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,
source?: NexusMoritaniGrummanSource,
): NexusMoritaniReceipt {
requireMoritani(
source === undefined ||
(knownKeys(source, ['source', 'sourceEvent', 'prerequisite']) &&
Object.keys(source).length === 3 &&
source.source === 'grumman'),
'Moritani Nexus needs its original Grumman collection source.',
);
const evidence: NexusMoritaniEvidence = {
version: 1,
event: JSON.stringify(
source
? [
'nexusMoritaniGrumman',
context.turn,
owner,
token,
territory,
source.sourceEvent,
source.prerequisite,
]
: ['nexusMoritani', context.turn, owner, token, territory],
),
turn: context.turn,
owner,
token,
territory,
roster: roster(context),
signature: '',
};
const receipt: NexusMoritaniReceipt = source
? { ...evidence, ...source, phase: 7 }
: { ...evidence, source: 'mentat', phase: 8 };
receipt.signature = signature(receipt);
validateNexusMoritani(context, receipt);
return receipt;
}
function requireSupplyPlacement(
state: TerrorState,
tokenId: string,
territory: string,
): void {
requireMoritani(
staticTerritory(territory) &&
knownKeys(state, ['tokens', 'placementTurn', 'supplyEpoch']) &&
Array.isArray(state.tokens) &&
state.tokens.length === TERROR_KINDS.length &&
(state.placementTurn === undefined || whole(state.placementTurn)) &&
(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 and a static Arrakis destination; Hidden Mobile Stronghold and Homeworld placement are excluded.',
);
requireMoritani(
state.tokens.some((t) => t.id === tokenId && t.status === 'available'),
'Moritani Nexus must place a Terror token from available supply; Cunning cannot enhance relocation.',
);
}
function placeSupplyToken(
state: TerrorState,
tokenId: string,
territory: string,
): TerrorState {
return {
...state,
tokens: state.tokens.map((t) =>
t.id === tokenId
? { ...t, status: 'placed', location: territory }
: { ...t },
),
};
}
/** 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 {
requireSupplyPlacement(state, tokenId, territory);
requireMoritani(
whole(turn) &&
(state.placementTurn === undefined || state.placementTurn < turn),
'Moritani Nexus needs an unused Mentat placement for this turn.',
);
return { ...placeSupplyToken(state, tokenId, territory), placementTurn: turn };
}
/** Broaden an already-available native Grumman addition, not its prerequisite.
* The engine owns population, Collection timing and source-frame authorization.
* This separate opportunity never consumes or inspects Mentat timing. */
export function quoteNexusMoritaniGrummanPlacement(
state: TerrorState,
tokenId: string,
territory: string,
prerequisite: string,
): TerrorState {
requireSupplyPlacement(state, tokenId, territory);
requireMoritani(
TERROR_STRONGHOLDS.includes(prerequisite) &&
state.tokens.some(
(token) =>
token.status === 'placed' && token.location === prerequisite,
),
'Moritani Nexus needs an ordinary Grumman addition at a stronghold already containing Terror.',
);
return placeSupplyToken(state, tokenId, territory);
}