(null);
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
+
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const rect = el.getBoundingClientRect();
+ const vw = document.documentElement.clientWidth;
+ const vh = document.documentElement.clientHeight;
+
+ let left = position.x;
+ let top = position.y;
+
+ if (left + rect.width > vw) left = vw - rect.width - 8;
+ if (left < 8) left = 8;
+ if (top + rect.height > vh) top = position.y - rect.height;
+ if (top < 8) top = 8;
+
+ setPos({ top, left });
+ }, [position.x, position.y]);
+
+ useEffect(() => {
+ function handleMouseDown(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ onClose();
+ }
+ }
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("mousedown", handleMouseDown);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("mousedown", handleMouseDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [onClose]);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/src/hooks/use-long-press.ts b/apps/web/src/hooks/use-long-press.ts
new file mode 100644
index 0000000..7da7719
--- /dev/null
+++ b/apps/web/src/hooks/use-long-press.ts
@@ -0,0 +1,32 @@
+import { useCallback, useRef } from "react";
+
+const LONG_PRESS_MS = 500;
+
+export function useLongPress(onLongPress: (e: React.TouchEvent) => void) {
+ const timerRef = useRef>(undefined);
+ const firedRef = useRef(false);
+
+ const onTouchStart = useCallback(
+ (e: React.TouchEvent) => {
+ firedRef.current = false;
+ timerRef.current = setTimeout(() => {
+ firedRef.current = true;
+ onLongPress(e);
+ }, LONG_PRESS_MS);
+ },
+ [onLongPress],
+ );
+
+ const onTouchEnd = useCallback((e: React.TouchEvent) => {
+ clearTimeout(timerRef.current);
+ if (firedRef.current) {
+ e.preventDefault();
+ }
+ }, []);
+
+ const onTouchMove = useCallback(() => {
+ clearTimeout(timerRef.current);
+ }, []);
+
+ return { onTouchStart, onTouchEnd, onTouchMove };
+}
diff --git a/packages/application/src/__tests__/roll-all-initiative-use-case.test.ts b/packages/application/src/__tests__/roll-all-initiative-use-case.test.ts
index cd49b89..d44846e 100644
--- a/packages/application/src/__tests__/roll-all-initiative-use-case.test.ts
+++ b/packages/application/src/__tests__/roll-all-initiative-use-case.test.ts
@@ -161,6 +161,48 @@ describe("rollAllInitiativeUseCase", () => {
expect(store.saved).toBeNull();
});
+ it("uses higher roll with advantage", () => {
+ const enc = encounterWithCombatants([
+ { name: "A", creatureId: "creature-a" },
+ ]);
+ const store = stubEncounterStore(enc);
+ const creature = makeCreature("creature-a");
+
+ // Alternating rolls: 5, 15 → advantage picks 15
+ // Dex 14 → modifier +2, so 15 + 2 = 17
+ let call = 0;
+ const result = rollAllInitiativeUseCase(
+ store,
+ () => (++call % 2 === 1 ? 5 : 15),
+ (id) => (id === CREATURE_A ? creature : undefined),
+ "advantage",
+ );
+
+ expectSuccess(result);
+ expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
+ });
+
+ it("uses lower roll with disadvantage", () => {
+ const enc = encounterWithCombatants([
+ { name: "A", creatureId: "creature-a" },
+ ]);
+ const store = stubEncounterStore(enc);
+ const creature = makeCreature("creature-a");
+
+ // Alternating rolls: 15, 5 → disadvantage picks 5
+ // Dex 14 → modifier +2, so 5 + 2 = 7
+ let call = 0;
+ const result = rollAllInitiativeUseCase(
+ store,
+ () => (++call % 2 === 1 ? 15 : 5),
+ (id) => (id === CREATURE_A ? creature : undefined),
+ "disadvantage",
+ );
+
+ expectSuccess(result);
+ expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
+ });
+
it("saves encounter once at the end", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
diff --git a/packages/application/src/__tests__/roll-initiative-use-case.test.ts b/packages/application/src/__tests__/roll-initiative-use-case.test.ts
index 5a27a0d..10b5bd9 100644
--- a/packages/application/src/__tests__/roll-initiative-use-case.test.ts
+++ b/packages/application/src/__tests__/roll-initiative-use-case.test.ts
@@ -61,7 +61,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase(
store,
combatantId("unknown"),
- 10,
+ [10],
() => undefined,
);
@@ -80,7 +80,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase(
store,
combatantId("Fighter"),
- 10,
+ [10],
() => undefined,
);
@@ -96,7 +96,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
- 10,
+ [10],
() => undefined,
);
@@ -116,7 +116,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
- 10,
+ [10],
(id) => (id === GOBLIN_ID ? creature : undefined),
);
@@ -124,6 +124,42 @@ describe("rollInitiativeUseCase", () => {
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
});
+ it("uses higher roll with advantage", () => {
+ const creature = makeCreature();
+ const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
+ const store = stubEncounterStore(enc);
+
+ // Dex 14 -> modifier +2, advantage picks max(5, 15) = 15, 15 + 2 = 17
+ const result = rollInitiativeUseCase(
+ store,
+ combatantId("Goblin"),
+ [5, 15],
+ (id) => (id === GOBLIN_ID ? creature : undefined),
+ "advantage",
+ );
+
+ expect(isDomainError(result)).toBe(false);
+ expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
+ });
+
+ it("uses lower roll with disadvantage", () => {
+ const creature = makeCreature();
+ const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
+ const store = stubEncounterStore(enc);
+
+ // Dex 14 -> modifier +2, disadvantage picks min(5, 15) = 5, 5 + 2 = 7
+ const result = rollInitiativeUseCase(
+ store,
+ combatantId("Goblin"),
+ [5, 15],
+ (id) => (id === GOBLIN_ID ? creature : undefined),
+ "disadvantage",
+ );
+
+ expect(isDomainError(result)).toBe(false);
+ expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
+ });
+
it("applies initiative proficiency bonus correctly", () => {
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
@@ -145,7 +181,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase(
store,
combatantId("Monster"),
- 8,
+ [8],
(id) => (id === GOBLIN_ID ? creature : undefined),
);
diff --git a/packages/application/src/roll-all-initiative-use-case.ts b/packages/application/src/roll-all-initiative-use-case.ts
index b3fd646..0c18d6c 100644
--- a/packages/application/src/roll-all-initiative-use-case.ts
+++ b/packages/application/src/roll-all-initiative-use-case.ts
@@ -5,7 +5,9 @@ import {
type DomainError,
type DomainEvent,
isDomainError,
+ type RollMode,
rollInitiative,
+ selectRoll,
setInitiative,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
@@ -19,6 +21,7 @@ export function rollAllInitiativeUseCase(
store: EncounterStore,
rollDice: () => number,
getCreature: (id: CreatureId) => Creature | undefined,
+ mode: RollMode = "normal",
): RollAllResult | DomainError {
let encounter = store.get();
const allEvents: DomainEvent[] = [];
@@ -39,7 +42,10 @@ export function rollAllInitiativeUseCase(
cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency,
});
- const value = rollInitiative(rollDice(), modifier);
+ const roll1 = rollDice();
+ const effectiveRoll =
+ mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
+ const value = rollInitiative(effectiveRoll, modifier);
if (isDomainError(value)) {
return value;
diff --git a/packages/application/src/roll-initiative-use-case.ts b/packages/application/src/roll-initiative-use-case.ts
index 608334a..187e5ae 100644
--- a/packages/application/src/roll-initiative-use-case.ts
+++ b/packages/application/src/roll-initiative-use-case.ts
@@ -6,7 +6,9 @@ import {
type DomainError,
type DomainEvent,
isDomainError,
+ type RollMode,
rollInitiative,
+ selectRoll,
setInitiative,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
@@ -14,8 +16,9 @@ import type { EncounterStore } from "./ports.js";
export function rollInitiativeUseCase(
store: EncounterStore,
combatantId: CombatantId,
- diceRoll: number,
+ diceRolls: readonly [number, ...number[]],
getCreature: (id: CreatureId) => Creature | undefined,
+ mode: RollMode = "normal",
): DomainEvent[] | DomainError {
const encounter = store.get();
const combatant = encounter.combatants.find((c) => c.id === combatantId);
@@ -50,7 +53,11 @@ export function rollInitiativeUseCase(
cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency,
});
- const value = rollInitiative(diceRoll, modifier);
+ const effectiveRoll =
+ mode === "normal"
+ ? diceRolls[0]
+ : selectRoll(diceRolls[0], diceRolls[1] ?? diceRolls[0], mode);
+ const value = rollInitiative(effectiveRoll, modifier);
if (isDomainError(value)) {
return value;
diff --git a/packages/domain/src/__tests__/roll-initiative.test.ts b/packages/domain/src/__tests__/roll-initiative.test.ts
index 6e24f0d..1580d10 100644
--- a/packages/domain/src/__tests__/roll-initiative.test.ts
+++ b/packages/domain/src/__tests__/roll-initiative.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { rollInitiative } from "../roll-initiative.js";
+import { rollInitiative, selectRoll } from "../roll-initiative.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
@@ -63,3 +63,31 @@ describe("rollInitiative", () => {
});
});
});
+
+describe("selectRoll", () => {
+ it("normal mode returns the first roll", () => {
+ expect(selectRoll(8, 15, "normal")).toBe(8);
+ });
+
+ it("advantage returns the higher roll", () => {
+ expect(selectRoll(8, 15, "advantage")).toBe(15);
+ });
+
+ it("advantage returns the higher roll (reversed)", () => {
+ expect(selectRoll(15, 8, "advantage")).toBe(15);
+ });
+
+ it("disadvantage returns the lower roll", () => {
+ expect(selectRoll(8, 15, "disadvantage")).toBe(8);
+ });
+
+ it("disadvantage returns the lower roll (reversed)", () => {
+ expect(selectRoll(15, 8, "disadvantage")).toBe(8);
+ });
+
+ it("equal rolls return the same value for all modes", () => {
+ expect(selectRoll(12, 12, "normal")).toBe(12);
+ expect(selectRoll(12, 12, "advantage")).toBe(12);
+ expect(selectRoll(12, 12, "disadvantage")).toBe(12);
+ });
+});
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index b6a772d..e82864d 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -84,7 +84,11 @@ export {
removeCombatant,
} from "./remove-combatant.js";
export { retreatTurn } from "./retreat-turn.js";
-export { rollInitiative } from "./roll-initiative.js";
+export {
+ type RollMode,
+ rollInitiative,
+ selectRoll,
+} from "./roll-initiative.js";
export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetHpSuccess, setHp } from "./set-hp.js";
export {
diff --git a/packages/domain/src/roll-initiative.ts b/packages/domain/src/roll-initiative.ts
index a84c7e0..9615354 100644
--- a/packages/domain/src/roll-initiative.ts
+++ b/packages/domain/src/roll-initiative.ts
@@ -1,5 +1,21 @@
import type { DomainError } from "./types.js";
+export type RollMode = "normal" | "advantage" | "disadvantage";
+
+/**
+ * Selects the effective roll from two dice values based on the roll mode.
+ * Advantage takes the higher, disadvantage takes the lower.
+ */
+export function selectRoll(
+ roll1: number,
+ roll2: number,
+ mode: RollMode,
+): number {
+ if (mode === "advantage") return Math.max(roll1, roll2);
+ if (mode === "disadvantage") return Math.min(roll1, roll2);
+ return roll1;
+}
+
/**
* Pure function that computes initiative from a resolved dice roll and modifier.
* The dice roll must be an integer in [1, 20].