Wave 2: shared components + dice engine crypto migration

- feat(components): add ResultBox and CopyButton shared components
- refactor(dice): migrate dice engine to cryptographically secure random
This commit is contained in:
emil
2026-05-13 19:42:28 +03:00
parent 515c4a6168
commit e24a8b744d
6 changed files with 265 additions and 56 deletions
+28 -2
View File
@@ -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"
+4 -4
View File
@@ -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
+70
View File
@@ -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);
---
<button
type="button"
class="copy-btn inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-150 cursor-pointer"
data-text={text}
aria-label={label || T.copy}
>
<span class="copy-btn-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
</span>
<span class="copy-btn-check hidden" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
{label ? <span>{label}</span> : <span class="sr-only">{T.copy}</span>}
</button>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
document.querySelectorAll<HTMLElement>(".copy-btn").forEach((btn) => {
const icon = btn.querySelector<HTMLElement>(".copy-btn-icon")!;
const check = btn.querySelector<HTMLElement>(".copy-btn-check")!;
const feedback = new CopyFeedback(icon, check);
btn.addEventListener("click", async () => {
const text = btn.dataset.text || "";
if (!text) return;
await navigator.clipboard.writeText(text);
feedback.showCopied();
});
});
</script>
+118
View File
@@ -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);
---
<div
class="result-box rounded-xl bg-zinc-900/80 border border-zinc-800/80 p-5"
data-result={result}
>
{
label && (
<div class="mb-2 text-sm font-medium text-zinc-400">{label}</div>
)
}
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<span class="result-text block text-zinc-100 font-medium break-all">
{result}
</span>
<slot />
</div>
<button
type="button"
class="copy-btn group relative flex-shrink-0 cursor-copy rounded-lg p-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-label={T.copy}
>
<span
class="copy-icon text-zinc-500 group-hover:text-zinc-300 transition-colors duration-200"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
></path></svg
>
</span>
<span
class="check-icon hidden text-accent"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5"></path></svg
>
</span>
</button>
</div>
<span
class="copied-label mt-1 block text-xs font-medium text-accent opacity-0 transition-opacity duration-200"
aria-live="polite"
aria-atomic="true"
>{T.copied}</span
>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
function initResultBox(box: HTMLElement) {
if (box.dataset.initialized) return;
box.dataset.initialized = "true";
const btn = box.querySelector(".copy-btn") as HTMLButtonElement | null;
const copyIcon = box.querySelector(".copy-icon") as HTMLElement | null;
const checkIcon = box.querySelector(".check-icon") as HTMLElement | null;
const copiedLabel = box.querySelector(".copied-label") as
| HTMLElement
| null;
if (!btn || !copyIcon || !checkIcon || !copiedLabel) return;
const feedback = new CopyFeedback(copyIcon, checkIcon);
btn.addEventListener("click", async () => {
const text =
box.querySelector(".result-text")?.textContent?.trim() ||
box.dataset.result ||
"";
if (!text) return;
await navigator.clipboard.writeText(text);
feedback.showCopied();
copiedLabel.style.opacity = "1";
setTimeout(() => {
copiedLabel.style.opacity = "0";
}, 1500);
});
}
document.querySelectorAll(".result-box").forEach((box) => {
initResultBox(box as HTMLElement);
});
</script>
+38 -45
View File
@@ -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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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]);
});
});
+7 -5
View File
@@ -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 {