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:
+28
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user