working through a review

This commit is contained in:
itsamejms
2026-08-09 13:49:54 +01:00
parent abf3668081
commit 65aee44d6b
21 changed files with 1134 additions and 259 deletions
+101
View File
@@ -0,0 +1,101 @@
// ponytail: dice notation parsing + advantage/disadvantage, extracted from
// DiceRoller so the math is testable without the component. Pure functions,
// no React, no DOM.
export type Mode = "normal" | "adv" | "dis";
export interface ParsedNotation {
count: number;
sides: number;
modifier: number;
}
// ponytail: one rolled result, shaped for the history list + breakdown view.
// Re-exported by DiceRoller so the component owns the display but not the
// shape.
export interface DieResult {
notation: string;
rolls: number[];
total: number;
modifier: number;
advantage?: Mode | null;
keptRoll?: number;
}
/**
* Parse a dice notation string: `[count]d<sides>[+/-modifier]`.
* Case-insensitive, trims whitespace. Returns null on a bad expression.
* Examples: `d20`, `1d20`, `2d6+3`, `d100-2`, `4d8`.
*/
export function parseNotation(
notation: string,
): ParsedNotation | null {
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
if (!match) return null;
return {
count: parseInt(match[1] || "1"),
sides: parseInt(match[2]),
modifier: parseInt(match[3] || "0"),
};
}
/**
* Apply advantage/disadvantage to a 2-roll d20 set. For non-d20 or non-pair
* rolls, the first roll is kept unchanged. Returns the rolls to display plus
* the single value to add to the modifier.
*/
export function applyMode(
rolls: number[],
sides: number,
mode: Mode,
): { rolls: number[]; kept: number } {
if (mode === "normal" || sides !== 20 || rolls.length !== 2) {
return { rolls, kept: rolls[0] ?? 0 };
}
if (mode === "adv") {
return { rolls, kept: Math.max(rolls[0], rolls[1]) };
}
return { rolls, kept: Math.min(rolls[0], rolls[1]) };
}
// ─── Self-check (run: node scripts/check-dice.ts) ──────────────
// ponytail: the smallest thing that fails if the parser breaks. No framework
// — plain asserts. Covers the notations a DM types every session plus the
// adv/dis keep rule and the boundary cases that silently break (negative
// mods, bare `d20`, `d100`, rejects garbage).
function assert(cond: boolean, msg: string): void {
if (!cond) throw new Error(`dice self-check failed: ${msg}`);
}
export function demo(): void {
// Basic shapes.
assert(parseNotation("d20")!.count === 1, "bare d20 → count 1");
assert(parseNotation("1d20")!.sides === 20, "1d20 sides");
assert(parseNotation("2d6+3")!.count === 2, "2d6+3 count");
assert(parseNotation("2d6+3")!.sides === 6, "2d6+3 sides");
assert(parseNotation("2d6+3")!.modifier === 3, "2d6+3 mod +3");
assert(parseNotation("1d20-2")!.modifier === -2, "negative modifier");
assert(parseNotation("d100")!.sides === 100, "d100 sides");
// Case + whitespace tolerance.
assert(parseNotation(" 4D8 ")!.count === 4, "uppercase + whitespace");
assert(parseNotation("3d8-5")!.modifier === -5, "3d8-5 mod -5");
// Garbage rejected.
assert(parseNotation("hello") === null, "words rejected");
assert(parseNotation("2d") === null, "missing sides rejected");
assert(parseNotation("d20+abc") === null, "non-numeric mod rejected");
assert(parseNotation("") === null, "empty rejected");
// Advantage keeps the higher of two d20s; disadvantage the lower.
assert(applyMode([5, 17], 20, "adv").kept === 17, "adv keeps high");
assert(applyMode([5, 17], 20, "dis").kept === 5, "dis keeps low");
// Non-d20 rolls ignore mode entirely.
assert(applyMode([4, 6], 6, "adv").kept === 4, "non-d20 adv keeps first");
// Single roll (no comparison possible) keeps the first.
assert(applyMode([12], 20, "adv").kept === 12, "single roll keeps first");
// Normal mode keeps the first regardless of count.
assert(applyMode([8, 15], 20, "normal").kept === 8, "normal keeps first");
console.log("dice self-check passed ✓");
}
+42
View File
@@ -62,3 +62,45 @@ export const DIFFICULTY_COLOR: Record<Difficulty, string> = {
Hard: "var(--color-gold-bright)",
Deadly: "var(--color-danger)",
};
// ─── Self-check (run: node scripts/check-encounter-budget.ts) ────
// ponytail: the smallest thing that fails if the budget math breaks. No
// framework — plain asserts. Verifies the per-level table scales, the
// party-size multiplier, level clamping, and the XP→difficulty bucketing
// (the boundary the DMG relies on: a budget-equal XP is the *next* tier).
function assert(cond: boolean, msg: string): void {
if (!cond) throw new Error(`encounter-budget self-check failed: ${msg}`);
}
export function demo(): void {
// Level 5, 4 PCs — DMG p.82 per-character: Easy 250 / Med 500 / Hard 750 / Deadly 1100.
const b = encounterBudget(5, 4);
assert(b.easy === 1000, "L5×4 easy = 250×4");
assert(b.medium === 2000, "L5×4 medium = 500×4");
assert(b.hard === 3000, "L5×4 hard = 750×4");
assert(b.deadly === 4400, "L5×4 deadly = 1100×4");
// Solo L1 character.
const solo = encounterBudget(1, 1);
assert(solo.easy === 25 && solo.deadly === 100, "L1×1 matches table");
// Level clamps to 1..20; size clamps to >=1.
const low = encounterBudget(0, 1);
assert(low.easy === 25, "level clamps up to 1");
const high = encounterBudget(99, 1);
assert(high.deadly === 12700, "level clamps down to 20");
const zero = encounterBudget(5, 0);
assert(zero.medium === 500, "size clamps up to 1");
// difficultyForXp: budget-equal XP lands in the *next* tier up (>=, not >),
// and anything below the easy floor is still Easy (never undefined).
const d = encounterBudget(5, 4);
assert(difficultyForXp(999, d) === "Easy", "below easy floor stays Easy");
assert(difficultyForXp(1000, d) === "Easy", "== easy budget is Easy");
assert(difficultyForXp(2000, d) === "Medium", "== medium budget is Medium");
assert(difficultyForXp(3000, d) === "Hard", "== hard budget is Hard");
assert(difficultyForXp(4400, d) === "Deadly", "== deadly budget is Deadly");
assert(difficultyForXp(9999, d) === "Deadly", "over deadly stays Deadly");
console.log("encounter-budget self-check passed ✓");
}