import type { TableDefinition, TableEntry } from "@/types/content"; import type { RollResult } from "@/types/rules"; import { applyRollModifier, rollDice, type DiceRoller } from "./dice"; export type TableLookupResult = { tableId: string; entryKey: string; roll: RollResult; entry: TableEntry; }; function getNumericEntryValue(entry: TableEntry) { if (entry.d66 !== undefined) { return entry.d66; } if (entry.exact !== undefined) { return entry.exact; } return undefined; } function getEntryBounds(entries: TableEntry[]) { const values = entries .flatMap((entry) => { if (entry.min !== undefined && entry.max !== undefined) { return [entry.min, entry.max]; } const exactValue = getNumericEntryValue(entry); return exactValue !== undefined ? [exactValue] : []; }) .sort((a, b) => a - b); if (values.length === 0) { throw new Error("Cannot compute table bounds for an empty numeric table."); } return { min: values[0], max: values[values.length - 1] }; } function matchesEntry(entry: TableEntry, lookupValue: number) { if (entry.min !== undefined && entry.max !== undefined) { return lookupValue >= entry.min && lookupValue <= entry.max; } const exactValue = getNumericEntryValue(entry); return exactValue === lookupValue; } function findNearestEntry(entries: TableEntry[], lookupValue: number) { return entries .map((entry) => ({ entry, value: getNumericEntryValue(entry) })) .filter((candidate): candidate is { entry: TableEntry; value: number } => { return candidate.value !== undefined; }) .sort((left, right) => { const distanceDelta = Math.abs(left.value - lookupValue) - Math.abs(right.value - lookupValue); return distanceDelta !== 0 ? distanceDelta : left.value - right.value; })[0]?.entry; } export function resolveTableEntry( table: TableDefinition, roll: RollResult, ): TableLookupResult { const lookupValue = roll.modifiedTotal ?? roll.total; if (lookupValue === undefined) { throw new Error(`Cannot resolve table ${table.code} without a roll total.`); } const matchingEntry = table.entries.find((entry) => matchesEntry(entry, lookupValue)); const fallbackEntry = matchingEntry ?? findNearestEntry(table.entries, lookupValue); if (!fallbackEntry) { throw new Error(`No table entry found for ${table.code} with value ${lookupValue}.`); } return { tableId: table.id, entryKey: fallbackEntry.key, roll, entry: fallbackEntry, }; } export function lookupTable( table: TableDefinition, options?: { modifier?: number; roller?: DiceRoller; }, ): TableLookupResult { const rawRoll = rollDice(table.diceKind, options?.roller); const roll = options?.modifier !== undefined ? applyRollModifier( rawRoll, options.modifier, table.usesModifiedRangesRule ? getEntryBounds(table.entries) : undefined, ) : rawRoll; return resolveTableEntry(table, roll); }