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
+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 {