Files
Dune/game/charity-order.ts
T

79 lines
4.1 KiB
TypeScript

/** Native Charity follows the actual First Player order, one opportunity per seat. */
export type CharityOrder = {
version: 1;
turn: number;
order: string[];
remaining: string[];
};
export type CharityOrderContext = {
turn: number;
order: readonly string[];
players: readonly { id: string }[];
ready: readonly string[];
};
export class CharityOrderError extends Error {
constructor(message: string) {
super(message);
this.name = 'CharityOrderError';
}
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value) &&
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
const id = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
const ids = (value: unknown): value is readonly string[] => Array.isArray(value) &&
[...value].every(id) && new Set(value).size === value.length;
const same = (left: readonly string[], right: readonly string[]) =>
left.length === right.length && left.every((value, index) => value === right[index]);
function requireOrder(condition: unknown, message: string): asserts condition {
if (!condition) throw new CharityOrderError(message);
}
function validateContext(context: CharityOrderContext): void {
requireOrder(record(context) && Number.isSafeInteger(context.turn) && context.turn > 0 &&
Array.isArray(context.players) && context.players.length >= 2 && context.players.length <= 6 &&
[...context.players].every(player => record(player) && id(player.id)) && ids(context.order),
'Charity needs a current turn and two through six actual seats in public order.');
const seats = context.players.map(player => player.id);
requireOrder(new Set(seats).size === seats.length && context.order.length === seats.length &&
context.order.every(player => seats.includes(player)),
'Charity order must contain every actual seat exactly once.');
requireOrder(ids(context.ready) && context.ready.length <= context.order.length &&
same(context.ready, context.order.slice(0, context.ready.length)),
'Completed Charity opportunities must be the exact public-order prefix.');
}
export function createCharityOrder(context: CharityOrderContext): CharityOrder {
validateContext(context);
return { version: 1, turn: context.turn, order: [...context.order],
remaining: context.order.slice(context.ready.length) };
}
export function validateCharityOrder(context: CharityOrderContext, frame: CharityOrder): void {
validateContext(context);
requireOrder(record(frame) && Object.keys(frame).sort().join(',') === 'order,remaining,turn,version' &&
frame.version === 1 && frame.turn === context.turn && ids(frame.order) && ids(frame.remaining),
'The current Charity order is malformed or belongs to another turn.');
requireOrder(same(frame.order, context.order) &&
same(frame.remaining, context.order.slice(context.ready.length)),
'Charity must retain its original public order and exact remaining suffix.');
}
export function charityOrderBlock(context: CharityOrderContext, frame: CharityOrder, player: string): string | null {
validateCharityOrder(context, frame);
requireOrder(id(player), 'Choose a valid Charity participant.');
if (!frame.order.includes(player)) return 'Only seated factions have a Charity opportunity.';
if (!frame.remaining.length) return 'Charity opportunities are complete.';
if (context.ready.includes(player)) return 'Your Charity opportunity has finished.';
return frame.remaining[0] === player ? null : 'Wait for your Charity opportunity.';
}
/** Consume the pre-finish ready prefix. The caller appends this actor to ready
* with the native claim/pass in the same commit; no wallet or card changes occur here.
*/
export function finishCharityOrder(context: CharityOrderContext, frame: CharityOrder, player: string): CharityOrder {
const blocked = charityOrderBlock(context, frame, player);
requireOrder(blocked === null, blocked ?? 'This Charity opportunity is unavailable.');
return { version: 1, turn: frame.turn, order: [...frame.order], remaining: frame.remaining.slice(1) };
}