Implement Grumman collection and private stacked Terror choices

Add conserved token collection with four bank spice, owner-only stack selection, recovery proofs, controls and AI. Preserve unresolved removal and expansion release gates. Verified 3712 offline cases, types, lint, production build, 40 HTTP cases, three-seat desktop/mobile play, and unchanged 3081 preexisting saved games.
This commit is contained in:
Quintine committed 2026-09-10 08:38:06 +10:00
1 parent 28ba1618ba
commit 1db11a0187
27 files changed
+2451 -43

No files matched your search

+7
View File
@@ -1,6 +1,7 @@
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
import { caladanReinforcementActions } from './caladan-reinforcement-options';
import { grummanCollectionActions } from './grumman-collection-options';
import { botHomeworldShipmentPaymentAllowed } from './homeworld-payment-options';
import { guildHomeworldShipmentActions } from './guild-homeworld-shipment-options';
import { choamSaleGholaTiming } from './choam-market-ghola';
@@ -1471,6 +1472,10 @@ function policyActions(g: GameView): Action[] {
if (d.kind === 'moritaniTerror') {
const entry = g.terrorEntry;
if (!entry) return [];
if (entry.stage === 'select') {
const candidate = entry.candidates?.find((candidate) => candidate.canReveal || candidate.canOfferAlliance);
return [candidate ? { type: 'decision', token: candidate.token } : { type: 'decision', decline: true }];
}
if (entry.stage === 'allianceReply') {
const moritani = g.players.find(
(player) => player.faction === 'moritani',
@@ -2378,6 +2383,8 @@ function policyActions(g: GameView): Action[] {
return homeworldRevivalDeploymentActions(g);
if (d.kind === 'caladanReinforcement')
return caladanReinforcementActions(g);
if (d.kind === 'grummanCollection')
return grummanCollectionActions(g);
if (d.kind === 'battleCards')
return [
{
+140 -6
View File
@@ -13,6 +13,8 @@ import type { HomeworldForces } from './homeworld-custody';
import { nativeReserveSources } from './homeworld-options';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { lowGrummanRevealBlock } from './homeworld-collection';
import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return';
import { ambassadorPhaseAllowed } from './ambassador-phase';
import { makeHomeworldVictoryReturn, validateHomeworldVictoryReturn, homeworldVictoryOfferSignature,
homeworldVictoryObligationSignature, type HomeworldVictoryReturn, type HomeworldVictoryObligation } from './homeworld-victory-return';
@@ -34,7 +36,7 @@ import {
type HomeworldRevivalReturn,
} from './homeworld-revival-return';
import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations';
import { terrorEntrySignature, validateTerrorEntrySignature } from './terror-entry-receipt';
import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt';
import {
homeworldLowBonus,
homeworldRevivalKaramaBlock,
@@ -577,6 +579,7 @@ export type Auction = {
};
export type Decision =
| { kind: 'homeworldRevivalDeployment'; player: string; event: string }
| { kind: 'grummanCollection'; player: string; event: string }
| { kind: 'caladanReinforcement'; player: string; event: string }
| { kind: 'choamAudit'; player: string; event: string }
| { kind: 'choamAuditPayment'; player: string; event: string }
@@ -1086,6 +1089,8 @@ export type Game = {
pendingTerrorEntry?: {
/** Original public arrival, absent only in legacy saves. */
entrySignature?: string;
candidates?: string[];
selectionSignature?: string;
token: string;
entrant: string;
territory: string;
@@ -1104,6 +1109,7 @@ export type Game = {
turn: number;
phase: number;
stage:
| 'select'
| 'offer'
| 'robbery'
| 'discard'
@@ -1116,6 +1122,7 @@ export type Game = {
ambassadorEvent?: string;
} | null;
moritaniTerror?: TerrorState;
grummanCollection?: GrummanCollection;
pendingMoritaniPlacement?: {
token: string;
territory: string;
@@ -2490,6 +2497,7 @@ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
homeworldRevivalReturn: g.homeworldRevivalReturn,
...(g.homeworldRevivalProgress === undefined ? {} : { homeworldRevivalProgress: g.homeworldRevivalProgress }),
homeworldVictoryReinforcement: g.homeworldVictoryReinforcement,
...(g.grummanCollection === undefined ? {} : { grummanCollection: g.grummanCollection }),
dukeVidal: g.dukeVidal,
revivalFreeIncome: g.revivalFreeIncome,
players: g.players.map((p) => ({
@@ -8715,6 +8723,17 @@ function terrorEntryIntegrity(g: Game) {
g.players.some((p) => p.id === entry.entrant && p.faction !== 'moritani') &&
g.players.filter((p) => p.faction === 'moritani').length === 1,
'The saved Terror entry has lost its original turn or seated participants.');
if (entry.candidates) {
const committedDiscard = continuation?.kind === 'terrorDiscard' && entry === continuation.entry;
const reserved = entry.candidates.filter((id) => id !== entry.token ||
(!committedDiscard && ['select', 'offer', 'allianceResponse', 'allianceReply'].includes(entry.stage)));
requireRule(reserved.every((id) => g.moritaniTerror?.tokens.filter((token) =>
token.id === id && token.status === 'placed' && token.location === entry.territory).length === 1),
'The stacked Terror choice has lost an original reserved token.');
if (committedDiscard)
requireRule(g.moritaniTerror?.tokens.some((token) => token.id === entry.token && token.status === 'removed'),
'The stacked Terror discard has lost its consumed token.');
}
}
}
/** Public original batch size; concealed No-Field markers count as one. */
@@ -8737,9 +8756,10 @@ function openTerrorEntry(
ambassadorEvent?: string,
) {
const moritani = byFaction(g, 'moritani');
const token = g.moritaniTerror?.tokens.find(
const tokens = g.moritaniTerror?.tokens.filter(
(t) => t.status === 'placed' && t.location === to,
);
) ?? [];
const token = tokens[0];
if (
!moritani ||
!token ||
@@ -8755,6 +8775,7 @@ function openTerrorEntry(
);
g.pendingTerrorEntry = {
token: token.id,
...(tokens.length > 1 ? { candidates: tokens.map((t) => t.id) } : {}),
entrant: entrant.id,
territory: to,
sector,
@@ -8763,7 +8784,7 @@ function openTerrorEntry(
cause,
turn: g.turn,
phase: g.phase,
stage: 'offer',
stage: tokens.length > 1 ? 'select' : 'offer',
resume,
...(ambassadorEvent ? { ambassadorEvent } : {}),
};
@@ -8933,6 +8954,24 @@ function decideTerror(g: Game, p: Player, action: Action) {
: p.faction === 'moritani'),
'This Terror entry opportunity is no longer current.',
);
if (entry.stage === 'select') {
if (action.decline === true) {
requireRule(action.token === undefined, 'Declining leaves every stacked token hidden.');
log(g, `${p.name} left all Terror tokens hidden after this entry.`);
finishTerrorEntry(g);
return;
}
requireRule(typeof action.token === 'string' && entry.candidates?.includes(action.token),
'Choose one of the original stacked Terror tokens.');
requireRule(action.reveal === undefined && action.alliance === undefined,
'Choose the token before deciding whether to reveal it or offer an alliance.');
entry.token = action.token;
entry.stage = 'offer';
entry.selectionSignature = terrorSelectionSignature(entry);
entry.entrySignature = terrorEntrySignature(entry);
g.decision = { kind: 'moritaniTerror', player: p.id, entrant: entry.entrant, territory: entry.territory };
return;
}
const token = g.moritaniTerror?.tokens.find((t) => t.id === entry.token);
requireRule(token, 'The entry’s Terror token is unavailable.');
const entrant = getPlayer(g, entry.entrant);
@@ -9865,6 +9904,11 @@ function finishEcazPlacement(g: Game) {
completePhase(g);
}
function completePhase(g: Game) {
if (g.phase === 7 && g.grummanCollection?.turn === g.turn && g.grummanCollection.stage === 'waiting') {
g.grummanCollection.stage = 'complete';
g.grummanCollection.outcome = 'expired';
g.grummanCollection.signature = grummanCollectionSignature(g.grummanCollection);
}
if (g.phase === 8) settleStrongholdOwnership(g);
g.phase++;
if (g.phase === 9) {
@@ -10236,6 +10280,77 @@ function creditGiediCollection(g: Game, player: string, desert: number) {
log(g, 'Harkonnen received 2 spice from the bank: high-population Giedi Prime rewards positive desert collection once this phase.',
{ faction: 'harkonnen', name: 'Giedi Prime collection' });
}
function grummanCollectionIntegrity(g: Game) {
homeworldRule(() => validateGrummanCollection(g));
const frame = g.grummanCollection;
const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
const decisions = contexts.flatMap((context) => context?.decision?.kind === 'grummanCollection' ? [context.decision] : []);
requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event) &&
(frame?.stage !== 'choice' || decisions.length > 0),
'The Grumman Collection choice has lost its original phase opportunity.');
}
function stageGrummanCollection(g: Game) {
const owner = byFaction(g, 'moritani');
if (!owner || !g.homeworlds?.custody || g.grummanCollection?.turn === g.turn) return;
const frame: GrummanCollection = { event: crypto.randomUUID(), turn: g.turn,
player: owner.id, stage: 'waiting', signature: '' };
frame.signature = grummanCollectionSignature(frame);
g.grummanCollection = frame;
}
function grummanCollectionAutomatic(g: Game): boolean {
const frame = g.grummanCollection;
return !!(frame?.stage === 'waiting' && frame.turn === g.turn && g.phase === 7 &&
!g.phaseOpening && !g.decision && !g.response && !g.truthtrance && !g.pendingTreacheryDiscard &&
!g.pendingNullentropy && !g.pendingExchange && !g.pendingRicheseGift && !g.pendingKarama &&
(!g.ecazCollection || g.ecazCollection.stage === 'complete') &&
homeworldRule(() => quoteGrummanCollection(g, frame.player)).high);
}
function resumeGrummanCollection(g: Game) {
if (!grummanCollectionAutomatic(g)) return;
const frame = g.grummanCollection!;
frame.stage = 'choice';
frame.signature = grummanCollectionSignature(frame);
g.decision = { kind: 'grummanCollection', player: frame.player, event: frame.event };
}
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.');
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' });
}
frame.stage = 'complete';
frame.signature = grummanCollectionSignature(frame);
}
function projectedGrummanCollection(g: Game, player: string) {
const frame = g.grummanCollection;
if (frame?.stage !== 'choice') return null;
const quote = homeworldRule(() => quoteGrummanCollection(g, frame.player));
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 })) : [] };
}
function commitCollection(
g: Game,
quote: ReturnType<typeof quoteSpiceCollection>,
@@ -10295,8 +10410,13 @@ function commitCollection(
}
for (const receipt of quote.receipts)
creditGiediCollection(g, receipt.player, receipt.desert);
stageGrummanCollection(g);
}
function collect(g: Game) {
if (g.grummanCollection?.turn === g.turn) {
grummanCollectionIntegrity(g);
return;
}
if (g.ecazCollection?.turn === g.turn) {
ecazCollectionIntegrity(g);
return;
@@ -11025,6 +11145,7 @@ function gholaOptions(g: Game, p: Player) {
};
}
function marketGholaIntegrity(g: Game) {
grummanCollectionIntegrity(g);
homeworldVictoryReturnIntegrity(g);
homeworldRevivalReturnIntegrity(g);
terrorEntryIntegrity(g);
@@ -16013,6 +16134,7 @@ function finishActionContinuations(g: Game) {
resumeHomeworldRevivalReturn(g);
resumeHomeworldVictoryReturn(g);
resumeMarketGhola(g);
resumeGrummanCollection(g);
if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g);
if (
!g.truthtrance &&
@@ -16544,6 +16666,10 @@ function applyActionInner(
}
return g;
}
if (decision.kind === 'grummanCollection') {
decideGrummanCollection(g, p, action);
return g;
}
if (decision.kind === 'moritaniTerror') {
decideTerror(g, p, action);
return g;
@@ -19411,7 +19537,7 @@ export function viewGame(state: Game, id: string) {
: null,
schema: g.schema,
botsPending: g.botsPending ?? false,
automaticContinuationPending: !!g.pendingTreacheryDiscard || homeworldRevealPending(g) || homeworldShipmentAutomatic(g),
automaticContinuationPending: !!g.pendingTreacheryDiscard || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || grummanCollectionAutomatic(g),
botNextActionAt: g.botNextActionAt ?? null,
code: g.code,
version: g.version,
@@ -19428,6 +19554,7 @@ export function viewGame(state: Game, id: string) {
: null,
moritaniPendingPlacement:
me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null,
grummanCollection: projectedGrummanCollection(g, id),
terrorEntry: g.pendingTerrorEntry
? (() => {
const entry = g.pendingTerrorEntry!;
@@ -19440,7 +19567,14 @@ export function viewGame(state: Game, id: string) {
sector: entry.sector,
cause: entry.cause,
stage: entry.stage,
...(me.faction === 'moritani' && token
...(me.faction === 'moritani' && entry.stage === 'select'
? { candidates: entry.candidates!.map((id) => {
const candidate = g.moritaniTerror!.tokens.find((t) => t.id === id)!;
return { token: id, kind: candidate.kind, canReveal: !terrorRevealBlocked(g, entry, candidate.kind),
canOfferAlliance: !terrorAllianceBlocked(g, entry, candidate.kind),
revealBlocked: terrorRevealBlocked(g, entry, candidate.kind) ?? undefined };
}) } : {}),
...(me.faction === 'moritani' && token && entry.stage !== 'select'
? {
kind: token.kind as TerrorKind,
canOfferAlliance: !terrorAllianceBlocked(
+57
View File
@@ -0,0 +1,57 @@
import type { Action, GameView } from './engine';
/** Use the current private projection; do not reconstruct hidden Terror supply. */
export function grummanCollectionCanAct(g: GameView): boolean {
return !!(
g.status === 'playing' &&
g.grummanCollection?.player === g.me &&
g.decision?.kind === 'grummanCollection' &&
g.decision.player === g.me &&
g.decision.event === g.grummanCollection.event &&
!g.response &&
!g.truthtrance &&
!g.phaseOpening &&
!g.automaticContinuationPending
);
}
export function grummanCollectionChoice(
g: GameView,
token: string,
territory: string,
): { 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.';
return {
blocked,
action: blocked
? null
: {
type: 'decision',
event: offer!.event,
mode: 'add',
token,
territory,
},
};
}
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 },
];
}
+36
View File
@@ -0,0 +1,36 @@
import type { Game } from './engine';
import { HomeworldCustodyError } from './homeworld-custody';
import { TERROR_STRONGHOLDS } from './moritani-terror';
export type GrummanCollection = {
event: string;
turn: number;
player: string;
stage: 'waiting' | 'choice' | 'complete';
outcome?: 'add' | 'decline' | 'expired';
token?: string;
territory?: 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]);
}
export function validateGrummanCollection(g: Pick<Game, 'turn' | 'phase' | 'players' | 'homeworlds' | 'grummanCollection'>): 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)) ||
!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.signature !== grummanCollectionSignature(frame))
throw new HomeworldCustodyError('The saved Grumman Collection opportunity no longer matches its original phase and completion.');
}
+173
View File
@@ -0,0 +1,173 @@
import type { Game } from './engine';
import { HomeworldCustodyError } from './homeworld-custody';
import { homeworldContext } from './homeworld-game';
import { homeworldPopulations } from './homeworld-population';
import {
TERROR_KINDS,
TERROR_STRONGHOLDS,
type TerrorState,
type TerrorToken,
} from './moritani-terror';
export type GrummanCollectionContext = Pick<
Game,
'advanced' | 'players' | 'homeworlds' | 'moritaniTerror'
>;
export type GrummanCollectionQuote = {
player: string;
population: number | null;
high: boolean;
blocked: string | null;
/** Owner-only physical identities and secret faces; never a public projection. */
tokens: TerrorToken[];
destinations: string[];
removeBlocked: string;
};
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);
}
function knownKeys(value: unknown, allowed: string[]): boolean {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).every((key) => allowed.includes(key))
);
}
function stateFor(
context: GrummanCollectionContext,
ownerId: string,
): TerrorState {
const owners = context.players.filter((player) => player.id === ownerId);
if (owners.length !== 1 || owners[0].faction !== 'moritani')
fail('Only Moritani can use the native Grumman collection opportunity.');
const state = context.moritaniTerror;
if (
!knownKeys(state, ['tokens', 'placementTurn', 'supplyEpoch']) ||
!Array.isArray(state?.tokens) ||
state.tokens.length !== TERROR_KINDS.length ||
(state.placementTurn !== undefined &&
(!Number.isSafeInteger(state.placementTurn) ||
state.placementTurn < 1)) ||
(state.supplyEpoch !== undefined &&
(!Number.isSafeInteger(state.supplyEpoch) || state.supplyEpoch < 0))
)
fail(
'Grumman needs the complete original Terror inventory and placement records.',
);
const ids = new Set<string>(),
kinds = new Set<string>();
for (const token of state.tokens) {
if (
!knownKeys(token, ['id', 'kind', 'location', 'status']) ||
typeof token.id !== 'string' ||
!token.id ||
ids.has(token.id) ||
!TERROR_KINDS.includes(token.kind) ||
kinds.has(token.kind) ||
!['available', 'placed', 'removed', 'extortion'].includes(token.status) ||
(token.status === 'placed'
? typeof token.location !== 'string' ||
!TERROR_STRONGHOLDS.includes(token.location)
: token.location !== null) ||
(token.status === 'extortion' && token.kind !== 'extortion')
)
fail(
'Grumman needs six distinct Terror tokens in their valid custody zones.',
);
ids.add(token.id);
kinds.add(token.kind);
}
return state;
}
/** Current native population only. The caller owns phase, once-only receipt and
* pending-entry reservations. Occupied Homeworld lifecycle remains separate. */
export function quoteGrummanCollection(
context: GrummanCollectionContext,
ownerId: string,
): GrummanCollectionQuote {
const state = stateFor(context, ownerId);
const home = context.homeworlds?.custody
? homeworldPopulations(
homeworldContext(context),
context.homeworlds.custody,
).find(
(candidate) =>
candidate.native === ownerId && candidate.card === 'grumman',
)
: undefined;
const high = home?.side === 'high';
const tokens = high
? state.tokens.filter((token) => token.status === 'available')
: [];
const destinations = high
? TERROR_STRONGHOLDS.filter((destination) =>
state.tokens.some(
(token) =>
token.status === 'placed' && token.location === destination,
),
)
: [];
return {
player: ownerId,
population: home?.population ?? null,
high,
blocked: !high
? 'Grumman needs at least eight native forces for its collection opportunity.'
: !tokens.length
? 'There is no available Terror token to add.'
: !destinations.length
? 'Adding Terror requires an ordinary stronghold already containing a Terror token.'
: null,
tokens: tokens.map((token) => ({ ...token })),
destinations: [...destinations],
removeBlocked: removalBlock,
};
}
/** A successful addition and its four bank spice are one quote. No effects,
* randomness, supply rotation or Mentat placement allowance are consumed here. */
export function quoteGrummanCollectionAction(
context: GrummanCollectionContext,
ownerId: string,
action: GrummanCollectionAction,
): { state: TerrorState; amount: 0 | 4 } {
const state = stateFor(context, ownerId);
if (
!knownKeys(
action,
action?.mode === 'add'
? ['mode', 'token', 'destination']
: action?.mode === 'remove'
? ['mode', 'token']
: ['mode'],
)
)
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 !== 'add') fail('Choose one Grumman collection operation.');
const quote = quoteGrummanCollection(context, ownerId);
if (quote.blocked) fail(quote.blocked);
if (!quote.tokens.some((token) => token.id === action.token))
fail('Choose an available physical Terror token from your supply.');
if (!quote.destinations.includes(action.destination))
fail('Choose an ordinary stronghold already containing Terror.');
const result = structuredClone(state);
const token = result.tokens.find(
(candidate) => candidate.id === action.token,
)!;
token.status = 'placed';
token.location = action.destination;
return { state: result, amount: 4 };
}
+1 -5
View File
@@ -187,11 +187,7 @@ export function quoteMoritaniAllianceCancellation(
matches.length === 1 &&
matches[0].status === 'placed' &&
matches[0].location === entry.territory &&
TERROR_KINDS.includes(matches[0].kind) &&
tokens.filter(
(token) =>
token.status === 'placed' && token.location === entry.territory,
).length === 1,
TERROR_KINDS.includes(matches[0].kind),
'The alliance opportunity no longer has its original hidden Terror token.',
);
return {
+1
View File
@@ -274,6 +274,7 @@ export type TerrorDefinition = Readonly<{
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.',
'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.',
+13
View File
@@ -3134,6 +3134,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Placement involving Face Dancer ordering, storm, the mobile stronghold or unresolved entry reactions remains guarded. These guards do not certify those combined rules.',
'Low Tupile’s intelligence advantage requires contact with another faction’s Homeworld or that faction’s presence on Tupile, and is usable once per faction. Its public eligibility checks are implemented, but the private disclosure action and occupation-dependent availability remain unfinished.',
'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.',
'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.',
'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
@@ -3163,6 +3165,12 @@ export const RULE_TOPICS: RuleTopic[] = [
'game/homeworld-revival-return.ts',
'game/homeworld-victory-reinforcement.ts',
'game/homeworld-victory-return.ts',
'game/grumman-collection.ts',
'game/grumman-collection-return.ts',
'game/grumman-collection-options.ts',
'game/terror-entry-receipt.ts',
'components/grumman-collection.tsx',
'components/terror-board-markers.tsx',
'game/homeworld-arrival.ts',
'game/tupile-intelligence.ts',
'game/homeworld-shipment.ts',
@@ -3302,6 +3310,11 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/caladan-reinforcement-controls.test.ts',
'tests/homeworld-victory-reinforcement-recovery.test.ts',
'tests/tupile-intelligence.test.ts',
'tests/grumman-collection.test.ts',
'tests/grumman-collection-engine.test.ts',
'tests/grumman-collection-controls.test.ts',
'tests/grumman-collection-recovery.test.ts',
'tests/grumman-selection-integrity.test.ts',
'tests/homeworld-benefits-recovery.test.ts',
],
},
+1
View File
@@ -21,6 +21,7 @@ const DECISIONS = {
ecazSpice: true,
homeworldRevivalDeployment: true,
caladanReinforcement: true,
grummanCollection: true,
choamStorm: true,
choamMovement: true,
choamMentat: true,
+17 -2
View File
@@ -9,7 +9,7 @@ type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
* This is a consistency receipt, not an authorization token or secret digest. */
export function terrorEntrySignature(entry: TerrorEntry): string {
return JSON.stringify([
entry.token,
entry.candidates === undefined ? entry.token : ['stack', entry.candidates, entry.selectionSignature ?? null],
entry.entrant,
entry.territory,
entry.sector,
@@ -23,14 +23,29 @@ export function terrorEntrySignature(entry: TerrorEntry): string {
]);
}
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]);
}
/** 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) return;
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.stage !== 'select') return;
if (
typeof entry.entrySignature !== 'string' ||
typeof entry.token !== 'string' ||
!entry.token ||
(entry.candidates === undefined
? entry.selectionSignature !== undefined || entry.stage === 'select'
: !Array.isArray(entry.candidates) || entry.candidates.length < 2 ||
entry.candidates.some((id) => typeof id !== 'string' || !id) ||
new Set(entry.candidates).size !== entry.candidates.length ||
!entry.candidates.includes(entry.token) ||
(entry.stage === 'select'
? entry.token !== entry.candidates[0] || entry.selectionSignature !== undefined
: entry.selectionSignature !== terrorSelectionSignature(entry))) ||
typeof entry.entrant !== 'string' ||
!entry.entrant ||
!TERROR_STRONGHOLDS.includes(entry.territory) ||