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

+88 -21
View File
@@ -64,7 +64,7 @@ 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 { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, quoteNexusMoritaniGrummanPlacement, type NexusMoritaniReceipt } from './nexus-moritani';
import { moritaniBetrayalOffer } from './nexus-moritani-betrayal';
import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal';
import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal, type StoredFremenNexusBetrayal } from './nexus-fremen-betrayal';
@@ -19383,30 +19383,78 @@ function resumeGrummanCollection(g: Game) {
frame.signature = grummanCollectionSignature(frame);
g.decision = { kind: 'grummanCollection', player: frame.player, event: frame.event };
}
function grummanCunningGrant(g: Game, frame: GrummanCollection,
quote: ReturnType<typeof quoteGrummanCollection>) {
const owner = getPlayer(g, frame.player);
if (g.nexusCards?.cards?.hands[owner.id] !== 'moritani') return null;
return { event: JSON.stringify(['nexusMoritaniGrumman', g.turn, owner.id, frame.event]),
blocked: owner.ally ? 'Nexus Cunning requires an unallied Moritani player.' : quote.blocked,
prerequisite: quote.destinations[0] ?? null };
}
function decideGrummanCollection(g: Game, p: Player, action: Action) {
const frame = g.grummanCollection;
requireRule(frame?.stage === 'choice' && frame.player === p.id && frame.event === action.event,
'Choose the current Grumman Collection opportunity.');
if (action.decline === true) {
requireRule(action.token === undefined && action.territory === undefined && action.mode === undefined,
'Declining Grumman does not move a token or collect spice.');
requireRule(action.token === undefined && action.territory === undefined &&
action.mode === undefined && action.nexus === undefined,
'Declining Grumman does not move a token, spend Cunning or collect spice.');
frame.outcome = 'decline';
log(g, `${p.name} declined Grumman’s optional Terror change; no bank spice was collected.`);
} else {
requireRule(action.mode === 'add' || action.mode === 'remove', 'Choose a Grumman token operation.');
requireRule(typeof action.token === 'string', 'Choose a physical Terror token.');
const destination = typeof action.territory === 'string' ? action.territory : '';
requireRule(action.mode !== 'add' || !!destination, 'Choose a stronghold for the added token.');
const intent = action.mode === 'remove'
? { mode: 'remove' as const, token: action.token }
: { mode: 'add' as const, token: action.token, destination };
const quote = homeworldRule(() => quoteGrummanCollectionAction(g, p.id, intent));
requireRule(Number.isSafeInteger(p.spice + quote.amount), 'Grumman income would overflow the spice balance.');
g.moritaniTerror = quote.state;
p.spice += quote.amount;
frame.outcome = 'add'; frame.token = action.token; frame.territory = destination;
log(g, `${p.name} added one hidden Terror token to ${territory(frame.territory).name}, which already held Terror, and received 4 spice from the bank. The separate Mentat placement remains available according to its own usage.`,
{ faction: p.faction, name: 'Grumman Collection' });
requireRule(Number.isSafeInteger(p.spice + 4), 'Grumman income would overflow the spice balance.');
if (action.mode === 'add' && action.nexus !== undefined) {
const ordinary = homeworldRule(() => quoteGrummanCollection(g, p.id));
const grant = grummanCunningGrant(g, frame, ordinary);
requireRule(grant && !grant.blocked && action.nexus === grant.event && grant.prerequisite,
grant?.blocked ?? 'Choose the current Cunning modifier for an already eligible Grumman addition.');
requireRule(typeof action.territory === 'string', 'Choose a permitted Arrakis destination.');
const placed = nexusRule(() => quoteNexusMoritaniGrummanPlacement(g.moritaniTerror!,
action.token as string, action.territory as string, grant.prerequisite!));
const receipt = nexusRule(() => createNexusMoritani(g, p.id, action.token as string,
action.territory as string, { source: 'grumman', sourceEvent: frame.event,
prerequisite: grant.prerequisite! }));
const record = { receipt, stage: 'complete' as const, before: JSON.stringify(g.moritaniTerror),
frame: JSON.stringify({ token: receipt.token, territory: receipt.territory, turn: receipt.turn,
nexusEvent: receipt.event, sourceEvent: frame.event, prerequisite: grant.prerequisite }),
signature: '' };
record.signature = nexusMoritaniRecordSignature(record);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
(g.nexusMoritaniHistory ??= []).push(record);
g.nexusMoritaniLast = { event: receipt.event, stage: record.stage };
g.moritaniTerror = placed;
(g.nexusMoritaniLocations ??= {})[receipt.token] = { event: receipt.event, territory: receipt.territory };
frame.outcome = 'add'; frame.token = receipt.token; frame.territory = receipt.territory;
frame.nexusEvent = receipt.event;
p.spice += 4;
log(g, `${p.name} used Moritani Nexus Cunning to add one hidden Terror token to ${territory(receipt.territory).name} from an eligible Grumman addition and received 4 spice from the bank. The separate Mentat placement is unchanged.`,
{ faction: p.faction, name: 'Grumman Collection' });
} else {
requireRule(action.nexus === undefined, 'Returning a Terror token does not use Cunning.');
requireRule(action.mode !== 'remove' || action.territory === undefined,
'Returning a Terror token has no destination selection.');
const destination = typeof action.territory === 'string' ? action.territory : '';
requireRule(action.mode !== 'add' || !!destination, 'Choose a stronghold for the added token.');
const original = g.moritaniTerror!.tokens.find(token => token.id === action.token);
const intent = action.mode === 'remove'
? { mode: 'remove' as const, token: action.token }
: { mode: 'add' as const, token: action.token, destination };
const quote = homeworldRule(() => quoteGrummanCollectionAction(g, p.id, intent, random));
g.moritaniTerror = quote.state;
p.spice += quote.amount;
frame.outcome = action.mode; frame.token = action.token;
frame.territory = action.mode === 'remove' ? original!.location! : destination;
if (action.mode === 'remove') {
if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[action.token];
log(g, `${p.name} returned one unrevealed Terror token from ${territory(frame.territory).name} to hidden supply and received 4 spice from the bank. The separate Mentat placement is unchanged.`,
{ faction: p.faction, name: 'Grumman Collection' });
} else {
log(g, `${p.name} added one hidden Terror token to ${territory(frame.territory).name}, which already held Terror, and received 4 spice from the bank. The separate Mentat placement remains available according to its own usage.`,
{ faction: p.faction, name: 'Grumman Collection' });
}
}
}
frame.stage = 'complete';
frame.signature = grummanCollectionSignature(frame);
@@ -19415,9 +19463,14 @@ function projectedGrummanCollection(g: Game, player: string) {
const frame = g.grummanCollection;
if (frame?.stage !== 'choice') return null;
const quote = homeworldRule(() => quoteGrummanCollection(g, frame.player));
const own = player === frame.player;
const cunning = own ? grummanCunningGrant(g, frame, quote) : null;
return { event: frame.event, player: frame.player, blocked: quote.blocked, removeBlocked: quote.removeBlocked,
tokens: player === frame.player ? quote.tokens.map((token) => ({ id: token.id, kind: token.kind })) : [],
destinations: player === frame.player ? quote.destinations.map((id) => ({ id, name: territory(id).name })) : [] };
tokens: own ? quote.tokens.map(token => ({ id: token.id, kind: token.kind })) : [],
destinations: own ? quote.destinations.map(id => ({ id, name: territory(id).name })) : [],
removable: own ? quote.removable.map(token => ({ id: token.id, kind: token.kind,
territory: token.location!, name: territory(token.location!).name })) : [],
cunning: cunning ? { ...cunning, destinations: TERRITORIES.map(place => ({ id: place.id, name: place.name })) } : null };
}
function commitCollection(
g: Game,
@@ -26766,7 +26819,7 @@ function nexusMoritaniOffer(g: Game, owner: string, decision: Game['decision'] =
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.';
else if (!tokens.length) blocked = 'No supply token is available. Cunning does not enhance ordinary relocation.';
return {event:JSON.stringify(['nexusMoritani',g.turn,p.id]),blocked,tokens,
destinations:TERRITORIES.map(t => t.id)};
}
@@ -26789,9 +26842,23 @@ function nexusMoritaniIntegrity(g: Game) {
'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.');
if (record.receipt.source === 'grumman') {
const receipt = record.receipt;
nexusRule(() => quoteNexusMoritaniGrummanPlacement(before, receipt.token, receipt.territory, receipt.prerequisite));
requireRule(record.stage === 'complete' && record.frame === JSON.stringify({
token: receipt.token, territory: receipt.territory, turn: receipt.turn, nexusEvent: receipt.event,
sourceEvent: receipt.sourceEvent, prerequisite: receipt.prerequisite }),
'Moritani Cunning has changed its original Grumman addition source.');
if (receipt.turn === g.turn) requireRule(g.grummanCollection?.event === receipt.sourceEvent &&
g.grummanCollection.stage === 'complete' && g.grummanCollection.outcome === 'add' &&
g.grummanCollection.token === receipt.token && g.grummanCollection.territory === receipt.territory &&
g.grummanCollection.nexusEvent === receipt.event,
'Moritani Cunning has lost its completed current Grumman opportunity.');
} else {
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.');
+30 -22
View File
@@ -19,39 +19,47 @@ export function grummanCollectionChoice(
g: GameView,
token: string,
territory: string,
mode: 'add' | 'remove' = 'add',
useNexus = false,
): { blocked: string | null; action: Action | null } {
const offer = g.grummanCollection;
let blocked: string | null = null;
if (!grummanCollectionCanAct(g))
blocked = 'Wait for the current choice to finish.';
else if (offer!.blocked) blocked = offer!.blocked;
else if (!offer!.tokens.some((candidate) => candidate.id === token))
blocked = 'Choose a currently available Terror token.';
else if (!offer!.destinations.some((candidate) => candidate.id === territory))
blocked = 'Choose a current destination stronghold.';
else if (mode === 'remove') {
if (useNexus || territory) blocked = 'Returning a token does not use Cunning or select a destination.';
else if (offer!.removeBlocked) blocked = offer!.removeBlocked;
else if (!offer!.removable.some(candidate => candidate.id === token))
blocked = 'Choose one of your currently placed Terror tokens.';
} else {
if (useNexus && !offer!.cunning) blocked = 'You do not have a current Moritani Cunning addition.';
else if (useNexus ? offer!.cunning!.blocked : offer!.blocked)
blocked = useNexus ? offer!.cunning!.blocked : offer!.blocked;
else if (!offer!.tokens.some(candidate => candidate.id === token))
blocked = 'Choose a currently available Terror token.';
else if (!(useNexus ? offer!.cunning!.destinations : offer!.destinations).some(candidate => candidate.id === territory))
blocked = 'Choose a current permitted addition destination.';
}
return {
blocked,
action: blocked
? null
: {
type: 'decision',
event: offer!.event,
mode: 'add',
token,
territory,
},
action: blocked ? null : mode === 'remove'
? { type: 'decision', event: offer!.event, mode, token }
: { type: 'decision', event: offer!.event, mode, token, territory,
...(useNexus ? { nexus: offer!.cunning!.event } : {}) },
};
}
export function grummanCollectionActions(g: GameView): Action[] {
if (!grummanCollectionCanAct(g)) return [];
const offer = g.grummanCollection!;
const choice = grummanCollectionChoice(
g,
offer.tokens[0]?.id ?? '',
offer.destinations[0]?.id ?? '',
);
return [
choice.action ?? { type: 'decision', event: offer.event, decline: true },
];
if (offer.cunning && !offer.cunning.blocked) {
const choice = grummanCollectionChoice(g, offer.tokens[0]?.id ?? '',
offer.cunning.destinations[0]?.id ?? '', 'add', true);
if (choice.action) return [choice.action];
}
const addition = grummanCollectionChoice(g, offer.tokens[0]?.id ?? '',
offer.destinations[0]?.id ?? '');
if (addition.action) return [addition.action];
const removal = grummanCollectionChoice(g, offer.removable[0]?.id ?? '', '', 'remove');
return [removal.action ?? { type: 'decision', event: offer.event, decline: true }];
}
+18 -6
View File
@@ -1,36 +1,48 @@
import type { Game } from './engine';
import { HomeworldCustodyError } from './homeworld-custody';
import { TERROR_STRONGHOLDS } from './moritani-terror';
import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location';
export type GrummanCollection = {
event: string;
turn: number;
player: string;
stage: 'waiting' | 'choice' | 'complete';
outcome?: 'add' | 'decline' | 'expired';
outcome?: 'add' | 'remove' | 'decline' | 'expired';
token?: string;
territory?: string;
/** Earned Grumman Cunning receipt, never a removal or Mentat source. */
nexusEvent?: string;
signature: string;
};
/** Bind phase progress so a completed operation cannot rewind to a choice. */
export function grummanCollectionSignature(frame: Omit<GrummanCollection, 'signature'>): string {
return JSON.stringify(['grummanCollection', frame.event, frame.turn, frame.player,
frame.stage, frame.outcome ?? null, frame.token ?? null, frame.territory ?? null]);
frame.stage, frame.outcome ?? null, frame.token ?? null, frame.territory ?? null,
...(frame.nexusEvent === undefined ? [] : [frame.nexusEvent])]);
}
export function validateGrummanCollection(g: Pick<Game, 'turn' | 'phase' | 'players' | 'homeworlds' | 'grummanCollection'>): void {
export function validateGrummanCollection(g: Pick<Game, 'turn' | 'phase' | 'players' | 'homeworlds' | 'grummanCollection'> & TerrorLocationContext): void {
const frame = g.grummanCollection;
if (frame === undefined) return;
const text = (s: unknown): s is string => typeof s === 'string' && !!s;
if (!frame || typeof frame !== 'object' || Array.isArray(frame) ||
Object.keys(frame).some((key) => !['event', 'turn', 'player', 'stage', 'outcome', 'token', 'territory', 'signature'].includes(key)) ||
Object.keys(frame).some((key) => !['event', 'turn', 'player', 'stage', 'outcome', 'token', 'territory', 'nexusEvent', 'signature'].includes(key)) ||
!text(frame.event) || !text(frame.player) || !Number.isSafeInteger(frame.turn) || frame.turn < 1 || frame.turn > g.turn ||
!g.homeworlds?.custody || g.players.filter((p) => p.id === frame.player && p.faction === 'moritani').length !== 1 ||
!['waiting', 'choice', 'complete'].includes(frame.stage) ||
(frame.stage !== 'complete' && (frame.turn !== g.turn || g.phase !== 7)) ||
(frame.stage === 'complete' ? !['add', 'decline', 'expired'].includes(frame.outcome ?? '') : frame.outcome !== undefined) ||
(frame.outcome === 'add' ? !text(frame.token) || !TERROR_STRONGHOLDS.includes(frame.territory ?? '') : frame.token !== undefined || frame.territory !== undefined) ||
(frame.stage === 'complete' ? !['add', 'remove', 'decline', 'expired'].includes(frame.outcome ?? '') : frame.outcome !== undefined) ||
(frame.outcome === 'add' || frame.outcome === 'remove'
? !text(frame.token) || !text(frame.territory) ||
(frame.outcome === 'add' && frame.nexusEvent !== undefined
? !text(frame.nexusEvent) || !terrorLocationAllowed(g, frame.token, frame.territory, frame.nexusEvent, false, true)
: frame.outcome === 'add'
? !TERROR_STRONGHOLDS.includes(frame.territory)
: !terrorLocationAllowed(g, frame.token, frame.territory, undefined, false, true))
: frame.token !== undefined || frame.territory !== undefined) ||
(frame.nexusEvent !== undefined && frame.outcome !== 'add') ||
frame.signature !== grummanCollectionSignature(frame))
throw new HomeworldCustodyError('The saved Grumman Collection opportunity no longer matches its original phase and completion.');
}
+24 -8
View File
@@ -4,6 +4,7 @@ import { HomeworldCustodyError } from './homeworld-custody';
import { homeworldContext } from './homeworld-game';
import { homeworldPopulations } from './homeworld-population';
import {
returnTerror,
TERROR_KINDS,
TERROR_STRONGHOLDS,
type TerrorState,
@@ -22,16 +23,15 @@ export type GrummanCollectionQuote = {
/** Owner-only physical identities and secret faces; never a public projection. */
tokens: TerrorToken[];
destinations: string[];
removeBlocked: string;
/** Owner-only placed identities, including historically authorized Cunning locations. */
removable: TerrorToken[];
removeBlocked: string | null;
};
export type GrummanCollectionAction =
| { mode: 'add'; token: string; destination: string }
| { mode: 'decline' }
| { mode: 'remove'; token: string };
const removalBlock =
'Grumman Terror removal awaits a ruling on the removed token’s next custody zone.';
function fail(message: string): never {
throw new HomeworldCustodyError(message);
}
@@ -118,6 +118,9 @@ export function quoteGrummanCollection(
),
)
: [];
const removable = high
? state.tokens.filter((token) => token.status === 'placed')
: [];
return {
player: ownerId,
population: home?.population ?? null,
@@ -131,16 +134,23 @@ export function quoteGrummanCollection(
: null,
tokens: tokens.map((token) => ({ ...token })),
destinations: [...destinations],
removeBlocked: removalBlock,
removable: removable.map((token) => ({ ...token })),
removeBlocked: !high
? 'Grumman needs at least eight native forces for its collection opportunity.'
: !removable.length
? 'There is no placed Terror token to return to supply.'
: null,
};
}
/** A successful addition and its four bank spice are one quote. No effects,
* randomness, supply rotation or Mentat placement allowance are consumed here. */
/** A successful operation and its four bank spice are one quote. Removal
* rotates hidden supply with private randomness; Mentat usage remains separate. */
export function quoteGrummanCollectionAction(
context: GrummanCollectionContext,
ownerId: string,
action: GrummanCollectionAction,
random: () => number = () =>
crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296,
): { state: TerrorState; amount: 0 | 4 } {
const state = stateFor(context, ownerId);
if (
@@ -156,7 +166,13 @@ export function quoteGrummanCollectionAction(
fail('Choose one Grumman collection operation.');
if (action.mode === 'decline')
return { state: structuredClone(state), amount: 0 };
if (action.mode === 'remove') fail(removalBlock);
if (action.mode === 'remove') {
const quote = quoteGrummanCollection(context, ownerId);
if (quote.removeBlocked) fail(quote.removeBlocked);
if (!quote.removable.some((token) => token.id === action.token))
fail('Choose a currently placed physical Terror token to return to supply.');
return { state: returnTerror(state, action.token, random), amount: 4 };
}
if (action.mode !== 'add') fail('Choose one Grumman collection operation.');
const quote = quoteGrummanCollection(context, ownerId);
if (quote.blocked) fail(quote.blocked);
+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);
}
+7 -3
View File
@@ -3952,7 +3952,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In supported payment cases, low Kaitain halves Emperor’s Treachery payment income and low Junction halves eligible Guild shipping income, rounded up. The payer still pays the full cost. For example, a five-spice auction payment gives low-population Emperor three spice and leaves two in the bank. The income opportunity shows the original payment, current receipt and bank remainder. A population change before collection updates the split; ordinary Karama can still prevent the separate faction income.',
'Guild’s own shipment contribution goes to the bank before any low-Junction reduction. Splits with two odd contributions from other factions remain unavailable while their rounding rule is unresolved; choose another funding split. Single-contributor payments and splits where both rounding interpretations agree remain supported. Occupier income is still unfinished.',
'With at least seven native forces on Giedi Prime, Harkonnen automatically gains two bank spice after collecting positive desert spice during Spice Collection, once for the whole phase. Several deposits still earn only two. Shared Ecaz spice qualifies when Harkonnen actually receives a positive allocation; a zero share, stronghold income or technology income does not qualify. Refreshing preserves the completed bonus.',
'With fewer than eight native forces on Grumman, Moritani can reveal Terror only when at least three forces enter together. Count each incoming physical counter once, including advisors, and a concealed No-Field as one regardless of its hidden value. Forces already at the destination do not count. High Grumman’s separate Collection choice can add a token to an existing stronghold stack and collect four bank spice; removal still requires a custody ruling.',
'With fewer than eight native forces on Grumman, Moritani can reveal Terror only when at least three forces enter together. Count each incoming physical counter once, including advisors, and a concealed No-Field as one regardless of its hidden value. Forces already at the destination do not count. High Grumman’s separate Collection choice can add a token to an existing stronghold stack or return an unrevealed placed token to hidden supply for four bank spice, once per Collection.',
'While Ecaz has at least seven native reserves, it automatically receives three bank spice for each discarded poison weapon, including its own. Poison Blade and Poison Tooth qualify; Chemistry qualifies only when used as a weapon. Poison defenses and Residual Poison do not qualify. Ecaz sees its income in a private Homeworld record; private discarded faces stay private.',
'Battle cleanup checks population when each card is actually discarded. Losing-card disposal comes before winner casualties. Mandatory winning cards, including a used Poison Tooth, are discarded after winner casualties and any Ixian substitution, before optional winning-card cleanup. If Ecaz falls below seven during its winning losses, its later card discards earn no poison income.',
'If Tleilax starts Revival at low population, it receives no bank income for other factions’ ordinary free revivals throughout that phase, even if its population rises. Paid revival receipts and the separately named Ghola reward remain available.',
@@ -3962,7 +3962,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.',
'Choose weapons or defenses in the private Tupile panel at a settled action boundary. The server answers immediately without an opponent confirmation, and does not expose individual cards. Count physical held Treachery cards by their default category: Weirding Way is a weapon, Chemistry is a defense, while Worthless cards and leader substitutes count as neither. Selecting the other category later cannot renew a faction’s used entitlement.',
'Occupation removes Tupile’s low advantage. New Homeworld games record qualifying foreign presence from setup, including brief sole occupation during ordered battle losses and presence at turn boundaries. Earlier saves without that history, or histories with a Tupile qualifier whose continuing entitlement is unresolved, cannot make a new intelligence request. Already learned observations remain readable. Recorded qualification does not itself award occupied income or settle its expiry.',
'During Spice Collection, Grumman with at least eight native reserves may add one available Terror token to an ordinary stronghold that already has Terror, then collect four bank spice. This opportunity is separate from Mentat placement and follows any pending shared spice allocation. Declining collects nothing. Removing a token remains unavailable while its destination after removal awaits a ruling.',
'During Spice Collection, Grumman with at least eight native reserves may add one available Terror token to an ordinary stronghold already holding Terror, or return one unrevealed placed token to hidden supply, then collect four bank spice. Hidden supply identities rotate without revealing the returned face. This opportunity is separate from Mentat placement and follows pending shared spice allocation. Declining collects nothing. Under the user-selected Cunning interpretation, an ordinary eligible addition must first exist; an unallied Moritani holder may then spend its Nexus to broaden that addition’s destination to static Arrakis.',
'When several Terror tokens share a stronghold, Moritani privately selects one for an eligible entry, then chooses whether to reveal it or offer an alliance. The other tokens remain hidden. The board shows the public stack count; refresh preserves the selected token and original arrival.',
'While Junction has high population, Guild may offer another faction transport at half or full price during that faction’s shipment. The recipient can choose exact forces from one territory or Homeworld, including a return to their own world. An allied Homeworld remains forbidden. Half-price totals round up; five forces to a Homeworld cost three spice. The Junction route and offered rate resist Karama.',
'Homeworld shipment, battle and occupation have special rules. Occupiers receive the printed Collection income and card effects. An alliance can gain a special Homeworld victory through high-population Ecaz; this is separate from ordinary stronghold victory.',
@@ -4194,6 +4194,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/grumman-collection-engine.test.ts',
'tests/grumman-collection-controls.test.ts',
'tests/grumman-collection-recovery.test.ts',
'tests/grumman-supply-return.test.ts',
'tests/grumman-cunning-runtime.test.ts',
'tests/nexus-moritani-grumman.test.ts',
'tests/fixture-ruled-grumman.ts',
'tests/grumman-selection-integrity.test.ts',
'tests/homeworld-benefits-recovery.test.ts',],
},
@@ -4569,7 +4573,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'With CHOAM seated, an unallied holder may spend CHOAM Betrayal at a clean play boundary to discard one uniformly random held CHOAM Treachery Card without spice compensation. The target name is public, but its hand count is offered only during Bidding; the holder cannot pick or inspect the card before the ordinary discard reveal.',
'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 retain their existing runtime. The explicit new Nexus Kull profile adds Kull at the canonical Karama reaction only; it remains unavailable in other Nexus previews. The old printed Kull profile still excludes Nexus. See the source-aware Kull guide for cost custody, counters and pending verification.',
'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.',
'Moritani Cunning enhances supply placement only, not relocation, and never permits Homeworlds or the Hidden Mobile Stronghold. During native Mentat placement, choose one available supply token and a printed Arrakis territory, including one already holding Terror. The high-Grumman Collection controls additionally offer Cunning when an ordinary eligible supply addition to an existing stronghold stack is available. Cunning may then broaden the selected destination, but creates no underlying opportunity and does not spend the independent Mentat allowance.',
'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.',
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.',