Apply ruled Grumman return and Cunning placement

This commit is contained in:
Quintine committed 2026-10-14 08:26:05 +11:00
1 parent 7994026a70
commit 2efa69d10f
24 files changed
+1246 -219

No files matched your search

+126 -38
View File
@@ -1,16 +1,18 @@
import { TERRITORIES } from './board';
import type { FactionId } from './catalog';
import { TERROR_KINDS, type TerrorState } from './moritani-terror';
import {
TERROR_KINDS,
TERROR_STRONGHOLDS,
type TerrorState,
} from './moritani-terror';
export type NexusMoritaniContext = {
turn: number;
players: readonly { id: string; faction: FactionId }[];
};
export type NexusMoritaniReceipt = {
type NexusMoritaniEvidence = {
version: 1;
event: string;
source: 'mentat';
phase: 8;
turn: number;
owner: string;
token: string;
@@ -18,6 +20,16 @@ export type NexusMoritaniReceipt = {
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);
}
@@ -62,7 +74,7 @@ function roster(context: NexusMoritaniContext) {
.sort((a, b) => a.id.localeCompare(b.id));
}
function signature(receipt: NexusMoritaniReceipt): string {
return JSON.stringify([
const evidence = [
receipt.version,
receipt.event,
receipt.source,
@@ -72,7 +84,10 @@ function signature(receipt: NexusMoritaniReceipt): string {
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. */
@@ -93,24 +108,44 @@ export function validateNexusMoritani(
'territory',
'roster',
'signature',
...(receipt?.source === 'grumman'
? ['sourceEvent', 'prerequisite']
: []),
]) &&
Object.keys(receipt).length === 10 &&
Object.keys(receipt).length ===
(receipt.source === 'grumman' ? 12 : 10) &&
receipt.version === 1 &&
receipt.source === 'mentat' &&
receipt.phase === 8 &&
(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([
'nexusMoritani',
receipt.turn,
receipt.owner,
receipt.token,
receipt.territory,
]) &&
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.',
@@ -121,18 +156,30 @@ export function createNexusMoritani(
owner: string,
token: string,
territory: string,
source?: NexusMoritaniGrummanSource,
): NexusMoritaniReceipt {
const receipt: 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([
'nexusMoritani',
context.turn,
owner,
token,
territory,
]),
source: 'mentat',
phase: 8,
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,
@@ -140,27 +187,25 @@ export function createNexusMoritani(
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;
}
/** One earned Mentat supply placement. Static territory validity is intrinsic;
* the engine separately proves prior Cunning placements in unusual locations. */
export function quoteNexusMoritaniPlacement(
function requireSupplyPlacement(
state: TerrorState,
tokenId: string,
territory: string,
turn: number,
): TerrorState {
): void {
requireMoritani(
whole(turn) &&
staticTerritory(territory) &&
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.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 &&
@@ -176,12 +221,18 @@ export function quoteNexusMoritaniPlacement(
: 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.',
'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; enhanced relocation is unresolved.',
'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) =>
@@ -189,6 +240,43 @@ export function quoteNexusMoritaniPlacement(
? { ...t, status: 'placed', location: territory }
: { ...t },
),
placementTurn: turn,
};
}
/** 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);
}