366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { MOBILE_STRONGHOLD } from './board';
|
|
import { isDiscoveryLocationId } from './discoveries';
|
|
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
|
|
|
export type NoFieldValue = 0 | 3 | 5;
|
|
/** Existing planet records retain their original JSON shape. A Homeworld is a
|
|
* distinct location, not a territory or a synthetic sector. */
|
|
export type NoFieldTerritoryLocation = { territory: string; sector: number };
|
|
export type NoFieldHomeworldLocation = { homeworld: string };
|
|
export type NoFieldLocation = NoFieldTerritoryLocation | NoFieldHomeworldLocation;
|
|
|
|
const HOMEWORLD_LOCATIONS = new Set(HOMEWORLD_CARDS.map(card =>
|
|
`homeworld:${card.faction}${card.id === 'salusa_secundus' ? ':salusa' : ''}`));
|
|
|
|
export function isHomeworldNoFieldLocation(location: NoFieldLocation): location is NoFieldHomeworldLocation {
|
|
return !!location && typeof location === 'object' && 'homeworld' in location;
|
|
}
|
|
|
|
export function isBoardNoFieldLocation(location: NoFieldLocation): location is NoFieldTerritoryLocation {
|
|
return !!location && typeof location === 'object' && 'territory' in location;
|
|
}
|
|
export type NoFieldToken = { id: string; value: NoFieldValue };
|
|
export type NoFieldDeployment = {
|
|
tokenId: string;
|
|
controller: string;
|
|
location: NoFieldLocation;
|
|
};
|
|
export type RicheseNoField = {
|
|
tokens: NoFieldToken[];
|
|
/** Shared own/allied shipment history; revelation never resets it. */
|
|
lastShipped: string | null;
|
|
deployed: NoFieldDeployment | null;
|
|
/** A forced face-down recall retains the last shipment without revealing it. */
|
|
hiddenReturn?: true;
|
|
};
|
|
export type NoFieldRevealCause =
|
|
| 'voluntary'
|
|
| 'battle'
|
|
| 'storm'
|
|
| 'worm'
|
|
| 'gamont'
|
|
| 'beforeAlly'
|
|
| 'allyShipment'
|
|
| 'atomics'
|
|
| 'alliedSeparation'
|
|
| 'discoveryBlow';
|
|
export type NoFieldReveal = {
|
|
state: RicheseNoField;
|
|
tokenId: string;
|
|
controller: string;
|
|
location: NoFieldLocation;
|
|
value: NoFieldValue;
|
|
/** Materialized physical forces, not the hidden marker's effective count. */
|
|
forces: number;
|
|
cause: NoFieldRevealCause;
|
|
};
|
|
export type RicheseNoFieldView = {
|
|
deployed: {
|
|
controller: string;
|
|
location: NoFieldLocation;
|
|
effectiveForces: 1;
|
|
} | null;
|
|
lastUsed: NoFieldValue | null;
|
|
};
|
|
export type RicheseNoFieldOwnerView = RicheseNoFieldView & {
|
|
private: RicheseNoField;
|
|
};
|
|
|
|
function requireNoField(
|
|
condition: unknown,
|
|
message: string,
|
|
): asserts condition {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
function validId(id: unknown): id is string {
|
|
return typeof id === 'string' && id.trim().length > 0;
|
|
}
|
|
function validateLocation(location: NoFieldLocation) {
|
|
requireNoField(location && typeof location === 'object' && !Array.isArray(location),
|
|
'Choose a valid No-Field location.');
|
|
if (isHomeworldNoFieldLocation(location)) {
|
|
requireNoField(Object.keys(location).length === 1 && HOMEWORLD_LOCATIONS.has(location.homeworld),
|
|
'Choose a valid Homeworld for the No-Field.');
|
|
return;
|
|
}
|
|
requireNoField(
|
|
isBoardNoFieldLocation(location) &&
|
|
validId(location.territory) &&
|
|
Number.isSafeInteger(location.sector) &&
|
|
(location.sector >= 1 ||
|
|
(location.sector === 0 &&
|
|
(location.territory === 'polar_sink' ||
|
|
location.territory === MOBILE_STRONGHOLD ||
|
|
isDiscoveryLocationId(location.territory)))) &&
|
|
location.sector <= 18,
|
|
'Choose a valid planet territory and sector for the No-Field.',
|
|
);
|
|
}
|
|
|
|
/** Validate JSON custody too; no assumption that a restored token list is sound. */
|
|
export function validateRicheseNoField(state: RicheseNoField): void {
|
|
requireNoField(
|
|
state && Array.isArray(state.tokens) && state.tokens.length === 3,
|
|
'Richese must have exactly three No-Field tokens.',
|
|
);
|
|
requireNoField(
|
|
Array.from(state.tokens).every(
|
|
(token) => token && validId(token.id) && [0, 3, 5].includes(token.value),
|
|
) &&
|
|
new Set(state.tokens.map((token) => token.id)).size === 3 &&
|
|
new Set(state.tokens.map((token) => token.value)).size === 3,
|
|
'No-Field tokens need distinct identities and the values zero, three and five.',
|
|
);
|
|
requireNoField(
|
|
state.lastShipped === null ||
|
|
state.tokens.some((token) => token.id === state.lastShipped),
|
|
'The last shipped No-Field must be a physical token.',
|
|
);
|
|
requireNoField(
|
|
state.deployed === null ||
|
|
(typeof state.deployed === 'object' && !!state.deployed),
|
|
'Invalid deployed No-Field custody.',
|
|
);
|
|
requireNoField(state.hiddenReturn === undefined ||
|
|
(state.hiddenReturn === true && state.deployed === null && state.lastShipped !== null),
|
|
'A hidden No-Field return needs its previous shipment and no deployed token.');
|
|
if (state.deployed) {
|
|
requireNoField(
|
|
state.tokens.some((token) => token.id === state.deployed!.tokenId) &&
|
|
state.deployed.tokenId === state.lastShipped &&
|
|
validId(state.deployed.controller),
|
|
'The deployed No-Field must match the most recent shipment and a controller.',
|
|
);
|
|
validateLocation(state.deployed.location);
|
|
}
|
|
}
|
|
|
|
/** Engine supplies opaque identities; this pure constructor never draws RNG. */
|
|
export function createRicheseNoField(ids: readonly string[]): RicheseNoField {
|
|
const values: NoFieldValue[] = [0, 3, 5];
|
|
requireNoField(
|
|
ids.length === 3,
|
|
'Provide three physical No-Field identities.',
|
|
);
|
|
const state: RicheseNoField = {
|
|
tokens: ids.map((id, index) => ({ id, value: values[index] })),
|
|
lastShipped: null,
|
|
deployed: null,
|
|
};
|
|
validateRicheseNoField(state);
|
|
return state;
|
|
}
|
|
|
|
/** Engine resolves authorization, shipment legality, payment and Karama first. */
|
|
function validateDeployment(
|
|
previous: RicheseNoField,
|
|
deployment: NoFieldDeployment,
|
|
): void {
|
|
validateRicheseNoField(previous);
|
|
requireNoField(
|
|
!previous.deployed,
|
|
'Reveal the existing No-Field before deploying another.',
|
|
);
|
|
requireNoField(
|
|
previous.tokens.some((token) => token.id === deployment.tokenId),
|
|
'Choose a physical No-Field token.',
|
|
);
|
|
requireNoField(
|
|
deployment.tokenId !== previous.lastShipped,
|
|
'The same No-Field cannot be shipped twice in a row.',
|
|
);
|
|
requireNoField(
|
|
validId(deployment.controller),
|
|
'Choose the No-Field force controller.',
|
|
);
|
|
validateLocation(deployment.location);
|
|
}
|
|
|
|
export function deployRicheseNoField(
|
|
previous: RicheseNoField,
|
|
deployment: NoFieldDeployment,
|
|
): RicheseNoField {
|
|
validateDeployment(previous, deployment);
|
|
const state = structuredClone(previous);
|
|
delete state.hiddenReturn;
|
|
state.lastShipped = deployment.tokenId;
|
|
state.deployed = structuredClone(deployment);
|
|
return state;
|
|
}
|
|
|
|
/**
|
|
* Validate both identities and current reserves without allocating shipment
|
|
* state. The token reference belongs to the caller's validated input state.
|
|
*/
|
|
export function validateRicheseNoFieldPair(
|
|
previous: RicheseNoField,
|
|
concealed: NoFieldDeployment,
|
|
revealedTokenId: string,
|
|
reserves: number,
|
|
): NoFieldToken {
|
|
validateDeployment(previous, concealed);
|
|
requireNoField(
|
|
Number.isSafeInteger(reserves) && reserves >= 0,
|
|
'Available reserves must be a nonnegative safe integer.',
|
|
);
|
|
const token = previous.tokens.find((candidate) => candidate.id === revealedTokenId);
|
|
requireNoField(token && token.id !== concealed.tokenId,
|
|
'Choose a different physical No-Field token to reveal.');
|
|
requireNoField(token.id !== previous.lastShipped,
|
|
'The same No-Field cannot be shipped twice in a row.');
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Ship two distinct tokens for one shipment. Only the concealed token remains
|
|
* in marker custody; the other materializes immediately from available reserves.
|
|
* Engine owns payment, board placement and Karama resolution.
|
|
*/
|
|
export function deployRicheseNoFieldPair(
|
|
previous: RicheseNoField,
|
|
concealed: NoFieldDeployment,
|
|
revealedTokenId: string,
|
|
reserves: number,
|
|
): { state: RicheseNoField; revealed: NoFieldReveal } {
|
|
const token = validateRicheseNoFieldPair(previous, concealed, revealedTokenId, reserves);
|
|
const state = structuredClone(previous);
|
|
delete state.hiddenReturn;
|
|
state.lastShipped = concealed.tokenId;
|
|
state.deployed = structuredClone(concealed);
|
|
return {
|
|
state,
|
|
revealed: {
|
|
state,
|
|
tokenId: token.id,
|
|
controller: concealed.controller,
|
|
location: structuredClone(concealed.location),
|
|
value: token.value,
|
|
forces: Math.min(token.value, reserves),
|
|
cause: 'voluntary',
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Location/custody only; path, movement allowance and exposure belong to engine. */
|
|
export function moveRicheseNoField(
|
|
previous: RicheseNoField,
|
|
tokenId: string,
|
|
location: NoFieldLocation,
|
|
): RicheseNoField {
|
|
validateRicheseNoField(previous);
|
|
requireNoField(
|
|
previous.deployed?.tokenId === tokenId,
|
|
'That No-Field is not deployed.',
|
|
);
|
|
validateLocation(location);
|
|
const state = structuredClone(previous);
|
|
state.deployed!.location = structuredClone(location);
|
|
return state;
|
|
}
|
|
|
|
/**
|
|
* Returns materialization facts, never changes reserves/Tanks or allocates losses.
|
|
* Engine validates the cause's phase/window. Destruction with scarce reserves is
|
|
* conservation-based composition, recorded in docs/RICHESE_NO_FIELD_RULES.md.
|
|
*/
|
|
export function revealRicheseNoField(
|
|
previous: RicheseNoField,
|
|
input: { tokenId: string; reserves: number; cause: NoFieldRevealCause },
|
|
): NoFieldReveal {
|
|
validateRicheseNoField(previous);
|
|
requireNoField(
|
|
previous.deployed?.tokenId === input.tokenId,
|
|
'That No-Field is not deployed.',
|
|
);
|
|
requireNoField(
|
|
Number.isSafeInteger(input.reserves) && input.reserves >= 0,
|
|
'Available reserves must be a nonnegative safe integer.',
|
|
);
|
|
requireNoField(
|
|
[
|
|
'voluntary',
|
|
'battle',
|
|
'storm',
|
|
'worm',
|
|
'gamont',
|
|
'beforeAlly',
|
|
'allyShipment',
|
|
'atomics',
|
|
'alliedSeparation',
|
|
'discoveryBlow',
|
|
].includes(input.cause),
|
|
'Choose a valid No-Field reveal cause.',
|
|
);
|
|
const deployed = previous.deployed!;
|
|
const token = previous.tokens.find(
|
|
(candidate) => candidate.id === input.tokenId,
|
|
)!;
|
|
const state = structuredClone(previous);
|
|
delete state.hiddenReturn;
|
|
state.deployed = null;
|
|
return {
|
|
state,
|
|
tokenId: token.id,
|
|
controller: deployed.controller,
|
|
location: structuredClone(deployed.location),
|
|
value: token.value,
|
|
forces: Math.min(token.value, input.reserves),
|
|
cause: input.cause,
|
|
};
|
|
}
|
|
|
|
/** Provisional Ecaz Betrayal / authorized Guild-return composition: return face down, without
|
|
* revealing or materializing its denomination. The last-shipment ban remains. */
|
|
export function recallRicheseNoField(previous: RicheseNoField, tokenId: string): RicheseNoField {
|
|
validateRicheseNoField(previous);
|
|
requireNoField(previous.deployed?.tokenId === tokenId, 'That No-Field is not deployed.');
|
|
return {...structuredClone(previous), deployed:null, hiddenReturn:true};
|
|
}
|
|
|
|
/** Any existing marker must already have been revealed by the authorized caller. */
|
|
export function shipAlliedRicheseNoField(
|
|
previous: RicheseNoField,
|
|
deployment: NoFieldDeployment,
|
|
reserves: number,
|
|
): NoFieldReveal {
|
|
const deployed = deployRicheseNoField(previous, deployment);
|
|
return revealRicheseNoField(deployed, {
|
|
tokenId: deployment.tokenId,
|
|
reserves,
|
|
cause: 'allyShipment',
|
|
});
|
|
}
|
|
|
|
export function projectRicheseNoField(
|
|
state: RicheseNoField,
|
|
owner: true,
|
|
): RicheseNoFieldOwnerView;
|
|
export function projectRicheseNoField(
|
|
state: RicheseNoField,
|
|
owner: false,
|
|
): RicheseNoFieldView;
|
|
export function projectRicheseNoField(
|
|
state: RicheseNoField,
|
|
owner: boolean,
|
|
): RicheseNoFieldView | RicheseNoFieldOwnerView;
|
|
export function projectRicheseNoField(
|
|
state: RicheseNoField,
|
|
owner: boolean,
|
|
): RicheseNoFieldView | RicheseNoFieldOwnerView {
|
|
validateRicheseNoField(state);
|
|
const view: RicheseNoFieldView = {
|
|
deployed: state.deployed
|
|
? {
|
|
controller: state.deployed.controller,
|
|
location: structuredClone(state.deployed.location),
|
|
effectiveForces: 1,
|
|
}
|
|
: null,
|
|
lastUsed:
|
|
!state.deployed && !state.hiddenReturn && state.lastShipped !== null
|
|
? state.tokens.find((token) => token.id === state.lastShipped)!.value
|
|
: null,
|
|
};
|
|
return owner ? { ...view, private: structuredClone(state) } : view;
|
|
}
|