diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json
index 90bc0b2..062b55f 100644
--- a/.sisyphus/boulder.json
+++ b/.sisyphus/boulder.json
@@ -8,7 +8,7 @@
"plan_name": "critical-refactor",
"status": "active",
"started_at": "2026-05-13T16:25:19.266Z",
- "updated_at": "2026-05-13T16:33:52.668Z",
+ "updated_at": "2026-05-13T16:42:05.595Z",
"session_ids": [
"ses_1de6ede59ffexUwt5C0sddEWwR"
],
@@ -29,6 +29,19 @@
"updated_at": "2026-05-13T16:33:52.668Z",
"ended_at": "2026-05-13T16:33:52.668Z",
"elapsed_ms": 363792
+ },
+ "todo:4": {
+ "task_key": "todo:4",
+ "task_label": "4",
+ "task_title": "Create ResultBox.astro shared component",
+ "session_id": "ses_1ddce8961ffeoaXaB2Rxgt684X",
+ "agent": "Sisyphus-Junior",
+ "category": "visual-engineering",
+ "updated_at": "2026-05-13T16:42:05.595Z",
+ "started_at": "2026-05-13T16:40:57.448Z",
+ "status": "completed",
+ "ended_at": "2026-05-13T16:42:05.595Z",
+ "elapsed_ms": 68147
}
}
}
@@ -36,7 +49,7 @@
"active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/critical-refactor.md",
"started_at": "2026-05-13T16:25:19.266Z",
"status": "active",
- "updated_at": "2026-05-13T16:33:52.668Z",
+ "updated_at": "2026-05-13T16:42:05.595Z",
"session_ids": [
"ses_1de6ede59ffexUwt5C0sddEWwR"
],
@@ -57,6 +70,19 @@
"updated_at": "2026-05-13T16:33:52.668Z",
"ended_at": "2026-05-13T16:33:52.668Z",
"elapsed_ms": 363792
+ },
+ "todo:4": {
+ "task_key": "todo:4",
+ "task_label": "4",
+ "task_title": "Create ResultBox.astro shared component",
+ "session_id": "ses_1ddce8961ffeoaXaB2Rxgt684X",
+ "agent": "Sisyphus-Junior",
+ "category": "visual-engineering",
+ "updated_at": "2026-05-13T16:42:05.595Z",
+ "started_at": "2026-05-13T16:40:57.448Z",
+ "status": "completed",
+ "ended_at": "2026-05-13T16:42:05.595Z",
+ "elapsed_ms": 68147
}
},
"agent": "atlas"
diff --git a/.sisyphus/plans/critical-refactor.md b/.sisyphus/plans/critical-refactor.md
index 3d397c1..b4755e3 100644
--- a/.sisyphus/plans/critical-refactor.md
+++ b/.sisyphus/plans/critical-refactor.md
@@ -336,7 +336,7 @@ Max Concurrent: 8 (Wave 3)
- Message: `feat(lib): add client i18n helper with tests`
- Files: `src/lib/client/i18n.ts`, `src/lib/client/i18n.test.ts`
-- [ ] 4. Create ResultBox.astro shared component
+- [x] 4. Create ResultBox.astro shared component
**What to do**:
- Create `src/components/ResultBox.astro` with:
@@ -382,7 +382,7 @@ Max Concurrent: 8 (Wave 3)
**Commit**: YES (groups with Task 5)
-- [ ] 5. Create CopyButton.astro shared component
+- [x] 5. Create CopyButton.astro shared component
**What to do**:
- Create `src/components/CopyButton.astro` with:
@@ -728,7 +728,7 @@ Max Concurrent: 8 (Wave 3)
- Message: `refactor(dice): deduplicate dice engine by importing from lib`
- Files: `src/components/generators/DiceGenerator.astro`
-- [ ] 34. Update dice-engine.ts to use crypto random
+- [x] 34. Update dice-engine.ts to use crypto random
**What to do**:
- Replace all `Math.random()` in `src/lib/dice-engine.ts` with `random.ts` functions
@@ -852,7 +852,7 @@ Max Concurrent: 8 (Wave 3)
- Message: `ci(deploy): add test and lint gates, fix SSH security`
- Files: `.github/workflows/deploy.yml`
-- [ ] 37. Verify CI blocks deploy on failure
+- [x] 37. Verify CI blocks deploy on failure
**What to do**:
- Verify updated `deploy.yml` workflow syntax
diff --git a/src/components/CopyButton.astro b/src/components/CopyButton.astro
new file mode 100644
index 0000000..3927c77
--- /dev/null
+++ b/src/components/CopyButton.astro
@@ -0,0 +1,70 @@
+---
+import { useT } from "@/i18n/translations";
+import type { Lang } from "@/i18n/translations";
+
+interface Props {
+ text: string;
+ label?: string;
+}
+
+const { text, label } = Astro.props;
+const lang = (Astro.currentLocale as Lang) || "en";
+const T = useT(lang);
+---
+
+
+
+
diff --git a/src/components/ResultBox.astro b/src/components/ResultBox.astro
new file mode 100644
index 0000000..2b6925d
--- /dev/null
+++ b/src/components/ResultBox.astro
@@ -0,0 +1,118 @@
+---
+export interface Props {
+ result: string;
+ label?: string;
+}
+
+const { result, label } = Astro.props;
+import { useT } from "../i18n/translations";
+import type { Lang } from "../i18n/translations";
+
+const lang = (Astro.currentLocale as Lang) || "en";
+const T = useT(lang);
+---
+
+
+ {
+ label && (
+
{label}
+ )
+ }
+
+
+
+ {result}
+
+
+
+
+
+
{T.copied}
+
+
+
diff --git a/src/lib/dice-engine.test.ts b/src/lib/dice-engine.test.ts
index c53c430..c4b4c86 100644
--- a/src/lib/dice-engine.test.ts
+++ b/src/lib/dice-engine.test.ts
@@ -6,6 +6,11 @@ import {
buildNotation,
isAdvancedNotation,
} from "./dice-engine";
+import { randomInt } from "./client/random";
+
+vi.mock("./client/random", () => ({
+ randomInt: vi.fn(),
+}));
describe("parseDiceNotation", () => {
it('parses basic notation "d6"', () => {
@@ -104,25 +109,23 @@ describe("parseDiceNotation", () => {
});
describe("rollDice", () => {
- let mathRandomSpy: ReturnType;
-
afterEach(() => {
- mathRandomSpy?.mockRestore();
+ vi.mocked(randomInt).mockReset();
});
it("rolls basic dice", () => {
- mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
+ vi.mocked(randomInt).mockReturnValue(4);
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
+ expect(result.total).toBe(12);
});
it("applies modifier", () => {
- mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
+ vi.mocked(randomInt).mockReturnValue(11);
const parsed = parseDiceNotation("1d20+5")!;
const result = rollDice(parsed);
@@ -130,11 +133,10 @@ describe("rollDice", () => {
});
it("keeps highest rolls (kh)", () => {
- mathRandomSpy = vi
- .spyOn(Math, "random")
- .mockReturnValueOnce(0.1) // 1
- .mockReturnValueOnce(0.9) // 6
- .mockReturnValueOnce(0.5); // 4
+ vi.mocked(randomInt)
+ .mockReturnValueOnce(1)
+ .mockReturnValueOnce(6)
+ .mockReturnValueOnce(4);
const parsed = parseDiceNotation("3d6kh2")!;
const result = rollDice(parsed);
@@ -146,11 +148,10 @@ describe("rollDice", () => {
});
it("drops highest rolls (dh)", () => {
- mathRandomSpy = vi
- .spyOn(Math, "random")
- .mockReturnValueOnce(0.1) // 1
- .mockReturnValueOnce(0.9) // 6
- .mockReturnValueOnce(0.5); // 4
+ vi.mocked(randomInt)
+ .mockReturnValueOnce(1)
+ .mockReturnValueOnce(6)
+ .mockReturnValueOnce(4);
const parsed = parseDiceNotation("3d6dh1")!;
const result = rollDice(parsed);
@@ -162,11 +163,10 @@ describe("rollDice", () => {
});
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)
+ vi.mocked(randomInt)
+ .mockReturnValueOnce(6)
+ .mockReturnValueOnce(6)
+ .mockReturnValueOnce(1);
const parsed = parseDiceNotation("1d6!")!;
const result = rollDice(parsed);
@@ -177,10 +177,9 @@ describe("rollDice", () => {
});
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
+ vi.mocked(randomInt)
+ .mockReturnValueOnce(6)
+ .mockReturnValueOnce(6);
const parsed = parseDiceNotation("1d6!p")!;
const result = rollDice(parsed);
@@ -190,10 +189,9 @@ describe("rollDice", () => {
});
it("handles reroll", () => {
- mathRandomSpy = vi
- .spyOn(Math, "random")
- .mockReturnValueOnce(0.0) // first roll: 1 (reroll)
- .mockReturnValueOnce(0.5); // reroll: 4
+ vi.mocked(randomInt)
+ .mockReturnValueOnce(1)
+ .mockReturnValueOnce(4);
const parsed = parseDiceNotation("1d6r1")!;
const result = rollDice(parsed);
@@ -203,10 +201,9 @@ describe("rollDice", () => {
});
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)
+ vi.mocked(randomInt)
+ .mockReturnValueOnce(1)
+ .mockReturnValueOnce(1);
const parsed = parseDiceNotation("1d6ro1")!;
const result = rollDice(parsed);
@@ -217,17 +214,14 @@ describe("rollDice", () => {
});
describe("rollAdvantage", () => {
- let mathRandomSpy: ReturnType;
-
afterEach(() => {
- mathRandomSpy?.mockRestore();
+ vi.mocked(randomInt).mockReset();
});
it("returns highest for advantage", () => {
- mathRandomSpy = vi
- .spyOn(Math, "random")
- .mockReturnValueOnce(0.1) // 3
- .mockReturnValueOnce(0.9); // 19
+ vi.mocked(randomInt)
+ .mockReturnValueOnce(3)
+ .mockReturnValueOnce(19);
const result = rollAdvantage(20, 0, true);
expect(result.total).toBe(19);
@@ -235,13 +229,12 @@ describe("rollAdvantage", () => {
});
it("returns lowest for disadvantage", () => {
- mathRandomSpy = vi
- .spyOn(Math, "random")
- .mockReturnValueOnce(0.9) // 19
- .mockReturnValueOnce(0.1); // 3
+ vi.mocked(randomInt)
+ .mockReturnValueOnce(19)
+ .mockReturnValueOnce(3);
const result = rollAdvantage(20, 2, false);
- expect(result.total).toBe(5); // 3 + 2
+ expect(result.total).toBe(5);
expect(result.advantageRolls).toEqual([19, 3]);
});
});
diff --git a/src/lib/dice-engine.ts b/src/lib/dice-engine.ts
index 7e0f921..9ac7fcd 100644
--- a/src/lib/dice-engine.ts
+++ b/src/lib/dice-engine.ts
@@ -2,6 +2,8 @@
// Supports: XdY±Z, kh/kl/dh/dl, ! exploding, r reroll, advantage/disadvantage
// Caps: explode chain ≤100, reroll recursion ≤1000
+import { randomInt } from "./client/random";
+
type KeepDrop = { type: "kh" | "kl" | "dh" | "dl"; count: number } | null;
type Explode = {
@@ -159,7 +161,7 @@ export function parseDiceNotation(raw: string): Parsed | null {
/** Roll a single die with optional reroll and explode logic */
function rollDie(sides: number, explode: Explode, reroll: Reroll): DieResult {
- let value = Math.floor(Math.random() * sides) + 1;
+ let value = randomInt(1, sides);
const original = value;
let rerolledFrom: number | null = null;
const explosions: number[] = [];
@@ -170,7 +172,7 @@ function rollDie(sides: number, explode: Explode, reroll: Reroll): DieResult {
rerolledFrom = value;
let rerollCount = 0;
while (reroll.values.has(value) && rerollCount < REROLL_CAP) {
- value = Math.floor(Math.random() * sides) + 1;
+ value = randomInt(1, sides);
rerollCount++;
if (reroll.once) break;
}
@@ -181,7 +183,7 @@ function rollDie(sides: number, explode: Explode, reroll: Reroll): DieResult {
exploded = true;
let chain = 0;
while (chain < EXPLODE_CAP) {
- let next = Math.floor(Math.random() * sides) + 1;
+ let next = randomInt(1, sides);
if (explode.penetrating) {
next = Math.max(1, next - 1); // penetrating subtracts 1
}
@@ -275,8 +277,8 @@ export function rollAdvantage(
modifier: number,
advantage: boolean,
): RollResult {
- const r1 = Math.floor(Math.random() * sides) + 1;
- const r2 = Math.floor(Math.random() * sides) + 1;
+ const r1 = randomInt(1, sides);
+ const r2 = randomInt(1, sides);
const keptVal = advantage ? Math.max(r1, r2) : Math.min(r1, r2);
return {