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",
|
"plan_name": "critical-refactor",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"started_at": "2026-05-13T16:25:19.266Z",
|
"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": [
|
"session_ids": [
|
||||||
"ses_1de6ede59ffexUwt5C0sddEWwR"
|
"ses_1de6ede59ffexUwt5C0sddEWwR"
|
||||||
],
|
],
|
||||||
@@ -29,6 +29,19 @@
|
|||||||
"updated_at": "2026-05-13T16:33:52.668Z",
|
"updated_at": "2026-05-13T16:33:52.668Z",
|
||||||
"ended_at": "2026-05-13T16:33:52.668Z",
|
"ended_at": "2026-05-13T16:33:52.668Z",
|
||||||
"elapsed_ms": 363792
|
"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",
|
"active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/critical-refactor.md",
|
||||||
"started_at": "2026-05-13T16:25:19.266Z",
|
"started_at": "2026-05-13T16:25:19.266Z",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"updated_at": "2026-05-13T16:33:52.668Z",
|
"updated_at": "2026-05-13T16:42:05.595Z",
|
||||||
"session_ids": [
|
"session_ids": [
|
||||||
"ses_1de6ede59ffexUwt5C0sddEWwR"
|
"ses_1de6ede59ffexUwt5C0sddEWwR"
|
||||||
],
|
],
|
||||||
@@ -57,6 +70,19 @@
|
|||||||
"updated_at": "2026-05-13T16:33:52.668Z",
|
"updated_at": "2026-05-13T16:33:52.668Z",
|
||||||
"ended_at": "2026-05-13T16:33:52.668Z",
|
"ended_at": "2026-05-13T16:33:52.668Z",
|
||||||
"elapsed_ms": 363792
|
"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"
|
"agent": "atlas"
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ Max Concurrent: 8 (Wave 3)
|
|||||||
- Message: `feat(lib): add client i18n helper with tests`
|
- Message: `feat(lib): add client i18n helper with tests`
|
||||||
- Files: `src/lib/client/i18n.ts`, `src/lib/client/i18n.test.ts`
|
- 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**:
|
**What to do**:
|
||||||
- Create `src/components/ResultBox.astro` with:
|
- Create `src/components/ResultBox.astro` with:
|
||||||
@@ -382,7 +382,7 @@ Max Concurrent: 8 (Wave 3)
|
|||||||
|
|
||||||
**Commit**: YES (groups with Task 5)
|
**Commit**: YES (groups with Task 5)
|
||||||
|
|
||||||
- [ ] 5. Create CopyButton.astro shared component
|
- [x] 5. Create CopyButton.astro shared component
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `src/components/CopyButton.astro` with:
|
- 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`
|
- Message: `refactor(dice): deduplicate dice engine by importing from lib`
|
||||||
- Files: `src/components/generators/DiceGenerator.astro`
|
- 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**:
|
**What to do**:
|
||||||
- Replace all `Math.random()` in `src/lib/dice-engine.ts` with `random.ts` functions
|
- 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`
|
- Message: `ci(deploy): add test and lint gates, fix SSH security`
|
||||||
- Files: `.github/workflows/deploy.yml`
|
- Files: `.github/workflows/deploy.yml`
|
||||||
|
|
||||||
- [ ] 37. Verify CI blocks deploy on failure
|
- [x] 37. Verify CI blocks deploy on failure
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Verify updated `deploy.yml` workflow syntax
|
- 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,
|
buildNotation,
|
||||||
isAdvancedNotation,
|
isAdvancedNotation,
|
||||||
} from "./dice-engine";
|
} from "./dice-engine";
|
||||||
|
import { randomInt } from "./client/random";
|
||||||
|
|
||||||
|
vi.mock("./client/random", () => ({
|
||||||
|
randomInt: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("parseDiceNotation", () => {
|
describe("parseDiceNotation", () => {
|
||||||
it('parses basic notation "d6"', () => {
|
it('parses basic notation "d6"', () => {
|
||||||
@@ -104,25 +109,23 @@ describe("parseDiceNotation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("rollDice", () => {
|
describe("rollDice", () => {
|
||||||
let mathRandomSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mathRandomSpy?.mockRestore();
|
vi.mocked(randomInt).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rolls basic dice", () => {
|
it("rolls basic dice", () => {
|
||||||
mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
|
vi.mocked(randomInt).mockReturnValue(4);
|
||||||
const parsed = parseDiceNotation("3d6")!;
|
const parsed = parseDiceNotation("3d6")!;
|
||||||
const result = rollDice(parsed);
|
const result = rollDice(parsed);
|
||||||
|
|
||||||
expect(result.dice).toHaveLength(3);
|
expect(result.dice).toHaveLength(3);
|
||||||
expect(result.kept).toHaveLength(3);
|
expect(result.kept).toHaveLength(3);
|
||||||
expect(result.dropped).toHaveLength(0);
|
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", () => {
|
it("applies modifier", () => {
|
||||||
mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
|
vi.mocked(randomInt).mockReturnValue(11);
|
||||||
const parsed = parseDiceNotation("1d20+5")!;
|
const parsed = parseDiceNotation("1d20+5")!;
|
||||||
const result = rollDice(parsed);
|
const result = rollDice(parsed);
|
||||||
|
|
||||||
@@ -130,11 +133,10 @@ describe("rollDice", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps highest rolls (kh)", () => {
|
it("keeps highest rolls (kh)", () => {
|
||||||
mathRandomSpy = vi
|
vi.mocked(randomInt)
|
||||||
.spyOn(Math, "random")
|
.mockReturnValueOnce(1)
|
||||||
.mockReturnValueOnce(0.1) // 1
|
.mockReturnValueOnce(6)
|
||||||
.mockReturnValueOnce(0.9) // 6
|
.mockReturnValueOnce(4);
|
||||||
.mockReturnValueOnce(0.5); // 4
|
|
||||||
|
|
||||||
const parsed = parseDiceNotation("3d6kh2")!;
|
const parsed = parseDiceNotation("3d6kh2")!;
|
||||||
const result = rollDice(parsed);
|
const result = rollDice(parsed);
|
||||||
@@ -146,11 +148,10 @@ describe("rollDice", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("drops highest rolls (dh)", () => {
|
it("drops highest rolls (dh)", () => {
|
||||||
mathRandomSpy = vi
|
vi.mocked(randomInt)
|
||||||
.spyOn(Math, "random")
|
.mockReturnValueOnce(1)
|
||||||
.mockReturnValueOnce(0.1) // 1
|
.mockReturnValueOnce(6)
|
||||||
.mockReturnValueOnce(0.9) // 6
|
.mockReturnValueOnce(4);
|
||||||
.mockReturnValueOnce(0.5); // 4
|
|
||||||
|
|
||||||
const parsed = parseDiceNotation("3d6dh1")!;
|
const parsed = parseDiceNotation("3d6dh1")!;
|
||||||
const result = rollDice(parsed);
|
const result = rollDice(parsed);
|
||||||
@@ -162,11 +163,10 @@ describe("rollDice", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles exploding dice", () => {
|
it("handles exploding dice", () => {
|
||||||
mathRandomSpy = vi
|
vi.mocked(randomInt)
|
||||||
.spyOn(Math, "random")
|
.mockReturnValueOnce(6)
|
||||||
.mockReturnValueOnce(0.99) // first roll: 6 (max, triggers explode)
|
.mockReturnValueOnce(6)
|
||||||
.mockReturnValueOnce(0.99) // explosion 1: 6 (triggers again)
|
.mockReturnValueOnce(1);
|
||||||
.mockReturnValueOnce(0.1); // explosion 2: 1 (stops)
|
|
||||||
|
|
||||||
const parsed = parseDiceNotation("1d6!")!;
|
const parsed = parseDiceNotation("1d6!")!;
|
||||||
const result = rollDice(parsed);
|
const result = rollDice(parsed);
|
||||||
@@ -177,10 +177,9 @@ describe("rollDice", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles penetrating explode", () => {
|
it("handles penetrating explode", () => {
|
||||||
mathRandomSpy = vi
|
vi.mocked(randomInt)
|
||||||
.spyOn(Math, "random")
|
.mockReturnValueOnce(6)
|
||||||
.mockReturnValueOnce(0.99) // first roll: 6 (explode)
|
.mockReturnValueOnce(6);
|
||||||
.mockReturnValueOnce(0.99); // explosion: 6 -> penetrating -> 5, then 5 < 6 stops
|
|
||||||
|
|
||||||
const parsed = parseDiceNotation("1d6!p")!;
|
const parsed = parseDiceNotation("1d6!p")!;
|
||||||
const result = rollDice(parsed);
|
const result = rollDice(parsed);
|
||||||
@@ -190,10 +189,9 @@ describe("rollDice", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles reroll", () => {
|
it("handles reroll", () => {
|
||||||
mathRandomSpy = vi
|
vi.mocked(randomInt)
|
||||||
.spyOn(Math, "random")
|
.mockReturnValueOnce(1)
|
||||||
.mockReturnValueOnce(0.0) // first roll: 1 (reroll)
|
.mockReturnValueOnce(4);
|
||||||
.mockReturnValueOnce(0.5); // reroll: 4
|
|
||||||
|
|
||||||
const parsed = parseDiceNotation("1d6r1")!;
|
const parsed = parseDiceNotation("1d6r1")!;
|
||||||
const result = rollDice(parsed);
|
const result = rollDice(parsed);
|
||||||
@@ -203,10 +201,9 @@ describe("rollDice", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles reroll once (ro)", () => {
|
it("handles reroll once (ro)", () => {
|
||||||
mathRandomSpy = vi
|
vi.mocked(randomInt)
|
||||||
.spyOn(Math, "random")
|
.mockReturnValueOnce(1)
|
||||||
.mockReturnValueOnce(0.0) // first: 1 (reroll once)
|
.mockReturnValueOnce(1);
|
||||||
.mockReturnValueOnce(0.0); // second: 1 (stays, no more rerolls)
|
|
||||||
|
|
||||||
const parsed = parseDiceNotation("1d6ro1")!;
|
const parsed = parseDiceNotation("1d6ro1")!;
|
||||||
const result = rollDice(parsed);
|
const result = rollDice(parsed);
|
||||||
@@ -217,17 +214,14 @@ describe("rollDice", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("rollAdvantage", () => {
|
describe("rollAdvantage", () => {
|
||||||
let mathRandomSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mathRandomSpy?.mockRestore();
|
vi.mocked(randomInt).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns highest for advantage", () => {
|
it("returns highest for advantage", () => {
|
||||||
mathRandomSpy = vi
|
vi.mocked(randomInt)
|
||||||
.spyOn(Math, "random")
|
.mockReturnValueOnce(3)
|
||||||
.mockReturnValueOnce(0.1) // 3
|
.mockReturnValueOnce(19);
|
||||||
.mockReturnValueOnce(0.9); // 19
|
|
||||||
|
|
||||||
const result = rollAdvantage(20, 0, true);
|
const result = rollAdvantage(20, 0, true);
|
||||||
expect(result.total).toBe(19);
|
expect(result.total).toBe(19);
|
||||||
@@ -235,13 +229,12 @@ describe("rollAdvantage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns lowest for disadvantage", () => {
|
it("returns lowest for disadvantage", () => {
|
||||||
mathRandomSpy = vi
|
vi.mocked(randomInt)
|
||||||
.spyOn(Math, "random")
|
.mockReturnValueOnce(19)
|
||||||
.mockReturnValueOnce(0.9) // 19
|
.mockReturnValueOnce(3);
|
||||||
.mockReturnValueOnce(0.1); // 3
|
|
||||||
|
|
||||||
const result = rollAdvantage(20, 2, false);
|
const result = rollAdvantage(20, 2, false);
|
||||||
expect(result.total).toBe(5); // 3 + 2
|
expect(result.total).toBe(5);
|
||||||
expect(result.advantageRolls).toEqual([19, 3]);
|
expect(result.advantageRolls).toEqual([19, 3]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// Supports: XdY±Z, kh/kl/dh/dl, ! exploding, r reroll, advantage/disadvantage
|
// Supports: XdY±Z, kh/kl/dh/dl, ! exploding, r reroll, advantage/disadvantage
|
||||||
// Caps: explode chain ≤100, reroll recursion ≤1000
|
// Caps: explode chain ≤100, reroll recursion ≤1000
|
||||||
|
|
||||||
|
import { randomInt } from "./client/random";
|
||||||
|
|
||||||
type KeepDrop = { type: "kh" | "kl" | "dh" | "dl"; count: number } | null;
|
type KeepDrop = { type: "kh" | "kl" | "dh" | "dl"; count: number } | null;
|
||||||
|
|
||||||
type Explode = {
|
type Explode = {
|
||||||
@@ -159,7 +161,7 @@ export function parseDiceNotation(raw: string): Parsed | null {
|
|||||||
|
|
||||||
/** Roll a single die with optional reroll and explode logic */
|
/** Roll a single die with optional reroll and explode logic */
|
||||||
function rollDie(sides: number, explode: Explode, reroll: Reroll): DieResult {
|
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;
|
const original = value;
|
||||||
let rerolledFrom: number | null = null;
|
let rerolledFrom: number | null = null;
|
||||||
const explosions: number[] = [];
|
const explosions: number[] = [];
|
||||||
@@ -170,7 +172,7 @@ function rollDie(sides: number, explode: Explode, reroll: Reroll): DieResult {
|
|||||||
rerolledFrom = value;
|
rerolledFrom = value;
|
||||||
let rerollCount = 0;
|
let rerollCount = 0;
|
||||||
while (reroll.values.has(value) && rerollCount < REROLL_CAP) {
|
while (reroll.values.has(value) && rerollCount < REROLL_CAP) {
|
||||||
value = Math.floor(Math.random() * sides) + 1;
|
value = randomInt(1, sides);
|
||||||
rerollCount++;
|
rerollCount++;
|
||||||
if (reroll.once) break;
|
if (reroll.once) break;
|
||||||
}
|
}
|
||||||
@@ -181,7 +183,7 @@ function rollDie(sides: number, explode: Explode, reroll: Reroll): DieResult {
|
|||||||
exploded = true;
|
exploded = true;
|
||||||
let chain = 0;
|
let chain = 0;
|
||||||
while (chain < EXPLODE_CAP) {
|
while (chain < EXPLODE_CAP) {
|
||||||
let next = Math.floor(Math.random() * sides) + 1;
|
let next = randomInt(1, sides);
|
||||||
if (explode.penetrating) {
|
if (explode.penetrating) {
|
||||||
next = Math.max(1, next - 1); // penetrating subtracts 1
|
next = Math.max(1, next - 1); // penetrating subtracts 1
|
||||||
}
|
}
|
||||||
@@ -275,8 +277,8 @@ export function rollAdvantage(
|
|||||||
modifier: number,
|
modifier: number,
|
||||||
advantage: boolean,
|
advantage: boolean,
|
||||||
): RollResult {
|
): RollResult {
|
||||||
const r1 = Math.floor(Math.random() * sides) + 1;
|
const r1 = randomInt(1, sides);
|
||||||
const r2 = Math.floor(Math.random() * sides) + 1;
|
const r2 = randomInt(1, sides);
|
||||||
const keptVal = advantage ? Math.max(r1, r2) : Math.min(r1, r2);
|
const keptVal = advantage ? Math.max(r1, r2) : Math.min(r1, r2);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user