working through a review
This commit is contained in:
+101
@@ -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 ✓");
|
||||
}
|
||||
@@ -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 ✓");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user