diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 94805c2..d7f2593 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,7 @@ import { type Creature, type CreatureId, isDomainError, + type RollMode, } from "@initiative/domain"; import { useCallback, @@ -147,12 +148,15 @@ export function App() { ); const handleRollInitiative = useCallback( - (id: CombatantId) => { + (id: CombatantId, mode: RollMode = "normal") => { + const diceRolls: [number, ...number[]] = + mode === "normal" ? [rollDice()] : [rollDice(), rollDice()]; const result = rollInitiativeUseCase( makeStore(), id, - rollDice(), + diceRolls, getCreature, + mode, ); if (isDomainError(result)) { setRollSingleSkipped(true); @@ -165,12 +169,20 @@ export function App() { [makeStore, getCreature, encounter.combatants, sidePanel.showCreature], ); - const handleRollAllInitiative = useCallback(() => { - const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); - if (!isDomainError(result) && result.skippedNoSource > 0) { - setRollSkippedCount(result.skippedNoSource); - } - }, [makeStore, getCreature]); + const handleRollAllInitiative = useCallback( + (mode: RollMode = "normal") => { + const result = rollAllInitiativeUseCase( + makeStore(), + rollDice, + getCreature, + mode, + ); + if (!isDomainError(result) && result.skippedNoSource > 0) { + setRollSkippedCount(result.skippedNoSource); + } + }, + [makeStore, getCreature], + ); const handleViewStatBlock = useCallback( (result: SearchResult) => { diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index 8f284f4..cc55cdf 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -1,4 +1,4 @@ -import type { PlayerCharacter } from "@initiative/domain"; +import type { PlayerCharacter, RollMode } from "@initiative/domain"; import { Check, Eye, @@ -12,11 +12,18 @@ import { Sun, Users, } from "lucide-react"; -import React, { type RefObject, useDeferredValue, useState } from "react"; +import React, { + type RefObject, + useCallback, + useDeferredValue, + useState, +} from "react"; import type { SearchResult } from "../hooks/use-bestiary.js"; +import { useLongPress } from "../hooks/use-long-press.js"; import { cn } from "../lib/utils.js"; import { D20Icon } from "./d20-icon.js"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; +import { RollModeMenu } from "./roll-mode-menu.js"; import { Button } from "./ui/button.js"; import { Input } from "./ui/input.js"; import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js"; @@ -41,7 +48,7 @@ interface ActionBarProps { playerCharacters?: readonly PlayerCharacter[]; onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; onManagePlayers?: () => void; - onRollAllInitiative?: () => void; + onRollAllInitiative?: (mode?: RollMode) => void; showRollAllInitiative?: boolean; rollAllInitiativeDisabled?: boolean; onOpenSourceManager?: () => void; @@ -479,6 +486,25 @@ export function ActionBar({ clearCustomFields(); }; + const [rollAllMenuPos, setRollAllMenuPos] = useState<{ + x: number; + y: number; + } | null>(null); + + const openRollAllMenu = useCallback((x: number, y: number) => { + setRollAllMenuPos({ x, y }); + }, []); + + const rollAllLongPress = useLongPress( + useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0]; + if (touch) openRollAllMenu(touch.clientX, touch.clientY); + }, + [openRollAllMenu], + ), + ); + const overflowItems = buildOverflowItems({ onManagePlayers, onOpenSourceManager, @@ -607,18 +633,32 @@ export function ActionBar({ )} {showRollAllInitiative && !!onRollAllInitiative && ( - + <> + + {!!rollAllMenuPos && ( + onRollAllInitiative(mode)} + onClose={() => setRollAllMenuPos(null)} + /> + )} + )} {overflowItems.length > 0 && } diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx index a5cd6e0..f7473a2 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -3,9 +3,11 @@ import { type ConditionId, deriveHpStatus, type PlayerIcon, + type RollMode, } from "@initiative/domain"; import { Book, BookOpen, Brain, X } from "lucide-react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react"; +import { useLongPress } from "../hooks/use-long-press"; import { cn } from "../lib/utils"; import { AcShield } from "./ac-shield"; import { ConditionPicker } from "./condition-picker"; @@ -13,6 +15,7 @@ import { ConditionTags } from "./condition-tags"; import { D20Icon } from "./d20-icon"; import { HpAdjustPopover } from "./hp-adjust-popover"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; +import { RollModeMenu } from "./roll-mode-menu"; import { ConfirmButton } from "./ui/confirm-button"; import { Input } from "./ui/input"; @@ -42,7 +45,7 @@ interface CombatantRowProps { onToggleConcentration: (id: CombatantId) => void; onShowStatBlock?: () => void; isStatBlockOpen?: boolean; - onRollInitiative?: (id: CombatantId) => void; + onRollInitiative?: (id: CombatantId, mode?: RollMode) => void; } function EditableName({ @@ -279,11 +282,29 @@ function InitiativeDisplay({ combatantId: CombatantId; dimmed: boolean; onSetInitiative: (id: CombatantId, value: number | undefined) => void; - onRollInitiative?: (id: CombatantId) => void; + onRollInitiative?: (id: CombatantId, mode?: RollMode) => void; }>) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(initiative?.toString() ?? ""); const inputRef = useRef(null); + const [menuPos, setMenuPos] = useState<{ + x: number; + y: number; + } | null>(null); + + const openMenu = useCallback((x: number, y: number) => { + setMenuPos({ x, y }); + }, []); + + const longPress = useLongPress( + useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0]; + if (touch) openMenu(touch.clientX, touch.clientY); + }, + [openMenu], + ), + ); const commit = useCallback(() => { if (draft === "") { @@ -328,18 +349,32 @@ function InitiativeDisplay({ // Empty + bestiary creature → d20 roll button if (initiative === undefined && onRollInitiative) { return ( - + {!!menuPos && ( + onRollInitiative(combatantId, mode)} + onClose={() => setMenuPos(null)} + /> )} - title="Roll initiative" - aria-label="Roll initiative" - > - - + ); } diff --git a/apps/web/src/components/roll-mode-menu.tsx b/apps/web/src/components/roll-mode-menu.tsx new file mode 100644 index 0000000..b43d16d --- /dev/null +++ b/apps/web/src/components/roll-mode-menu.tsx @@ -0,0 +1,88 @@ +import type { RollMode } from "@initiative/domain"; +import { ChevronsDown, ChevronsUp } from "lucide-react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; + +interface RollModeMenuProps { + readonly position: { x: number; y: number }; + readonly onSelect: (mode: RollMode) => void; + readonly onClose: () => void; +} + +export function RollModeMenu({ + position, + onSelect, + onClose, +}: RollModeMenuProps) { + const ref = useRef(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].