145 lines
4.7 KiB
TypeScript
145 lines
4.7 KiB
TypeScript
import { FACTIONS, type FactionId } from './catalog';
|
|
import { reserveShipmentCost } from './shipment-price';
|
|
|
|
export type NexusRicheseContext = {
|
|
turn: number;
|
|
players: readonly { id: string; faction: FactionId }[];
|
|
};
|
|
export type NexusRicheseReceipt = {
|
|
version: 1;
|
|
event: string;
|
|
owner: string;
|
|
turn: number;
|
|
phase: 5;
|
|
mode: 'secretAlly';
|
|
roster: { id: string; faction: FactionId }[];
|
|
signature: string;
|
|
};
|
|
export type NexusRicheseShipmentQuote = {
|
|
physicalAmount: number;
|
|
pricedAmount: 1;
|
|
cost: number;
|
|
source: 'nexusRichese';
|
|
};
|
|
function requireRichese(value: unknown, message: string): asserts value {
|
|
if (!value) throw new Error(message);
|
|
}
|
|
const faction = (value: unknown): value is FactionId =>
|
|
FACTIONS.some((candidate) => candidate.id === value);
|
|
const identifier = (value: unknown): value is string =>
|
|
typeof value === 'string' &&
|
|
value.trim().length > 0 &&
|
|
!['__proto__', 'prototype', 'constructor'].includes(value);
|
|
const plain = (value: unknown): value is Record<string, unknown> =>
|
|
!!value &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
[Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
|
|
/** Only the price basis changes. The engine validates actual typed reserve
|
|
* custody, destination, native Fremen geography, payment and arrival timing. */
|
|
export function quoteNexusRicheseShipment(
|
|
player: { faction: FactionId; halfRate: boolean },
|
|
territoryType: string,
|
|
amount: number,
|
|
): NexusRicheseShipmentQuote {
|
|
requireRichese(
|
|
player &&
|
|
faction(player.faction) &&
|
|
player.faction !== 'richese' &&
|
|
typeof player.halfRate === 'boolean' &&
|
|
['stronghold', 'sand', 'rock', 'polar'].includes(territoryType) &&
|
|
Number.isSafeInteger(amount) &&
|
|
amount >= 1 &&
|
|
amount <= 5,
|
|
'Richese Secret Ally needs one to five physical reserve forces and a valid ordinary shipment price.',
|
|
);
|
|
return {
|
|
physicalAmount: amount,
|
|
pricedAmount: 1,
|
|
cost: reserveShipmentCost(player, territoryType, 1),
|
|
source: 'nexusRichese',
|
|
};
|
|
}
|
|
function roster(context: NexusRicheseContext) {
|
|
requireRichese(
|
|
context &&
|
|
Number.isSafeInteger(context.turn) &&
|
|
context.turn > 0 &&
|
|
Array.isArray(context.players) &&
|
|
context.players.length > 0 &&
|
|
context.players.every(
|
|
(p) => p && identifier(p.id) && faction(p.faction),
|
|
) &&
|
|
new Set(context.players.map((p) => p.id)).size ===
|
|
context.players.length &&
|
|
new Set(context.players.map((p) => p.faction)).size ===
|
|
context.players.length &&
|
|
!context.players.some((p) => p.faction === 'richese'),
|
|
'Richese Secret Ally needs a valid original faction roster with Richese absent.',
|
|
);
|
|
return context.players
|
|
.map(({ id, faction }) => ({ id, faction }))
|
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
}
|
|
function signature(receipt: NexusRicheseReceipt) {
|
|
return JSON.stringify([
|
|
'nexusRicheseReceipt',
|
|
receipt.version,
|
|
receipt.event,
|
|
receipt.owner,
|
|
receipt.turn,
|
|
receipt.phase,
|
|
receipt.mode,
|
|
receipt.roster.map((p) => [p.id, p.faction]),
|
|
]);
|
|
}
|
|
/** Historical evidence stays valid after later card use, alliances and turns.
|
|
* Signatures establish consistency, not cryptographic authentication. */
|
|
export function validateNexusRichese(
|
|
context: NexusRicheseContext,
|
|
receipt: NexusRicheseReceipt,
|
|
): void {
|
|
const seats = roster(context);
|
|
requireRichese(
|
|
plain(receipt) &&
|
|
Object.keys(receipt).sort().join(',') ===
|
|
'event,mode,owner,phase,roster,signature,turn,version' &&
|
|
receipt.version === 1 &&
|
|
receipt.phase === 5 &&
|
|
receipt.mode === 'secretAlly' &&
|
|
Number.isSafeInteger(receipt.turn) &&
|
|
receipt.turn > 0 &&
|
|
receipt.turn <= context.turn &&
|
|
identifier(receipt.owner) &&
|
|
seats.some((p) => p.id === receipt.owner) &&
|
|
receipt.event ===
|
|
JSON.stringify(['nexusRichese', receipt.turn, receipt.owner]) &&
|
|
Array.isArray(receipt.roster) &&
|
|
receipt.roster.every(
|
|
(p) => plain(p) && Object.keys(p).sort().join(',') === 'faction,id',
|
|
) &&
|
|
JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
|
|
receipt.signature === signature(receipt),
|
|
'Richese Secret Ally has lost its original owner, turn, phase or faction roster.',
|
|
);
|
|
}
|
|
export function createNexusRichese(
|
|
context: NexusRicheseContext,
|
|
owner: string,
|
|
): NexusRicheseReceipt {
|
|
const receipt: NexusRicheseReceipt = {
|
|
version: 1,
|
|
event: JSON.stringify(['nexusRichese', context.turn, owner]),
|
|
owner,
|
|
turn: context.turn,
|
|
phase: 5,
|
|
mode: 'secretAlly',
|
|
roster: roster(context),
|
|
signature: '',
|
|
};
|
|
receipt.signature = signature(receipt);
|
|
validateNexusRichese(context, receipt);
|
|
return receipt;
|
|
}
|