124 lines
4.7 KiB
TypeScript
124 lines
4.7 KiB
TypeScript
import { FACTIONS, type FactionId } from './catalog';
|
|
import { guildShipmentCost } from './shipment-price';
|
|
|
|
export type NexusGuildSecretAllyContext = {
|
|
turn: number;
|
|
players: readonly { id: string; faction: FactionId }[];
|
|
};
|
|
export type NexusGuildSecretAllyReceipt = {
|
|
version: 1;
|
|
event: string;
|
|
owner: string;
|
|
turn: number;
|
|
phase: 5;
|
|
mode: 'secretAlly';
|
|
roster: { id: string; faction: FactionId }[];
|
|
signature: string;
|
|
};
|
|
function requireGuild(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));
|
|
|
|
/** Amount is billable units; a selected concealed marker contributes one unit
|
|
* but no physical counter. The caller validates permission and actual custody. */
|
|
export function quoteNexusGuildSecretShipment(destinationType: string, amount: number, noField = false) {
|
|
requireGuild(['stronghold','sand','rock','polar','reserves','homeworld'].includes(destinationType) &&
|
|
typeof noField === 'boolean' && (!noField || destinationType !== 'homeworld') &&
|
|
Number.isSafeInteger(amount) && amount > 0 && amount <= (noField ? 21 : 20),
|
|
noField ? 'Guild Secret Ally requires a valid destination, one concealed marker and up to twenty physical forces.'
|
|
: 'Guild Secret Ally requires a valid destination and one to twenty physical forces.');
|
|
return {physicalAmount:amount - (noField ? 1 : 0),cost:guildShipmentCost(destinationType === 'homeworld' ? 'reserves' : destinationType,amount),source:'nexusGuildSecretAlly' as const};
|
|
}
|
|
function roster(context: NexusGuildSecretAllyContext) {
|
|
requireGuild(
|
|
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 === 'guild'),
|
|
'Guild Secret Ally needs a valid original faction roster with the native Guild absent.',
|
|
);
|
|
return context.players
|
|
.map(({ id, faction }) => ({ id, faction }))
|
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
}
|
|
function signature(receipt: NexusGuildSecretAllyReceipt) {
|
|
return JSON.stringify([
|
|
'nexusGuildSecretAllyReceipt',
|
|
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 validateNexusGuildSecretAlly(
|
|
context: NexusGuildSecretAllyContext,
|
|
receipt: NexusGuildSecretAllyReceipt,
|
|
): void {
|
|
const seats = roster(context);
|
|
requireGuild(
|
|
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(['nexusGuildSecretAlly', 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),
|
|
'Guild Secret Ally has lost its original owner, turn, phase or faction roster.',
|
|
);
|
|
}
|
|
export function createNexusGuildSecretAlly(
|
|
context: NexusGuildSecretAllyContext,
|
|
owner: string,
|
|
): NexusGuildSecretAllyReceipt {
|
|
const receipt: NexusGuildSecretAllyReceipt = {
|
|
version: 1,
|
|
event: JSON.stringify(['nexusGuildSecretAlly', context.turn, owner]),
|
|
owner,
|
|
turn: context.turn,
|
|
phase: 5,
|
|
mode: 'secretAlly',
|
|
roster: roster(context),
|
|
signature: '',
|
|
};
|
|
receipt.signature = signature(receipt);
|
|
validateNexusGuildSecretAlly(context, receipt);
|
|
return receipt;
|
|
}
|