refactor(dice): extract engine from md-commander
The dice engine lived under md-commander/hooks but is shared by :md-dice, spark tables, the journal /roll command, and dice inside variable expressions. Move it to src/components/dice so the md-commander widget can be retired without losing rolling. Also promote the browser-console harness to a real jest suite; the file was named test.ts, so jest never ran it.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { tokenize, parse, roll, applyModifier, rollFormula } from "./index";
|
||||
import type { DieResult } from "./index";
|
||||
|
||||
describe("dice engine — tokenizer", () => {
|
||||
test("tokenizes NdX", () => {
|
||||
expect(tokenize("3d6")).toEqual([{ type: "dice", count: 3, sides: 6 }]);
|
||||
});
|
||||
|
||||
test("tokenizes bare dX as a single die", () => {
|
||||
expect(tokenize("d20")).toEqual([{ type: "dice", count: 1, sides: 20 }]);
|
||||
});
|
||||
|
||||
test("tokenizes a pool literal", () => {
|
||||
expect(tokenize("{d4,d8}")).toEqual([
|
||||
{ type: "pool_start" },
|
||||
{ type: "dice", count: 1, sides: 4 },
|
||||
{ type: "comma" },
|
||||
{ type: "dice", count: 1, sides: 8 },
|
||||
{ type: "pool_end" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("tokenizes a keep-highest modifier", () => {
|
||||
expect(tokenize("3d6kh2")).toEqual([
|
||||
{ type: "dice", count: 3, sides: 6 },
|
||||
{ type: "modifier", modType: "kh", count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores whitespace", () => {
|
||||
expect(tokenize(" 3d6 + 5 ")).toEqual([
|
||||
{ type: "dice", count: 3, sides: 6 },
|
||||
{ type: "plus" },
|
||||
{ type: "number", value: 5 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dice engine — parser", () => {
|
||||
test("builds one pool for NdX", () => {
|
||||
const pools = parse(tokenize("3d6"));
|
||||
expect(pools).toHaveLength(1);
|
||||
expect(pools[0].dice).toHaveLength(3);
|
||||
expect(pools[0].dice.every((d) => d.sides === 6)).toBe(true);
|
||||
});
|
||||
|
||||
test("builds a combined pool list for +", () => {
|
||||
const pools = parse(tokenize("3d6+5"));
|
||||
expect(pools).toHaveLength(2);
|
||||
expect(pools[1].dice[0]).toMatchObject({ sides: 0, value: 5 });
|
||||
});
|
||||
|
||||
test("marks a subtracted pool negative", () => {
|
||||
const pools = parse(tokenize("3d6-2"));
|
||||
expect(pools).toHaveLength(2);
|
||||
expect(pools[1].isNegative).toBe(true);
|
||||
expect(pools[1].dice[0].isNegative).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dice engine — applyModifier", () => {
|
||||
const dice = (...values: number[]): DieResult[] =>
|
||||
values.map((value) => ({ sides: 6, value, isNegative: false }));
|
||||
|
||||
test("keeps the highest N", () => {
|
||||
const { keptRolls, droppedRolls } = applyModifier(dice(2, 6, 4), {
|
||||
type: "kh",
|
||||
count: 2,
|
||||
});
|
||||
expect(keptRolls.map((r) => r.value).sort()).toEqual([4, 6]);
|
||||
expect(droppedRolls.map((r) => r.value)).toEqual([2]);
|
||||
});
|
||||
|
||||
test("drops the lowest N", () => {
|
||||
const { keptRolls, droppedRolls } = applyModifier(dice(2, 6, 4, 1), {
|
||||
type: "dl",
|
||||
count: 1,
|
||||
});
|
||||
expect(keptRolls.map((r) => r.value).sort()).toEqual([2, 4, 6]);
|
||||
expect(droppedRolls.map((r) => r.value)).toEqual([1]);
|
||||
});
|
||||
|
||||
test("keeps all when there is no modifier", () => {
|
||||
const { keptRolls, droppedRolls } = applyModifier(dice(1, 2), undefined);
|
||||
expect(keptRolls).toHaveLength(2);
|
||||
expect(droppedRolls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dice engine — roll", () => {
|
||||
test("rolls 3d6 within range", () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const result = roll("3d6");
|
||||
expect(result.total).toBeGreaterThanOrEqual(3);
|
||||
expect(result.total).toBeLessThanOrEqual(18);
|
||||
expect(result.pools).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("rolls d20 within range", () => {
|
||||
const result = roll("d20");
|
||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||
expect(result.total).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test("adds fixed numbers to the total", () => {
|
||||
const result = roll("3d6+5");
|
||||
expect(result.total).toBeGreaterThanOrEqual(8);
|
||||
expect(result.total).toBeLessThanOrEqual(23);
|
||||
});
|
||||
|
||||
test("keep-highest keeps N dice and totals only those", () => {
|
||||
const result = roll("3d6kh2");
|
||||
const [pool] = result.pools;
|
||||
expect(pool.keptRolls).toHaveLength(2);
|
||||
expect(pool.droppedRolls).toHaveLength(1);
|
||||
expect(result.total).toBe(pool.subtotal);
|
||||
});
|
||||
|
||||
test("emits matching HTML and plain-text detail", () => {
|
||||
const result = roll("2d6");
|
||||
expect(result.detail).toContain(String(result.total));
|
||||
expect(result.detail).toContain("<strong>");
|
||||
expect(result.plainDetail).toContain(String(result.total));
|
||||
});
|
||||
});
|
||||
|
||||
describe("dice engine — rollFormula", () => {
|
||||
test("normalizes a successful roll", () => {
|
||||
const result = rollFormula("3d6");
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.formula).toBe("3d6");
|
||||
expect(result.result.total).toBeGreaterThanOrEqual(3);
|
||||
expect(result.result.pools[0].rolls).toHaveLength(3);
|
||||
expect(result.result.pools[0].keptRolls).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("supports pool literals and modifiers", () => {
|
||||
const result = rollFormula("{d4,d8}kh2-5");
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result.pools.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user