Implement Moritani Nexus placement and preserve later Terror custody

This commit is contained in:
Quintine committed 2026-09-10 12:39:23 +10:00
1 parent 290259bac2
commit ae66668e2e
27 files changed
+2208 -73

No files matched your search

+3
View File
@@ -1,3 +1,4 @@
import { nexusMoritaniBotActions } from './nexus-moritani-options';
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
import { nexusCardBotActions } from './nexus-card-options';
import { nexusTraitorBotActions } from './nexus-traitor-options';
@@ -1724,6 +1725,8 @@ function policyActions(g: GameView): Action[] {
: [];
}
if (d.kind === 'moritaniPlacement') {
const cunning = nexusMoritaniBotActions(g);
if (cunning.length) return cunning;
const tokens = g.moritaniTerror?.tokens ?? [];
const destinations = TERRITORIES.filter(
(t) =>
+143 -14
View File
@@ -4,6 +4,8 @@ import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFace
import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids';
import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors';
import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar';
import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani';
import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location';
import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam';
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
@@ -375,6 +377,7 @@ import {
TERROR_DEFINITIONS,
createTerrorState,
placeTerror,
TERROR_STRONGHOLDS,
projectTerror,
revealTerror,
returnTerror,
@@ -1114,6 +1117,7 @@ export type Game = {
entrySignature?: string;
candidates?: string[];
selectionSignature?: string;
nexusPlacements?: Record<string,string>;
token: string;
entrant: string;
territory: string;
@@ -1150,6 +1154,7 @@ export type Game = {
token: string;
territory: string;
turn: number;
nexusEvent?: string;
} | null;
pendingChoamMove?: MovementOrder | null;
choamBaliset?: { turn: number; player: string; territory: string }[];
@@ -1276,6 +1281,9 @@ export type Game = {
casualties?: {forces: CombatForces; dial: number; support: number; options: Casualties[]; outcome: 'pending' | 'complete'}}[];
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[];
nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[];
nexusMoritaniLocations?: Record<string,{event:string;territory:string}>;
nexusMoritaniLast?: {event:string;stage:'pending'|'complete'|'canceled'};
nexusChoamLast?: {event: string; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'};
nexusTraitorPending?: string | null;
nexusTraitorParent?: { event: string; signature: string } | null;
@@ -3966,12 +3974,7 @@ function treacheryDiscardIntegrity(g: Game) {
owner?.faction === 'moritani' &&
entrant &&
entrant.id !== owner.id &&
TERRITORIES.some(
(t) =>
t.id === entry.territory &&
t.type === 'stronghold' &&
t.sectors.includes(entry.sector),
) &&
terrorEntryLocationAllowed(g,entry) && validLocation(entry.territory,entry.sector) &&
[
'shipment',
'movement',
@@ -9627,6 +9630,14 @@ function openTerrorEntry(
resume,
...(ambassadorEvent ? { ambassadorEvent } : {}),
};
if (!TERROR_STRONGHOLDS.includes(to)) {
const proofs = tokens.map(token => {
const record = g.nexusMoritaniHistory?.find(r => r.stage === 'complete' && r.receipt.token === token.id && r.receipt.territory === to);
requireRule(record && terrorLocationAllowed(g,token.id,to,record.receipt.event), 'The Terror token has lost its original Cunning placement.');
return [token.id,record.receipt.event];
});
g.pendingTerrorEntry.nexusPlacements = Object.fromEntries(proofs);
}
g.pendingTerrorEntry.entrySignature = terrorEntrySignature(g.pendingTerrorEntry);
g.decision = {
kind: 'moritaniTerror',
@@ -9838,6 +9849,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
formTerrorAlliance(g, owner, entrant);
g.moritaniTerror = returnTerror(g.moritaniTerror!, token.id, random);
if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[token.id];
log(
g,
`${owner.name} and ${entrant.name} formed an alliance through Enemy of My Enemy; their former alliances ended and the Terror token returned hidden to supply.`,
@@ -9881,6 +9893,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
const blocked = terrorRevealBlocked(g, entry, token.kind);
requireRule(!blocked, blocked ?? 'This Terror effect is unavailable.');
g.moritaniTerror = revealTerror(g.moritaniTerror!, token.id);
if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[token.id];
log(
g,
`${p.name} revealed ${TERROR_DEFINITIONS[token.kind].name} in ${territory(entry.territory).name}.`,
@@ -12083,6 +12096,7 @@ function marketGholaIntegrity(g: Game) {
nexusAdvisorIntegrity(g);
nexusSardaukarIntegrity(g);
nexusChoamIntegrity(g);
nexusMoritaniIntegrity(g);
traitorDeclarationIntegrity(g);
nexusInspectionIntegrity(g);
homeworldHistoryIntegrity(g);
@@ -14761,6 +14775,7 @@ function finishResponse(g: Game, canceled: boolean) {
placementCancellation?.receipt.kind === 'moritaniPlacement',
'Missing Terror denial receipt.',
);
finishNexusMoritani(g,true);
g.moritaniTerror = placementCancellation.receipt.terror;
log(
g,
@@ -14768,12 +14783,13 @@ function finishResponse(g: Game, canceled: boolean) {
);
} else {
try {
g.moritaniTerror = placeTerror(
g.moritaniTerror,
pending.token,
pending.territory,
g.turn,
);
const placed = pending.nexusEvent
? quoteNexusMoritaniPlacement(g.moritaniTerror,pending.token,pending.territory,g.turn)
: placeTerror(g.moritaniTerror,pending.token,pending.territory,g.turn);
finishNexusMoritani(g,false);
g.moritaniTerror = placed;
if (pending.nexusEvent) (g.nexusMoritaniLocations ??= {})[pending.token] = {event:pending.nexusEvent,territory:pending.territory};
else if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[pending.token];
} catch (error) {
throw new RuleError(
error instanceof Error ? error.message : 'Invalid Terror placement.',
@@ -16115,6 +16131,107 @@ function choamWorthlessBlocked(g: Game, card: string) {
g.choamWorthlessBlocked.cards.includes(card)
);
}
function nexusMoritaniOffer(g: Game, owner: string, decision: Game['decision'] = g.decision) {
const p = g.players.find(p => p.id === owner);
if (!p || p.faction !== 'moritani' || g.nexusCards?.cards?.hands[p.id] !== 'moritani') return null;
const tokens = g.moritaniTerror?.tokens.filter(t => t.status === 'available') ?? [];
let blocked: string | null = null;
if (p.ally) blocked = 'Nexus Cunning requires an unallied Moritani player.';
else if (g.status !== 'playing' || g.phase !== 8 || decision?.kind !== 'moritaniPlacement' || decision.player !== p.id)
blocked = 'Use Cunning when placing a supply token during your Mentat opportunity.';
else if (g.response || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard)
blocked = 'Finish the current interaction before declaring this placement.';
else if (g.moritaniTerror?.placementTurn === g.turn) blocked = 'This turn’s placement opportunity is already used.';
else if (!tokens.length) blocked = 'No supply token is available. Cunning relocation awaits a ruling.';
return {event:JSON.stringify(['nexusMoritani',g.turn,p.id]),blocked,tokens,
destinations:TERRITORIES.map(t => t.id)};
}
function nexusMoritaniIntegrity(g: Game) {
const history = g.nexusMoritaniHistory, pending = g.pendingMoritaniPlacement;
if (history === undefined) {
requireRule(!g.nexusMoritaniLast && !pending?.nexusEvent,
'Moritani Cunning has lost its saved placement history.');
} else {
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
'Moritani Cunning requires its original Nexus module and history.');
const events = new Set<string>();
for (const [index,record] of history.entries()) {
requireRule(record && typeof record === 'object' &&
Object.keys(record).sort().join(',') === 'before,frame,receipt,signature,stage' &&
['pending','complete','canceled'].includes(record.stage) &&
typeof record.before === 'string' && typeof record.frame === 'string' &&
record.signature === nexusMoritaniRecordSignature(record) && !events.has(record.receipt?.event) &&
(record.stage !== 'pending' || index === history.length - 1),
'Moritani Cunning has a changed or duplicated placement record.');
nexusRule(() => validateNexusMoritani(g,record.receipt));
const before = nexusRule(() => JSON.parse(record.before)) as TerrorState;
nexusRule(() => quoteNexusMoritaniPlacement(before,record.receipt.token,record.receipt.territory,record.receipt.turn));
requireRule(record.frame === JSON.stringify({token:record.receipt.token,territory:record.receipt.territory,turn:record.receipt.turn,nexusEvent:record.receipt.event}),
'Moritani Cunning has a changed original placement frame.');
const original = before.tokens.find(t => t.id === record.receipt.token)!;
const current = g.moritaniTerror?.tokens.find(t => t.id === record.receipt.token);
requireRule(!current || current.kind === original.kind, 'Moritani Cunning has changed the original physical token face.');
events.add(record.receipt.event);
}
const last = history.at(-1)!;
requireRule(JSON.stringify(g.nexusMoritaniLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}) &&
(last.stage === 'pending') === !!pending?.nexusEvent,
'Moritani Cunning has lost or reopened its latest placement.');
if (last.stage === 'pending') {
const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
const responses = contexts.flatMap(context => {
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
return [context?.response,karama?.use.kind === 'cancel' ? karama.use.response : null];
}).filter(r => r?.kind === 'moritaniPlacement');
requireRule(pending && pending.nexusEvent === last.receipt.event && pending.token === last.receipt.token &&
pending.territory === last.receipt.territory && pending.turn === last.receipt.turn &&
g.turn === last.receipt.turn && g.phase === 8 && JSON.stringify(pending) === last.frame &&
JSON.stringify(g.moritaniTerror) === last.before && responses.length > 0 &&
responses.every(r => r!.owner === last.receipt.owner) && g.nexusCards.cards.discard.includes('moritani'),
'Moritani Cunning no longer matches its original token, target or response.');
}
}
if (g.nexusMoritaniLocations !== undefined) {
requireRule(g.nexusMoritaniLocations && typeof g.nexusMoritaniLocations === 'object' && !Array.isArray(g.nexusMoritaniLocations),
'Moritani Cunning has an invalid live placement map.');
for (const [id,placement] of Object.entries(g.nexusMoritaniLocations)) {
const token = g.moritaniTerror?.tokens.find(t => t.id === id);
requireRule(placement && Object.keys(placement).sort().join(',') === 'event,territory' && token?.status === 'placed' &&
token.location === placement.territory && terrorLocationAllowed(g,id,placement.territory,placement.event),
'Moritani Cunning has a stale or changed live placement.');
}
}
for (const token of g.moritaniTerror?.tokens ?? []) {
if (token.status === 'placed') requireRule(token.location && terrorLocationAllowed(g,token.id,token.location),
'The Terror token has no valid original placement for this territory.');
}
const continuation = g.pendingTreacheryDiscard?.continuation;
for (const entry of [g.pendingTerrorEntry,continuation?.kind === 'terrorDiscard' ? continuation.entry : null])
if (entry) requireRule(terrorEntryLocationAllowed(g,entry),
'The Terror arrival has lost its original Cunning placement permission.');
}
function recordNexusMoritani(g: Game, owner: string, token: string, to: string) {
const receipt = nexusRule(() => createNexusMoritani(g,owner,token,to));
g.pendingMoritaniPlacement = {token,territory:to,turn:g.turn,nexusEvent:receipt.event};
const record = {receipt,stage:'pending' as const,before:JSON.stringify(g.moritaniTerror),frame:JSON.stringify(g.pendingMoritaniPlacement),signature:''};
record.signature = nexusMoritaniRecordSignature(record);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players));
(g.nexusMoritaniHistory ??= []).push(record);
g.nexusMoritaniLast = {event:receipt.event,stage:record.stage};
}
function finishNexusMoritani(g: Game, canceled: boolean) {
const pending = g.pendingMoritaniPlacement;
if (!pending?.nexusEvent) return;
const record = g.nexusMoritaniHistory?.find(r => r.receipt.event === pending.nexusEvent);
requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(pending) && record.before === JSON.stringify(g.moritaniTerror),
'Moritani Cunning has lost its original placement outcome.');
record.stage = canceled ? 'canceled' : 'complete';
record.signature = nexusMoritaniRecordSignature(record);
g.nexusMoritaniLast = {event:record.receipt.event,stage:record.stage};
}
function nexusChoamSignature(record: NonNullable<Game['nexusChoamHistory']>[number]) {
return JSON.stringify([record.receipt.signature, record.stage, record.frame, record.parent]);
}
@@ -17926,8 +18043,17 @@ function applyActionInner(
} else {
const token = stringField(action.token);
const to = stringField(action.territory);
const nexus = action.nexus !== undefined;
if (nexus) {
const offer = nexusMoritaniOffer(g,p.id,decision);
requireRule(offer && !offer.blocked && offer.event === action.nexus &&
Object.keys(action).every(key => ['type','token','territory','nexus'].includes(key)) &&
offer.tokens.some(t => t.id === token) && offer.destinations.includes(to),
offer?.blocked ?? 'Choose your current Moritani Cunning supply placement.');
}
try {
placeTerror(g.moritaniTerror, token, to, g.turn);
if (nexus) quoteNexusMoritaniPlacement(g.moritaniTerror,token,to,g.turn);
else placeTerror(g.moritaniTerror, token, to, g.turn);
} catch (error) {
throw new RuleError(
error instanceof Error
@@ -17935,7 +18061,9 @@ function applyActionInner(
: 'Invalid Terror placement.',
);
}
g.pendingMoritaniPlacement = { token, territory: to, turn: g.turn };
if (nexus) recordNexusMoritani(g,p.id,token,to);
else g.pendingMoritaniPlacement = { token, territory: to, turn: g.turn };
if (nexus) log(g, `${p.name} played Moritani Nexus Cunning for this hidden Terror placement. Karama may prevent the placement; the Nexus remains spent.`);
g.response = { kind: 'moritaniPlacement', owner: id, passed: [] };
log(g, `${p.name} declared a hidden Terror placement or relocation.`);
}
@@ -20915,6 +21043,7 @@ export function viewGame(state: Game, id: string) {
techTokens: g.techTokens ?? null,
strongholdCards: g.strongholdCards ?? null,
nexusCards: projectedNexusCards(g, id),
nexusMoritani: nexusMoritaniOffer(g,id),
nexusTraitors: projectedNexusTraitors(g, id),
nexusTleilaxu: projectedNexusTleilaxu(g, id),
nexusSuboids: projectedNexusSuboids(g, id),
+3 -2
View File
@@ -1,3 +1,4 @@
import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location';
import type { Game } from './engine';
import { HomeworldCustodyError } from './homeworld-custody';
import { homeworldContext } from './homeworld-game';
@@ -12,7 +13,7 @@ import {
export type GrummanCollectionContext = Pick<
Game,
'advanced' | 'players' | 'homeworlds' | 'moritaniTerror'
>;
> & TerrorLocationContext;
export type GrummanCollectionQuote = {
player: string;
population: number | null;
@@ -76,7 +77,7 @@ function stateFor(
!['available', 'placed', 'removed', 'extortion'].includes(token.status) ||
(token.status === 'placed'
? typeof token.location !== 'string' ||
!TERROR_STRONGHOLDS.includes(token.location)
!terrorLocationAllowed(context,token.id,token.location)
: token.location !== null) ||
(token.status === 'extortion' && token.kind !== 'extortion')
)
+4 -3
View File
@@ -1,3 +1,4 @@
import { terrorEntryLocationAllowed, type TerrorLocationContext } from './terror-location';
import {
validateGuildAmbassadorArrivalContext,
GuildAmbassadorContinuationError,
@@ -8,7 +9,7 @@ import {
validateAmbassadorRelocationContext,
MovementCancellationError,
} from './karama-movement-cancellation';
import { TERROR_KINDS, TERROR_STRONGHOLDS } from './moritani-terror';
import { TERROR_KINDS } from './moritani-terror';
import { validateTerrorEntrySignature } from './terror-entry-receipt';
export class MoritaniAllianceCancellationError extends Error {
@@ -27,7 +28,7 @@ export type MoritaniAllianceCancellationContext = Pick<
| 'moritaniTerror'
| 'pendingAmbassador'
| 'ecazAmbassadors'
>;
> & TerrorLocationContext;
export type MoritaniAllianceCancellationQuote = {
kind: 'moritaniAlliance';
owner: string;
@@ -94,7 +95,7 @@ export function quoteMoritaniAllianceCancellation(
);
requireSource(
typeof entry.territory === 'string' &&
TERROR_STRONGHOLDS.includes(entry.territory) &&
terrorEntryLocationAllowed(g,entry) &&
whole(entry.sector) &&
validLocation(entry.territory, entry.sector) &&
whole(entry.amount) &&
+4 -4
View File
@@ -272,9 +272,9 @@ export type TerrorDefinition = Readonly<{
/** Shared original-language guidance; these paragraphs do not expose token custody. */
export const TERROR_COMMON_GAMEPLAY: readonly string[] = Object.freeze([
'During Mentat Pause, Moritani may place one hidden Terror token from supply or move one placed token. Choose an ordinary stronghold without another Terror token. Storm does not prevent placement; Homeworlds and the Hidden Mobile Stronghold are excluded.',
'A token may be revealed when another faction enters its stronghold by shipment or movement. An ally does not qualify, but Bene Gesserit advisors do. Existing occupation alone does not trigger it. Declining the opportunity keeps the token hidden and unspent.',
'When a stronghold contains several Terror tokens, Moritani chooses one for that entry before deciding whether to reveal it or offer an alliance. The remaining tokens stay hidden and do not activate from that same entry.',
'During Mentat Pause, Moritani may place one hidden Terror token from supply or move one placed token. Choose an ordinary stronghold without another Terror token. Storm does not prevent placement. Moritani Nexus Cunning can expand a supply placement to an ordinary Arrakis territory, including an existing Terror location; unresolved relocation and Homeworld combinations remain unavailable. Homeworlds and the Hidden Mobile Stronghold are excluded.',
'A token may be revealed when another faction enters its territory by shipment or movement. An ally does not qualify, but Bene Gesserit advisors do. Existing occupation alone does not trigger it. Declining the opportunity keeps the token hidden and unspent.',
'When a territory contains several Terror tokens, Moritani chooses one for that entry before deciding whether to reveal it or offer an alliance. The remaining tokens stay hidden and do not activate from that same entry.',
'Before revealing a token, Moritani may instead offer the entrant an alliance, except when the entrant is Ecaz. Acceptance ends both previous alliances and returns the token to supply without revealing it. Refusal requires the token to be revealed.',
'Karama may prevent the placement or relocation opportunity, or prevent Enemy of My Enemy from forming an alliance while leaving revelation available. There is no generic Karama cancellation of a Terror token’s reveal or effect.',
'Terror tokens survive storm and Lasgun/Shield explosions. A Richese No-Field placement can trigger one even with a zero token; revealing a No-Field or replacing forces with Face Dancers does not.',
@@ -382,7 +382,7 @@ export const TERROR_DEFINITIONS: Readonly<
sneakAttack: define(
'sneakAttack',
'Sneak Attack',
'Bring up to five Moritani reserve forces into the stronghold for free.',
'Bring up to five Moritani reserve forces into the territory for free.',
[
'Bring up to five of Moritani’s reserve forces into this territory for free. Respect the storm and occupancy restrictions.',
'This entry is permitted even when Atomics Aftermath is present. The effect grants this reserve entry only; it does not grant another ordinary movement or a general shipment exemption.',
+116
View File
@@ -0,0 +1,116 @@
import type { Action, GameView } from './engine';
import { TERRITORIES, splitLocation } from './board';
import { TERROR_STRONGHOLDS, type TerrorToken } from './moritani-terror';
export function nexusMoritaniCanAct(g: GameView): boolean {
const owner = g.players.find((player) => player.id === g.me);
if (owner?.faction !== 'moritani') return false;
const offer = g.nexusMoritani;
return !!(
g.status === 'playing' &&
g.phase === 8 &&
!owner.ally &&
g.nexusCards?.card === 'moritani' &&
offer &&
!offer.blocked &&
offer.event === JSON.stringify(['nexusMoritani', g.turn, g.me]) &&
g.decision?.kind === 'moritaniPlacement' &&
g.decision.player === g.me &&
!g.response &&
!g.truthtrance &&
!g.phaseOpening &&
!g.automaticContinuationPending &&
!g.nexusCards.waiting.length &&
!g.nexusTraitors?.pending
);
}
/** Native options keep their existing relocation rules; Cunning uses the
* server's available supply and static Arrakis destination projection. */
export function moritaniPlacementChoices(
g: GameView,
useNexus: boolean,
selectedToken = '',
) {
if (g.players.find((player) => player.id === g.me)?.faction !== 'moritani')
return {
tokens: [] as TerrorToken[],
destinations: [] as string[],
token: undefined,
};
const tokens = useNexus
? (g.nexusMoritani?.tokens ?? []).filter(
(token) => token.status === 'available' && token.location === null,
)
: (g.moritaniTerror?.tokens ?? []).filter(
(token): token is TerrorToken =>
'kind' in token &&
(token.status === 'available' || token.status === 'placed'),
);
const token =
tokens.find((candidate) => candidate.id === selectedToken) ?? tokens[0];
const destinations = useNexus
? [...(g.nexusMoritani?.destinations ?? [])]
: TERROR_STRONGHOLDS.filter(
(to) =>
to !== token?.location &&
!g.moritaniTerror?.tokens.some(
(candidate) =>
candidate.status === 'placed' && candidate.location === to,
),
);
return { tokens, token, destinations };
}
export function nexusMoritaniAction(
g: GameView,
event: string,
token: string,
territory: string,
): Action | null {
if (!nexusMoritaniCanAct(g) || event !== g.nexusMoritani!.event) return null;
const choices = moritaniPlacementChoices(g, true, token);
if (
!choices.tokens.some((candidate) => candidate.id === token) ||
!choices.destinations.includes(territory)
)
return null;
return { type: 'decision', token, territory, nexus: event };
}
/** Spend Cunning for a public enemy destination unavailable to ordinary
* placement. Hidden faces are read only from Moritani's authorized supply. */
export function nexusMoritaniBotActions(g: GameView): Action[] {
if (!nexusMoritaniCanAct(g)) return [];
const choices = moritaniPlacementChoices(g, true);
const token =
choices.tokens.find((candidate) =>
['robbery', 'sabotage', 'sneakAttack', 'assassination'].includes(
candidate.kind,
),
) ?? choices.tokens[0];
const owner = g.players.find((player) => player.id === g.me)!;
const destination = choices.destinations.find(
(to) =>
(!TERROR_STRONGHOLDS.includes(to) ||
g.moritaniTerror?.tokens.some(
(candidate) =>
candidate.status === 'placed' && candidate.location === to,
)) &&
TERRITORIES.some((place) => place.id === to) &&
g.players.some(
(player) =>
player.id !== owner.id &&
player.id !== owner.ally &&
Object.entries(player.forces).some(
([key, amount]) =>
amount > 0 && splitLocation(key).territory === to,
),
),
);
const action =
token && destination
? nexusMoritaniAction(g, g.nexusMoritani!.event, token.id, destination)
: null;
return action ? [action] : [];
}
+194
View File
@@ -0,0 +1,194 @@
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,
};
}
+4 -3
View File
@@ -1,3 +1,4 @@
import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location';
import type { Game, ResponseWindow } from './engine';
import { TERRITORIES } from './board';
import {
@@ -39,7 +40,7 @@ export type PlacementCancellationContext = Pick<
| 'dukeVidal'
| 'dukeAcquisitionTurn'
| 'movementRemaining'
>;
> & TerrorLocationContext;
export type PlacementCancellationQuote =
| {
kind: 'ecazPlacement';
@@ -146,12 +147,12 @@ export function quotePlacementCancellation(
) &&
(t.status === 'placed'
? typeof t.location === 'string' &&
TERROR_STRONGHOLDS.includes(t.location)
terrorLocationAllowed(g,t.id,t.location)
: t.location === null) &&
(t.status !== 'extortion' || t.kind === 'extortion'),
) &&
state.tokens.filter((t) => t.id === pending.token).length === 1 &&
TERROR_STRONGHOLDS.includes(pending.territory),
(pending.nexusEvent ? terrorLocationAllowed(g,pending.token,pending.territory,pending.nexusEvent,true) : TERROR_STRONGHOLDS.includes(pending.territory)),
'No current physical Terror placement is awaiting cancellation.',
);
// Grumman may have created legal stacks earlier. Denial validates physical
+8 -6
View File
@@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
category: 'Advanced & expansions',
coverage: 'Partial',
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit, Emperor and five CHOAM Cunning effects are integrated. Other effects and complete module play remain in development.',
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit, Emperor, five CHOAM Cunning effects and Moritani Cunning supply placement are integrated. Other effects and complete module play remain in development.',
steps: [
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
@@ -3366,15 +3366,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups and other Emperor Nexus modes remain unavailable.',
'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.',
'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.',
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules; this does not implement Atomics, Extortion or complete Moritani games.',
'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection with native Karama responses. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects and Moritani supply placement beyond ordinary strongholds. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection with native Karama responses. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts'] },
],
},
...[
+11 -4
View File
@@ -1,5 +1,5 @@
import type { Game } from './engine';
import { validLocation } from './board';
import { TERRITORIES, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
import { TERROR_STRONGHOLDS } from './moritani-terror';
@@ -20,19 +20,21 @@ export function terrorEntrySignature(entry: TerrorEntry): string {
entry.phase,
entry.resume,
entry.ambassadorEvent ?? null,
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
]);
}
export function terrorSelectionSignature(entry: TerrorEntry): string {
return JSON.stringify(['terrorSelection', entry.candidates, entry.token, entry.entrant,
entry.territory, entry.sector, entry.amount, entry.elite, entry.cause,
entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null]);
entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null,
...(entry.nexusPlacements ? [entry.nexusPlacements] : [])]);
}
/** Older saves without a receipt remain explicit legacy state. Never invent
* their original entry count from the destination's present occupants. */
export function validateTerrorEntrySignature(entry: TerrorEntry): void {
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.stage !== 'select') return;
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.stage !== 'select') return;
if (
typeof entry.entrySignature !== 'string' ||
typeof entry.token !== 'string' ||
@@ -48,7 +50,12 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void {
: entry.selectionSignature !== terrorSelectionSignature(entry))) ||
typeof entry.entrant !== 'string' ||
!entry.entrant ||
!TERROR_STRONGHOLDS.includes(entry.territory) ||
(!TERROR_STRONGHOLDS.includes(entry.territory) &&
(!TERRITORIES.some(t => t.id === entry.territory) || !entry.nexusPlacements)) ||
(entry.nexusPlacements !== undefined &&
(!entry.nexusPlacements || typeof entry.nexusPlacements !== 'object' || Array.isArray(entry.nexusPlacements) ||
Object.keys(entry.nexusPlacements).length !== (entry.candidates ?? [entry.token]).length ||
(entry.candidates ?? [entry.token]).some(id => typeof entry.nexusPlacements?.[id] !== 'string' || !entry.nexusPlacements[id]))) ||
!Number.isSafeInteger(entry.sector) ||
!validLocation(entry.territory, entry.sector) ||
!Number.isSafeInteger(entry.amount) ||
+32
View File
@@ -0,0 +1,32 @@
import type { Game } from './engine';
import { TERRITORIES } from './board';
import { TERROR_STRONGHOLDS } from './moritani-terror';
import { validateNexusMoritani } from './nexus-moritani';
export type TerrorLocationContext = Pick<Game,'players'> & Partial<Pick<Game,'turn'|'nexusMoritaniHistory'|'nexusMoritaniLocations'>>;
type Record = NonNullable<Game['nexusMoritaniHistory']>[number];
export function nexusMoritaniRecordSignature(record: Record): string {
return JSON.stringify([record.receipt.signature,record.stage,record.before,record.frame]);
}
/** A historical grant survives card disposal, population changes and revelation.
* It never grants a new placement action or admits Homeworld/HMS destinations. */
export function terrorLocationAllowed(context: TerrorLocationContext, token: string, territory: string, event?: string, pending = false, historical = false): boolean {
if (!event && TERROR_STRONGHOLDS.includes(territory)) return true;
if (!TERRITORIES.some(t => t.id === territory) || !Number.isSafeInteger(context.turn)) return false;
const record = context.nexusMoritaniHistory?.find(candidate =>
candidate.receipt?.token === token && candidate.receipt.territory === territory &&
(!event || candidate.receipt.event === event) && candidate.stage === (pending ? 'pending' : 'complete'));
if (!record || record.signature !== nexusMoritaniRecordSignature(record)) return false;
try { validateNexusMoritani({turn:context.turn!,players:context.players},record.receipt); }
catch { return false; }
if (pending) return record.receipt.turn === context.turn;
return historical || (context.nexusMoritaniLocations?.[token]?.event === record.receipt.event &&
context.nexusMoritaniLocations[token].territory === territory);
}
export function terrorEntryLocationAllowed(context: TerrorLocationContext, entry: NonNullable<Game['pendingTerrorEntry']>): boolean {
if (TERROR_STRONGHOLDS.includes(entry.territory) && !entry.nexusPlacements) return true;
const ids = entry.candidates ?? [entry.token];
return !!entry.nexusPlacements && Object.keys(entry.nexusPlacements).length === ids.length && ids.every(id =>
typeof entry.nexusPlacements?.[id] === 'string' &&
terrorLocationAllowed(context,id,entry.territory,entry.nexusPlacements[id],false,true));
}