Files
Dune/game/nexus-guild-cunning.ts
T

174 lines
5.7 KiB
TypeScript

import { FACTIONS, type FactionId } from './catalog';
export type NexusGuildCunningContext = {
turn: number;
players: readonly { id: string; faction: FactionId; ally?: string | null }[];
};
export type NexusGuildCunningBefore = {
shipped: boolean;
moved: number;
hajrUsed: boolean;
};
export type NexusGuildCunningReceipt = {
version: 1;
event: string;
owner: string;
turn: number;
phase: 5;
mode: 'cunning';
roster: { id: string; faction: FactionId }[];
before: NexusGuildCunningBefore;
signature: string;
};
function requireGuild(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' &&
value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].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));
function roster(context: NexusGuildCunningContext) {
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) && FACTIONS.some((f) => f.id === 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,
'Guild Cunning requires its original faction roster and turn.',
);
return context.players
.map(({ id, faction }) => ({ id, faction }))
.sort((a, b) => a.id.localeCompare(b.id));
}
function validateBefore(before: NexusGuildCunningBefore) {
requireGuild(
plain(before) &&
Object.keys(before).sort().join(',') === 'hajrUsed,moved,shipped' &&
typeof before.shipped === 'boolean' &&
typeof before.hajrUsed === 'boolean' &&
Number.isSafeInteger(before.moved) &&
before.moved >= 0 &&
before.moved <= (before.hajrUsed ? 2 : 1),
'Guild Cunning needs the original ordinary shipment and Hajr movement allowance.',
);
}
function signature(receipt: NexusGuildCunningReceipt) {
return JSON.stringify([
'nexusGuildCunningReceipt',
receipt.version,
receipt.event,
receipt.owner,
receipt.turn,
receipt.phase,
receipt.mode,
receipt.roster.map((p) => [p.id, p.faction]),
[receipt.before.shipped, receipt.before.moved, receipt.before.hajrUsed],
]);
}
/** Validates historical facts, not current cards, alliances, resources or movement.
* The engine binds this receipt to its actual completed first combined turn. */
export function validateNexusGuildCunning(
context: NexusGuildCunningContext,
receipt: NexusGuildCunningReceipt,
): void {
const seats = roster(context);
requireGuild(
plain(receipt) &&
Object.keys(receipt).sort().join(',') ===
'before,event,mode,owner,phase,roster,signature,turn,version' &&
receipt.version === 1 &&
receipt.phase === 5 &&
receipt.mode === 'cunning' &&
Number.isSafeInteger(receipt.turn) &&
receipt.turn > 0 &&
receipt.turn <= context.turn &&
identifier(receipt.owner) &&
seats.some((p) => p.id === receipt.owner && p.faction === 'guild') &&
receipt.event ===
JSON.stringify(['nexusGuildCunning', 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),
'Guild Cunning has changed its original owner, turn or faction roster.',
);
validateBefore(receipt.before);
requireGuild(
receipt.signature === signature(receipt),
'Guild Cunning has changed its original combined turn.',
);
}
export function createNexusGuildCunning(
context: NexusGuildCunningContext & { phase: number; active: string | null },
owner: string,
before: NexusGuildCunningBefore,
): NexusGuildCunningReceipt {
const seats = roster(context);
requireGuild(
context.phase === 5 &&
context.active === owner &&
context.players.some(
(p) => p.id === owner && p.faction === 'guild' && !p.ally,
),
'Guild Cunning requires the unallied native Guild at the end of its own combined turn.',
);
validateBefore(before);
const receipt: NexusGuildCunningReceipt = {
version: 1,
event: JSON.stringify(['nexusGuildCunning', context.turn, owner]),
owner,
turn: context.turn,
phase: 5,
mode: 'cunning',
roster: seats,
before: { ...before },
signature: '',
};
receipt.signature = signature(receipt);
validateNexusGuildCunning(context, receipt);
return receipt;
}
/** Ending ordinary movement forgoes at least its first slot. Hajr supplies only
* the still-unused extra slot, whether played before or after the shipment. */
export function nexusGuildCunningMoves(
receipt: NexusGuildCunningReceipt,
currentHajrUsed: boolean,
movesAfterSecond = 0,
): 0 | 1 {
validateNexusGuildCunning(
{ turn: receipt.turn, players: receipt.roster },
receipt,
);
requireGuild(
typeof currentHajrUsed === 'boolean' &&
(!receipt.before.hajrUsed || currentHajrUsed) &&
Number.isSafeInteger(movesAfterSecond) &&
movesAfterSecond >= 0 &&
movesAfterSecond <= 1,
'Guild Cunning has changed its remaining Hajr movement.',
);
const available = currentHajrUsed
? Math.max(0, 2 - Math.max(1, receipt.before.moved))
: 0;
requireGuild(
movesAfterSecond <= available,
'Guild Cunning cannot supply another ordinary movement.',
);
return (available - movesAfterSecond) as 0 | 1;
}