120 lines
5.8 KiB
TypeScript
120 lines
5.8 KiB
TypeScript
import { FACTIONS, type FactionId } from './catalog';
|
|
|
|
/** Public prediction-card order and completed faction choices. No hidden deck
|
|
* or session credential is stored here; the host deals once before selection. */
|
|
export type FactionDraft = {
|
|
event: string;
|
|
order: { player: string; card: number }[];
|
|
picks: { player: string; faction: FactionId }[];
|
|
};
|
|
|
|
export class FactionDraftError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'FactionDraftError';
|
|
}
|
|
}
|
|
const record = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
const identifier = (value: unknown): value is string =>
|
|
typeof value === 'string' && value.length > 0 && value === value.trim();
|
|
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
|
Object.keys(value).sort().join(',') === expected;
|
|
const factionId = (value: unknown): value is FactionId =>
|
|
typeof value === 'string' && FACTIONS.some(faction => faction.id === value);
|
|
function requireDraft(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new FactionDraftError(message);
|
|
}
|
|
function validateSeats(seats: readonly string[]): void {
|
|
requireDraft(Array.isArray(seats) && seats.length >= 2 && seats.length <= 6 &&
|
|
seats.every(identifier) && new Set(seats).size === seats.length,
|
|
'A faction draft needs two through six distinct seated players.');
|
|
}
|
|
function validateEligible(factions: readonly FactionId[], seats: readonly string[]): void {
|
|
requireDraft(Array.isArray(factions) && factions.length >= seats.length &&
|
|
factions.length <= FACTIONS.length && factions.every(factionId) &&
|
|
new Set(factions).size === factions.length,
|
|
'The draft needs enough distinct enabled factions for every seat.');
|
|
}
|
|
|
|
/** numberCards are the unique prediction numbers dealt in seat order. */
|
|
export function createFactionDraft(
|
|
seatIds: readonly string[], numberCards: readonly number[], event: string,
|
|
): FactionDraft {
|
|
validateSeats(seatIds);
|
|
requireDraft(identifier(event), 'The faction draft needs a distinct event.');
|
|
requireDraft(Array.isArray(numberCards) && numberCards.length === seatIds.length &&
|
|
numberCards.every(card => Number.isSafeInteger(card) && card >= 1 && card <= 10) &&
|
|
new Set(numberCards).size === numberCards.length,
|
|
'Deal one distinct prediction number from 1 through 10 to each seat.');
|
|
return {
|
|
event,
|
|
order: seatIds.map((player, index) => ({ player, card: numberCards[index] }))
|
|
.sort((left, right) => right.card - left.card),
|
|
picks: [],
|
|
};
|
|
}
|
|
|
|
/** Validate a saved public draft against its actual seats and allowed faction
|
|
* pool. Picks are a prefix of the immutable descending-number order. */
|
|
export function validateFactionDraft(
|
|
value: unknown, seatIds: readonly string[], eligibleFactions: readonly FactionId[],
|
|
): asserts value is FactionDraft {
|
|
validateSeats(seatIds);
|
|
validateEligible(eligibleFactions, seatIds);
|
|
requireDraft(record(value) && keys(value, 'event,order,picks') && identifier(value.event) &&
|
|
Array.isArray(value.order) && value.order.length === seatIds.length && Array.isArray(value.picks) &&
|
|
value.picks.length <= seatIds.length,
|
|
'The saved faction draft is malformed.');
|
|
const order = value.order;
|
|
const selected = new Set<string>();
|
|
const dealt = new Set<number>();
|
|
for (const [index, entry] of order.entries()) {
|
|
requireDraft(record(entry) && keys(entry, 'card,player') && identifier(entry.player) &&
|
|
seatIds.includes(entry.player) && !selected.has(entry.player) &&
|
|
Number.isSafeInteger(entry.card) && (entry.card as number) >= 1 && (entry.card as number) <= 10 &&
|
|
!dealt.has(entry.card as number) &&
|
|
(index === 0 || (order[index - 1] as { card: number }).card > (entry.card as number)),
|
|
'The faction draft must preserve distinct seated players and descending dealt numbers.');
|
|
selected.add(entry.player);
|
|
dealt.add(entry.card as number);
|
|
}
|
|
const factions = new Set<string>();
|
|
for (const [index, pick] of value.picks.entries()) {
|
|
requireDraft(record(pick) && keys(pick, 'faction,player') &&
|
|
pick.player === (order[index] as { player: string }).player && factionId(pick.faction) &&
|
|
eligibleFactions.includes(pick.faction) && !factions.has(pick.faction),
|
|
'Faction choices must follow draft order and use distinct enabled factions.');
|
|
factions.add(pick.faction);
|
|
}
|
|
}
|
|
|
|
/** A validated completed draft has no current picker. */
|
|
export function currentFactionDrafter(draft: FactionDraft): string | null {
|
|
requireDraft(record(draft) && Array.isArray(draft.order) && Array.isArray(draft.picks),
|
|
'The saved faction draft is malformed.');
|
|
const seats = draft.order.map(entry => record(entry) ? entry.player : undefined);
|
|
requireDraft(seats.every(identifier), 'The saved faction draft has invalid seats.');
|
|
validateFactionDraft(draft, seats, FACTIONS.map(faction => faction.id));
|
|
return draft.order[draft.picks.length]?.player ?? null;
|
|
}
|
|
|
|
/** An accepted choice appends exactly once; the caller commits the returned
|
|
* snapshot through the same authoritative version fence as other actions. */
|
|
export function chooseFaction(
|
|
draft: FactionDraft, seatIds: readonly string[], eligibleFactions: readonly FactionId[],
|
|
actor: string, faction: FactionId,
|
|
): FactionDraft {
|
|
validateFactionDraft(draft, seatIds, eligibleFactions);
|
|
requireDraft(identifier(actor) && currentFactionDrafter(draft) === actor,
|
|
'Only the current faction drafter may choose.');
|
|
requireDraft(factionId(faction) && eligibleFactions.includes(faction) &&
|
|
!draft.picks.some(pick => pick.faction === faction),
|
|
'Choose an enabled faction that has not already been selected.');
|
|
return {
|
|
event: draft.event,
|
|
order: draft.order.map(entry => ({ ...entry })),
|
|
picks: [...draft.picks.map(pick => ({ ...pick })), { player: actor, faction }],
|
|
};
|
|
}
|