Apply ruled Grumman return and Cunning placement
This commit is contained in:
1 parent
7994026a70
commit
2efa69d10f
24 files changed
+1246
-219
No files matched your search
+126
-38
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user