Files
2D6-Dungeon/src/rules/tables.ts
Keith Solomon 6bf48df74c Feature: add character creation logic and tests
- Implement character creation functions to handle adventurer setup.
- Add validation for adventurer name and selection of starting items.
- Introduce new items in sample content pack: Flint and Steel, Pouch, Wax Sealing Kit, and Backpack.
- Create tests for character creation functionality to ensure valid options and error handling.

Feature: implement dice rolling mechanics and tests

- Add functions for rolling various dice types (d3, d6, 2d6, d66) with validation.
- Implement modifier application with clamping for roll results.
- Create tests to verify correct behavior of dice rolling functions.

Feature: add table lookup functionality and tests

- Implement table lookup and resolution logic for defined tables.
- Support for modified ranges and fallback to nearest entries.
- Create tests to ensure correct table entry resolution based on roll results.
2026-03-15 11:59:50 -05:00

111 lines
3.0 KiB
TypeScript

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);
}