test: add Vitest + 30 dice-engine tests

- Install vitest and @vitest/coverage-v8
- Add test scripts to package.json
- Create vitest.config.ts
- Add 30 comprehensive tests for parseDiceNotation, rollDice,
  rollAdvantage, buildNotation, isAdvancedNotation
- Fix isAdvancedNotation regex to detect ro (reroll once)
This commit is contained in:
emil
2026-05-10 21:12:08 +03:00
parent 3750cd2cc6
commit bb62d7310e
5 changed files with 903 additions and 57 deletions
+286
View File
@@ -0,0 +1,286 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
parseDiceNotation,
rollDice,
rollAdvantage,
buildNotation,
isAdvancedNotation,
} from "./dice-engine";
describe("parseDiceNotation", () => {
it('parses basic notation "d6"', () => {
const parsed = parseDiceNotation("d6");
expect(parsed).not.toBeNull();
expect(parsed!.count).toBe(1);
expect(parsed!.sides).toBe(6);
expect(parsed!.modifier).toBe(0);
expect(parsed!.keepDrop).toBeNull();
expect(parsed!.explode.active).toBe(false);
expect(parsed!.reroll.active).toBe(false);
});
it('parses "2d20+3"', () => {
const parsed = parseDiceNotation("2d20+3");
expect(parsed).not.toBeNull();
expect(parsed!.count).toBe(2);
expect(parsed!.sides).toBe(20);
expect(parsed!.modifier).toBe(3);
});
it('parses "3d8-1"', () => {
const parsed = parseDiceNotation("3d8-1");
expect(parsed!.count).toBe(3);
expect(parsed!.sides).toBe(8);
expect(parsed!.modifier).toBe(-1);
});
it('parses "4d6kh3"', () => {
const parsed = parseDiceNotation("4d6kh3");
expect(parsed!.keepDrop).toEqual({ type: "kh", count: 3 });
});
it('parses "5d6dl2"', () => {
const parsed = parseDiceNotation("5d6dl2");
expect(parsed!.keepDrop).toEqual({ type: "dl", count: 2 });
});
it('parses exploding "3d6!"', () => {
const parsed = parseDiceNotation("3d6!");
expect(parsed!.explode.active).toBe(true);
expect(parsed!.explode.penetrating).toBe(false);
expect(parsed!.explode.threshold).toBe(6);
});
it('parses penetrating explode "2d10!p"', () => {
const parsed = parseDiceNotation("2d10!p");
expect(parsed!.explode.active).toBe(true);
expect(parsed!.explode.penetrating).toBe(true);
});
it('parses explode with custom threshold "2d6!>4"', () => {
const parsed = parseDiceNotation("2d6!>4");
expect(parsed!.explode.active).toBe(true);
expect(parsed!.explode.threshold).toBe(4);
});
it('parses reroll "2d6r1"', () => {
const parsed = parseDiceNotation("2d6r1");
expect(parsed!.reroll.active).toBe(true);
expect(parsed!.reroll.once).toBe(false);
expect(parsed!.reroll.values).toContain(1);
});
it('parses reroll once "2d6ro1"', () => {
const parsed = parseDiceNotation("2d6ro1");
expect(parsed!.reroll.active).toBe(true);
expect(parsed!.reroll.once).toBe(true);
});
it('parses reroll less-than "2d6r<3"', () => {
const parsed = parseDiceNotation("2d6r<3");
expect(parsed!.reroll.active).toBe(true);
expect(parsed!.reroll.values).toContain(1);
expect(parsed!.reroll.values).toContain(2);
expect(parsed!.reroll.values).not.toContain(3);
});
it("ignores whitespace", () => {
const parsed = parseDiceNotation(" 2 d 20 + 5 ");
expect(parsed!.count).toBe(2);
expect(parsed!.sides).toBe(20);
expect(parsed!.modifier).toBe(5);
});
it("returns null for invalid notation", () => {
expect(parseDiceNotation("")).toBeNull();
expect(parseDiceNotation("abc")).toBeNull();
expect(parseDiceNotation("2d6+1+2")).toBeNull();
expect(parseDiceNotation("0d6")).toBeNull();
expect(parseDiceNotation("21d6")).toBeNull();
expect(parseDiceNotation("2d0")).toBeNull();
expect(parseDiceNotation("2d6kh0")).toBeNull();
expect(parseDiceNotation("2d6kh2")).toBeNull();
});
});
describe("rollDice", () => {
let mathRandomSpy: ReturnType<typeof vi.spyOn>;
afterEach(() => {
mathRandomSpy?.mockRestore();
});
it("rolls basic dice", () => {
mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const parsed = parseDiceNotation("3d6")!;
const result = rollDice(parsed);
expect(result.dice).toHaveLength(3);
expect(result.kept).toHaveLength(3);
expect(result.dropped).toHaveLength(0);
expect(result.total).toBe(12); // each die: floor(0.5*6)+1 = 4, 3*4 = 12
});
it("applies modifier", () => {
mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const parsed = parseDiceNotation("1d20+5")!;
const result = rollDice(parsed);
expect(result.total).toBe(16); // 11 + 5
});
it("keeps highest rolls (kh)", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.1) // 1
.mockReturnValueOnce(0.9) // 6
.mockReturnValueOnce(0.5); // 4
const parsed = parseDiceNotation("3d6kh2")!;
const result = rollDice(parsed);
expect(result.kept).toHaveLength(2);
expect(result.dropped).toHaveLength(1);
expect(result.dropped[0].dropped).toBe(true);
expect(result.total).toBe(10); // 6 + 4
});
it("drops highest rolls (dh)", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.1) // 1
.mockReturnValueOnce(0.9) // 6
.mockReturnValueOnce(0.5); // 4
const parsed = parseDiceNotation("3d6dh1")!;
const result = rollDice(parsed);
expect(result.kept).toHaveLength(2);
expect(result.dropped).toHaveLength(1);
expect(result.dropped[0].value).toBe(6);
expect(result.total).toBe(5); // 1 + 4
});
it("handles exploding dice", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.99) // first roll: 6 (max, triggers explode)
.mockReturnValueOnce(0.99) // explosion 1: 6 (triggers again)
.mockReturnValueOnce(0.1); // explosion 2: 1 (stops)
const parsed = parseDiceNotation("1d6!")!;
const result = rollDice(parsed);
expect(result.dice[0].exploded).toBe(true);
expect(result.dice[0].explosions).toEqual([6, 1]);
expect(result.total).toBe(13); // 6 + 6 + 1
});
it("handles penetrating explode", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.99) // first roll: 6 (explode)
.mockReturnValueOnce(0.99); // explosion: 6 -> penetrating -> 5, then 5 < 6 stops
const parsed = parseDiceNotation("1d6!p")!;
const result = rollDice(parsed);
expect(result.dice[0].explosions).toEqual([5]);
expect(result.total).toBe(11); // 6 + 5
});
it("handles reroll", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.0) // first roll: 1 (reroll)
.mockReturnValueOnce(0.5); // reroll: 4
const parsed = parseDiceNotation("1d6r1")!;
const result = rollDice(parsed);
expect(result.dice[0].rerolledFrom).toBe(1);
expect(result.dice[0].value).toBe(4);
});
it("handles reroll once (ro)", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.0) // first: 1 (reroll once)
.mockReturnValueOnce(0.0); // second: 1 (stays, no more rerolls)
const parsed = parseDiceNotation("1d6ro1")!;
const result = rollDice(parsed);
expect(result.dice[0].rerolledFrom).toBe(1);
expect(result.dice[0].value).toBe(1);
});
});
describe("rollAdvantage", () => {
let mathRandomSpy: ReturnType<typeof vi.spyOn>;
afterEach(() => {
mathRandomSpy?.mockRestore();
});
it("returns highest for advantage", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.1) // 3
.mockReturnValueOnce(0.9); // 19
const result = rollAdvantage(20, 0, true);
expect(result.total).toBe(19);
expect(result.advantageRolls).toEqual([3, 19]);
});
it("returns lowest for disadvantage", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.9) // 19
.mockReturnValueOnce(0.1); // 3
const result = rollAdvantage(20, 2, false);
expect(result.total).toBe(5); // 3 + 2
expect(result.advantageRolls).toEqual([19, 3]);
});
});
describe("buildNotation", () => {
it("builds basic notation", () => {
expect(buildNotation(1, 6, 0)).toBe("1d6");
expect(buildNotation(2, 20, 0)).toBe("2d20");
});
it("adds positive modifier", () => {
expect(buildNotation(2, 6, 3)).toBe("2d6+3");
});
it("adds negative modifier", () => {
expect(buildNotation(2, 6, -2)).toBe("2d6-2");
});
});
describe("isAdvancedNotation", () => {
it("returns false for basic notation", () => {
expect(isAdvancedNotation("2d6")).toBe(false);
expect(isAdvancedNotation("1d20+5")).toBe(false);
expect(isAdvancedNotation("3d8-1")).toBe(false);
});
it("returns true for keep/drop", () => {
expect(isAdvancedNotation("4d6kh3")).toBe(true);
expect(isAdvancedNotation("5d6dl2")).toBe(true);
});
it("returns true for explode", () => {
expect(isAdvancedNotation("3d6!")).toBe(true);
expect(isAdvancedNotation("2d10!p")).toBe(true);
});
it("returns true for reroll", () => {
expect(isAdvancedNotation("2d6r1")).toBe(true);
expect(isAdvancedNotation("2d6ro1")).toBe(true);
expect(isAdvancedNotation("2d6r<3")).toBe(true);
});
});
+90 -34
View File
@@ -2,7 +2,7 @@
// Supports: XdY±Z, kh/kl/dh/dl, ! exploding, r reroll, advantage/disadvantage
// Caps: explode chain ≤100, reroll recursion ≤1000
type KeepDrop = { type: 'kh' | 'kl' | 'dh' | 'dl'; count: number } | null;
type KeepDrop = { type: "kh" | "kl" | "dh" | "dl"; count: number } | null;
type Explode = {
active: boolean;
@@ -14,7 +14,7 @@ type Reroll = {
active: boolean;
values: Set<number>;
once: boolean; // "ro" = once only; "r" = recursive
operator: 'eq' | 'lt' | 'gt';
operator: "eq" | "lt" | "gt";
};
export interface Parsed {
@@ -32,7 +32,7 @@ export interface DieResult {
value: number;
original: number;
exploded: boolean;
explosions: number[]; // chained explosion rolls
explosions: number[]; // chained explosion rolls
rerolledFrom: number | null;
dropped: boolean;
}
@@ -52,7 +52,7 @@ const REROLL_CAP = 1000;
/** Remove all whitespace and lowercase for parsing */
function normalize(raw: string): string {
return raw.replace(/\s+/g, '').toLowerCase();
return raw.replace(/\s+/g, "").toLowerCase();
}
/**
@@ -67,7 +67,7 @@ export function parseDiceNotation(raw: string): Parsed | null {
const main = s.match(/^(\d*)d(\d+)(.*)$/);
if (!main) return null;
const count = main[1] === '' ? 1 : parseInt(main[1], 10);
const count = main[1] === "" ? 1 : parseInt(main[1], 10);
const sides = parseInt(main[2], 10);
const rest = main[3];
@@ -81,7 +81,10 @@ export function parseDiceNotation(raw: string): Parsed | null {
if (kdMatch) {
const countKD = parseInt(kdMatch[2], 10);
if (countKD < 1 || countKD >= count) return null;
keepDrop = { type: kdMatch[1] as 'kh' | 'kl' | 'dh' | 'dl', count: countKD };
keepDrop = {
type: kdMatch[1] as "kh" | "kl" | "dh" | "dl",
count: countKD,
};
remaining = remaining.slice(kdMatch[0].length);
}
@@ -90,8 +93,8 @@ export function parseDiceNotation(raw: string): Parsed | null {
const expMatch = remaining.match(/^(!p|!)(>?)(\d*)/);
if (expMatch) {
explode.active = true;
explode.penetrating = expMatch[1] === '!p';
if (expMatch[2] === '>' && expMatch[3]) {
explode.penetrating = expMatch[1] === "!p";
if (expMatch[2] === ">" && expMatch[3]) {
explode.threshold = parseInt(expMatch[3], 10);
if (explode.threshold < 2 || explode.threshold > sides) return null;
} else {
@@ -101,21 +104,26 @@ export function parseDiceNotation(raw: string): Parsed | null {
}
// Reroll: rN, roN, r<N, ro<N
let reroll: Reroll = { active: false, values: new Set(), once: false, operator: 'eq' };
let reroll: Reroll = {
active: false,
values: new Set(),
once: false,
operator: "eq",
};
const rrMatch = remaining.match(/^(ro|r)([<>]?)(\d+)/);
if (rrMatch) {
reroll.active = true;
reroll.once = rrMatch[1] === 'ro';
const op = rrMatch[2] as '' | '<' | '>';
reroll.once = rrMatch[1] === "ro";
const op = rrMatch[2] as "" | "<" | ">";
const val = parseInt(rrMatch[3], 10);
if (op === '<') {
reroll.operator = 'lt';
if (op === "<") {
reroll.operator = "lt";
for (let i = 1; i < val && i < sides; i++) reroll.values.add(i);
} else if (op === '>') {
reroll.operator = 'gt';
} else if (op === ">") {
reroll.operator = "gt";
for (let i = val + 1; i <= sides; i++) reroll.values.add(i);
} else {
reroll.operator = 'eq';
reroll.operator = "eq";
reroll.values.add(val);
}
remaining = remaining.slice(rrMatch[0].length);
@@ -133,7 +141,16 @@ export function parseDiceNotation(raw: string): Parsed | null {
// If anything remains unparsed, it's invalid
if (remaining.length > 0) return null;
return { count, sides, modifier, keepDrop, explode, reroll, advantage: false, disadvantage: false };
return {
count,
sides,
modifier,
keepDrop,
explode,
reroll,
advantage: false,
disadvantage: false,
};
}
/** Roll a single die with optional reroll and explode logic */
@@ -194,34 +211,47 @@ export function rollDice(parsed: Parsed): RollResult {
if (parsed.keepDrop) {
const sorted = [...dice].map((d, i) => ({ die: d, idx: i }));
if (parsed.keepDrop.type === 'kh') {
if (parsed.keepDrop.type === "kh") {
sorted.sort((a, b) => b.die.value - a.die.value);
const keepIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
const keepIndices = new Set(
sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx),
);
kept = dice.filter((_, i) => keepIndices.has(i));
dropped = dice.filter((_, i) => !keepIndices.has(i));
} else if (parsed.keepDrop.type === 'kl') {
} else if (parsed.keepDrop.type === "kl") {
sorted.sort((a, b) => a.die.value - b.die.value);
const keepIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
const keepIndices = new Set(
sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx),
);
kept = dice.filter((_, i) => keepIndices.has(i));
dropped = dice.filter((_, i) => !keepIndices.has(i));
} else if (parsed.keepDrop.type === 'dh') {
} else if (parsed.keepDrop.type === "dh") {
sorted.sort((a, b) => b.die.value - a.die.value);
const dropIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
const dropIndices = new Set(
sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx),
);
kept = dice.filter((_, i) => !dropIndices.has(i));
dropped = dice.filter((_, i) => dropIndices.has(i));
} else if (parsed.keepDrop.type === 'dl') {
} else if (parsed.keepDrop.type === "dl") {
sorted.sort((a, b) => a.die.value - b.die.value);
const dropIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
const dropIndices = new Set(
sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx),
);
kept = dice.filter((_, i) => !dropIndices.has(i));
dropped = dice.filter((_, i) => dropIndices.has(i));
}
}
dropped.forEach(d => { d.dropped = true; });
dropped.forEach((d) => {
d.dropped = true;
});
// Calculate total
let total = kept.reduce((sum, d) => sum + d.value, 0);
total += kept.reduce((sum, d) => sum + d.explosions.reduce((s, v) => s + v, 0), 0);
total += kept.reduce(
(sum, d) => sum + d.explosions.reduce((s, v) => s + v, 0),
0,
);
total += parsed.modifier;
return {
@@ -230,30 +260,56 @@ export function rollDice(parsed: Parsed): RollResult {
dropped,
modifier: parsed.modifier,
total,
notation: '', // filled by caller
notation: "", // filled by caller
advantageRolls: null,
};
}
/** Roll with advantage or disadvantage (2dX keep highest/lowest) */
export function rollAdvantage(sides: number, modifier: number, advantage: boolean): RollResult {
export function rollAdvantage(
sides: number,
modifier: number,
advantage: boolean,
): RollResult {
const r1 = Math.floor(Math.random() * sides) + 1;
const r2 = Math.floor(Math.random() * sides) + 1;
const keptVal = advantage ? Math.max(r1, r2) : Math.min(r1, r2);
return {
dice: [{ value: keptVal, original: keptVal, exploded: false, explosions: [], rerolledFrom: null, dropped: false }],
kept: [{ value: keptVal, original: keptVal, exploded: false, explosions: [], rerolledFrom: null, dropped: false }],
dice: [
{
value: keptVal,
original: keptVal,
exploded: false,
explosions: [],
rerolledFrom: null,
dropped: false,
},
],
kept: [
{
value: keptVal,
original: keptVal,
exploded: false,
explosions: [],
rerolledFrom: null,
dropped: false,
},
],
dropped: [],
modifier,
total: keptVal + modifier,
notation: '',
notation: "",
advantageRolls: [r1, r2],
};
}
/** Build notation string from parsed object (basic only, for simple sync) */
export function buildNotation(count: number, sides: number, modifier: number): string {
export function buildNotation(
count: number,
sides: number,
modifier: number,
): string {
let s = `${count}d${sides}`;
if (modifier > 0) s += `+${modifier}`;
else if (modifier < 0) s += modifier;
@@ -263,5 +319,5 @@ export function buildNotation(count: number, sides: number, modifier: number): s
/** Check if notation contains advanced features beyond basic XdY±Z */
export function isAdvancedNotation(raw: string): boolean {
const s = normalize(raw);
return /(kh|kl|dh|dl|!|[<>]?r\d)/.test(s);
return /(kh|kl|dh|dl|!|ro?[<>]?\d)/.test(s);
}