Files
Dune/game/grumman-collection.ts
T

175 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
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[];
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' ||
!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,
),
)
: [];
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 };
}