283 lines
8.9 KiB
TypeScript
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);
|
|
}
|