Files
Dune/game/grumman-collection.ts
T

191 lines
6.8 KiB
TypeScript

import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location';
import type { Game } from './engine';
import { HomeworldCustodyError } from './homeworld-custody';
import { homeworldContext } from './homeworld-game';
import { homeworldPopulations } from './homeworld-population';
import {
returnTerror,
TERROR_KINDS,
TERROR_STRONGHOLDS,
type TerrorState,
type TerrorToken,
} from './moritani-terror';
export type GrummanCollectionContext = Pick<
Game,
'advanced' | 'players' | 'homeworlds' | 'moritaniTerror'
> & TerrorLocationContext;
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[];
/** 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 };
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' ||
!terrorLocationAllowed(context,token.id,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,
),
)
: [];
const removable = high
? state.tokens.filter((token) => token.status === 'placed')
: [];
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],
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 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 (
!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') {
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);
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 };
}