Phase 4: interactive dice & sheet rolling
- Dice notation: exploding (Nd6!) and reroll-below (Nd6r2), capped + tested - Degrees of success / roll-vs-DC (PF2e ±10 + nat 20/1 steps; 5e crit on nat 20/1) - Global roll tray (shared store + RollTray in layout) with animated result + degree - Roll from the character sheet: skills, saves, and attack to-hit/damage are clickable and post to the tray + history (rollAndShow/rollCheck helpers) - Saved roll macros per campaign (persisted) on the Dice page 10 new unit tests (notation explode/reroll, degrees), interactive-dice e2e. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { degreeOfSuccess } from './check';
|
||||
|
||||
describe('degreeOfSuccess — pf2e', () => {
|
||||
it('meets DC = success, +10 = critical success', () => {
|
||||
expect(degreeOfSuccess({ total: 15, dc: 15, system: 'pf2e' })).toBe('success');
|
||||
expect(degreeOfSuccess({ total: 25, dc: 15, system: 'pf2e' })).toBe('critical-success');
|
||||
});
|
||||
it('below DC = failure, -10 = critical failure', () => {
|
||||
expect(degreeOfSuccess({ total: 14, dc: 15, system: 'pf2e' })).toBe('failure');
|
||||
expect(degreeOfSuccess({ total: 5, dc: 15, system: 'pf2e' })).toBe('critical-failure');
|
||||
});
|
||||
it('natural 20 bumps the degree up one step', () => {
|
||||
expect(degreeOfSuccess({ total: 14, dc: 15, system: 'pf2e', natural: 20 })).toBe('success'); // failure -> success
|
||||
expect(degreeOfSuccess({ total: 16, dc: 15, system: 'pf2e', natural: 20 })).toBe('critical-success');
|
||||
});
|
||||
it('natural 1 drops the degree one step', () => {
|
||||
expect(degreeOfSuccess({ total: 16, dc: 15, system: 'pf2e', natural: 1 })).toBe('failure'); // success -> failure
|
||||
});
|
||||
});
|
||||
|
||||
describe('degreeOfSuccess — 5e', () => {
|
||||
it('success/failure vs DC', () => {
|
||||
expect(degreeOfSuccess({ total: 15, dc: 15, system: '5e' })).toBe('success');
|
||||
expect(degreeOfSuccess({ total: 14, dc: 15, system: '5e' })).toBe('failure');
|
||||
});
|
||||
it('nat 20/1 are critical regardless of DC', () => {
|
||||
expect(degreeOfSuccess({ total: 3, dc: 30, system: '5e', natural: 20 })).toBe('critical-success');
|
||||
expect(degreeOfSuccess({ total: 40, dc: 5, system: '5e', natural: 1 })).toBe('critical-failure');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
|
||||
export type Degree = 'critical-success' | 'success' | 'failure' | 'critical-failure';
|
||||
|
||||
export const DEGREE_LABEL: Record<Degree, string> = {
|
||||
'critical-success': 'Critical Success',
|
||||
success: 'Success',
|
||||
failure: 'Failure',
|
||||
'critical-failure': 'Critical Failure',
|
||||
};
|
||||
|
||||
export const DEGREE_COLOR: Record<Degree, string> = {
|
||||
'critical-success': 'text-success',
|
||||
success: 'text-success',
|
||||
failure: 'text-warning',
|
||||
'critical-failure': 'text-danger',
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluate a d20 result against a DC.
|
||||
* - PF2e: ≥DC+10 crit success, ≥DC success, ≤DC-10 crit failure; a natural 20
|
||||
* improves the degree one step and a natural 1 worsens it one step.
|
||||
* - 5e: success/failure vs DC, with natural 20/1 mapped to critical results.
|
||||
*/
|
||||
export function degreeOfSuccess(input: {
|
||||
total: number;
|
||||
dc: number;
|
||||
system: SystemId;
|
||||
/** the natural face of the d20, if known */
|
||||
natural?: number | undefined;
|
||||
}): Degree {
|
||||
const { total, dc, system, natural } = input;
|
||||
if (system === 'pf2e') {
|
||||
let step = total >= dc + 10 ? 2 : total >= dc ? 1 : total <= dc - 10 ? -1 : 0;
|
||||
if (natural === 20) step += 1;
|
||||
else if (natural === 1) step -= 1;
|
||||
step = Math.max(-1, Math.min(2, step));
|
||||
return step === 2 ? 'critical-success' : step === 1 ? 'success' : step === 0 ? 'failure' : 'critical-failure';
|
||||
}
|
||||
// 5e
|
||||
if (natural === 20) return 'critical-success';
|
||||
if (natural === 1) return 'critical-failure';
|
||||
return total >= dc ? 'success' : 'failure';
|
||||
}
|
||||
@@ -64,4 +64,33 @@ describe('rollDice', () => {
|
||||
const r = rollDice('1d6+5', createRng(1));
|
||||
expect(r.total).toBe(r.terms[0]!.dice[0]!.value + 5);
|
||||
});
|
||||
|
||||
it('exploding dice add extra dice on a max roll', () => {
|
||||
// d2 explodes very often; verify at least one expansion occurs and total = sum
|
||||
let sawExplosion = false;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const r = rollDice('1d2!', createRng(i));
|
||||
if (r.terms[0]!.dice.length > 1) {
|
||||
sawExplosion = true;
|
||||
// all but the last die must be the max value (2)
|
||||
const dice = r.terms[0]!.dice;
|
||||
for (let j = 0; j < dice.length - 1; j++) expect(dice[j]!.value).toBe(2);
|
||||
}
|
||||
expect(r.total).toBe(r.terms[0]!.dice.reduce((s, d) => s + d.value, 0));
|
||||
}
|
||||
expect(sawExplosion).toBe(true);
|
||||
});
|
||||
|
||||
it('reroll-below rerolls low dice (so results trend higher)', () => {
|
||||
// d6r5 keeps rerolling 1-5 once; values can still be low but parse + bound hold
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const r = rollDice('1d6r2', createRng(i));
|
||||
expect(r.total).toBeGreaterThanOrEqual(1);
|
||||
expect(r.total).toBeLessThanOrEqual(6);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['1d6r6', '1d20r20'])('rejects reroll threshold >= sides (%s)', (expr) => {
|
||||
expect(() => parseDice(expr)).toThrow(DiceParseError);
|
||||
});
|
||||
});
|
||||
|
||||
+45
-10
@@ -1,12 +1,16 @@
|
||||
import type { Rng } from '@/lib/rng';
|
||||
import { createRng } from '@/lib/rng';
|
||||
|
||||
/** A single die-group term, e.g. `4d6kh3`. */
|
||||
/** A single die-group term, e.g. `4d6kh3`, `3d6!`, `2d20r1`. */
|
||||
export interface DiceGroup {
|
||||
kind: 'dice';
|
||||
sign: 1 | -1;
|
||||
count: number;
|
||||
sides: number;
|
||||
/** explode: roll an extra die each time the max is rolled */
|
||||
explode?: boolean;
|
||||
/** reroll (once) any die whose value is <= this threshold */
|
||||
rerollBelow?: number;
|
||||
/** keep/drop highest/lowest, e.g. { op: 'kh', n: 3 } */
|
||||
keep?: { op: 'kh' | 'kl' | 'dh' | 'dl'; n: number };
|
||||
}
|
||||
@@ -48,7 +52,8 @@ const MAX_DICE = 1000;
|
||||
const MAX_SIDES = 1000;
|
||||
|
||||
const TERM_RE =
|
||||
/^(\d*)d(\d+)(?:(kh|kl|dh|dl)(\d+))?$/i;
|
||||
/^(\d*)d(\d+)(!)?(?:r(\d+))?(?:(kh|kl|dh|dl)(\d+))?$/i;
|
||||
const EXPLODE_CAP = 100;
|
||||
|
||||
/**
|
||||
* Parse a dice expression like `2d6+1d4+3` or `4d6kh3` into terms.
|
||||
@@ -90,13 +95,26 @@ export function parseDice(expression: string): DiceTerm[] {
|
||||
if (count < 1 || count > MAX_DICE) throw new DiceParseError(`Dice count out of range in "${raw}"`);
|
||||
if (sides < 2 || sides > MAX_SIDES) throw new DiceParseError(`Die size out of range in "${raw}"`);
|
||||
|
||||
let keep: DiceGroup['keep'];
|
||||
if (m[3]) {
|
||||
const n = Number(m[4]);
|
||||
if (n < 1 || n > count) throw new DiceParseError(`keep/drop count out of range in "${raw}"`);
|
||||
keep = { op: m[3].toLowerCase() as 'kh' | 'kl' | 'dh' | 'dl', n };
|
||||
const explode = m[3] === '!';
|
||||
let rerollBelow: number | undefined;
|
||||
if (m[4] !== undefined) {
|
||||
const r = Number(m[4]);
|
||||
if (r < 1 || r >= sides) throw new DiceParseError(`reroll threshold out of range in "${raw}"`);
|
||||
rerollBelow = r;
|
||||
}
|
||||
terms.push({ kind: 'dice', sign, count, sides, ...(keep ? { keep } : {}) });
|
||||
|
||||
let keep: DiceGroup['keep'];
|
||||
if (m[5]) {
|
||||
const n = Number(m[6]);
|
||||
if (n < 1 || n > count) throw new DiceParseError(`keep/drop count out of range in "${raw}"`);
|
||||
keep = { op: m[5].toLowerCase() as 'kh' | 'kl' | 'dh' | 'dl', n };
|
||||
}
|
||||
terms.push({
|
||||
kind: 'dice', sign, count, sides,
|
||||
...(explode ? { explode } : {}),
|
||||
...(rerollBelow !== undefined ? { rerollBelow } : {}),
|
||||
...(keep ? { keep } : {}),
|
||||
});
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
@@ -129,7 +147,22 @@ export function rollDice(expression: string, rng: Rng = createRng()): RollResult
|
||||
continue;
|
||||
}
|
||||
const values: number[] = [];
|
||||
for (let i = 0; i < term.count; i++) values.push(rng.int(1, term.sides));
|
||||
for (let i = 0; i < term.count; i++) {
|
||||
let v = rng.int(1, term.sides);
|
||||
// reroll once if at/under the threshold
|
||||
if (term.rerollBelow !== undefined && v <= term.rerollBelow) v = rng.int(1, term.sides);
|
||||
values.push(v);
|
||||
// exploding: each max roll adds another die
|
||||
if (term.explode) {
|
||||
let last = v;
|
||||
let guard = 0;
|
||||
while (last === term.sides && guard < EXPLODE_CAP) {
|
||||
last = rng.int(1, term.sides);
|
||||
values.push(last);
|
||||
guard++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const kept = applyKeep(values, term.keep);
|
||||
const dice: RolledDie[] = values.map((value, i) => ({
|
||||
sides: term.sides,
|
||||
@@ -154,7 +187,9 @@ function formatTerm(tr: TermResult): string {
|
||||
const { term } = tr;
|
||||
if (term.kind === 'const') return String(term.value);
|
||||
const keep = term.keep ? `${term.keep.op}${term.keep.n}` : '';
|
||||
const label = `${term.count}d${term.sides}${keep}`;
|
||||
const explode = term.explode ? '!' : '';
|
||||
const reroll = term.rerollBelow !== undefined ? `r${term.rerollBelow}` : '';
|
||||
const label = `${term.count}d${term.sides}${explode}${reroll}${keep}`;
|
||||
const dice = tr.dice
|
||||
.map((d) => (d.kept ? String(d.value) : `~~${d.value}~~`))
|
||||
.join(', ');
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { rollDice } from '@/lib/dice/notation';
|
||||
import { createRng } from '@/lib/rng';
|
||||
import { degreeOfSuccess, type Degree } from '@/lib/dice/check';
|
||||
import { diceRepo } from '@/lib/db/repositories';
|
||||
import { useRollStore } from '@/stores/rollStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
|
||||
export interface RollOptions {
|
||||
expression: string;
|
||||
label?: string;
|
||||
/** roll against a DC to compute a success degree */
|
||||
dc?: number;
|
||||
system?: SystemId;
|
||||
}
|
||||
|
||||
/** Find the natural face of the (kept) d20, for crit/degree handling. */
|
||||
function naturalD20(result: ReturnType<typeof rollDice>): number | undefined {
|
||||
for (const t of result.terms) {
|
||||
if (t.term.kind === 'dice' && t.term.sides === 20) {
|
||||
const kept = t.dice.find((d) => d.kept);
|
||||
if (kept) return kept.value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll an expression, show it in the global tray, and persist it to history.
|
||||
* Returns the result + degree. Used by the dice page, sheets, and statblocks.
|
||||
*/
|
||||
export function rollAndShow(opts: RollOptions): { total: number; degree?: Degree } {
|
||||
const result = rollDice(opts.expression, createRng());
|
||||
const campaignId = useUiStore.getState().activeCampaignId ?? undefined;
|
||||
const label = opts.label ?? '';
|
||||
|
||||
let degree: Degree | undefined;
|
||||
if (opts.dc !== undefined && opts.system) {
|
||||
degree = degreeOfSuccess({ total: result.total, dc: opts.dc, system: opts.system, natural: naturalD20(result) });
|
||||
}
|
||||
|
||||
useRollStore.getState().push({
|
||||
label,
|
||||
result,
|
||||
...(opts.dc !== undefined ? { dc: opts.dc } : {}),
|
||||
...(degree ? { degree } : {}),
|
||||
});
|
||||
|
||||
void diceRepo.add({
|
||||
...(campaignId ? { campaignId } : {}),
|
||||
label,
|
||||
expression: opts.expression,
|
||||
total: result.total,
|
||||
breakdown: result.breakdown,
|
||||
});
|
||||
|
||||
return { total: result.total, ...(degree ? { degree } : {}) };
|
||||
}
|
||||
|
||||
/** Convenience: roll d20 + modifier, formatting the expression. */
|
||||
export function rollCheck(modifier: number, label: string, opts?: { dc?: number; system?: SystemId }): void {
|
||||
const sign = modifier >= 0 ? `+${modifier}` : `${modifier}`;
|
||||
rollAndShow({
|
||||
expression: `1d20${modifier === 0 ? '' : sign}`,
|
||||
label,
|
||||
...(opts?.dc !== undefined ? { dc: opts.dc } : {}),
|
||||
...(opts?.system ? { system: opts.system } : {}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user