Add advantage/disadvantage rolling for initiative
Right-click or long-press the d20 button (per-combatant or Roll All) to open a context menu with Advantage and Disadvantage options. Normal left-click behavior is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
type Creature,
|
type Creature,
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
|
type RollMode,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -147,12 +148,15 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRollInitiative = useCallback(
|
const handleRollInitiative = useCallback(
|
||||||
(id: CombatantId) => {
|
(id: CombatantId, mode: RollMode = "normal") => {
|
||||||
|
const diceRolls: [number, ...number[]] =
|
||||||
|
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
makeStore(),
|
makeStore(),
|
||||||
id,
|
id,
|
||||||
rollDice(),
|
diceRolls,
|
||||||
getCreature,
|
getCreature,
|
||||||
|
mode,
|
||||||
);
|
);
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
setRollSingleSkipped(true);
|
setRollSingleSkipped(true);
|
||||||
@@ -165,12 +169,20 @@ export function App() {
|
|||||||
[makeStore, getCreature, encounter.combatants, sidePanel.showCreature],
|
[makeStore, getCreature, encounter.combatants, sidePanel.showCreature],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(() => {
|
const handleRollAllInitiative = useCallback(
|
||||||
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
(mode: RollMode = "normal") => {
|
||||||
|
const result = rollAllInitiativeUseCase(
|
||||||
|
makeStore(),
|
||||||
|
rollDice,
|
||||||
|
getCreature,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||||
setRollSkippedCount(result.skippedNoSource);
|
setRollSkippedCount(result.skippedNoSource);
|
||||||
}
|
}
|
||||||
}, [makeStore, getCreature]);
|
},
|
||||||
|
[makeStore, getCreature],
|
||||||
|
);
|
||||||
|
|
||||||
const handleViewStatBlock = useCallback(
|
const handleViewStatBlock = useCallback(
|
||||||
(result: SearchResult) => {
|
(result: SearchResult) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter, RollMode } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -12,11 +12,18 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} 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 type { SearchResult } from "../hooks/use-bestiary.js";
|
||||||
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
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 { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||||
@@ -41,7 +48,7 @@ interface ActionBarProps {
|
|||||||
playerCharacters?: readonly PlayerCharacter[];
|
playerCharacters?: readonly PlayerCharacter[];
|
||||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
onRollAllInitiative?: () => void;
|
onRollAllInitiative?: (mode?: RollMode) => void;
|
||||||
showRollAllInitiative?: boolean;
|
showRollAllInitiative?: boolean;
|
||||||
rollAllInitiativeDisabled?: boolean;
|
rollAllInitiativeDisabled?: boolean;
|
||||||
onOpenSourceManager?: () => void;
|
onOpenSourceManager?: () => void;
|
||||||
@@ -479,6 +486,25 @@ export function ActionBar({
|
|||||||
clearCustomFields();
|
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({
|
const overflowItems = buildOverflowItems({
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onOpenSourceManager,
|
onOpenSourceManager,
|
||||||
@@ -607,18 +633,32 @@ export function ActionBar({
|
|||||||
<Button type="submit">Add</Button>
|
<Button type="submit">Add</Button>
|
||||||
)}
|
)}
|
||||||
{showRollAllInitiative && !!onRollAllInitiative && (
|
{showRollAllInitiative && !!onRollAllInitiative && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
onClick={onRollAllInitiative}
|
onClick={() => onRollAllInitiative()}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openRollAllMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
{...rollAllLongPress}
|
||||||
disabled={rollAllInitiativeDisabled}
|
disabled={rollAllInitiativeDisabled}
|
||||||
title="Roll all initiative"
|
title="Roll all initiative"
|
||||||
aria-label="Roll all initiative"
|
aria-label="Roll all initiative"
|
||||||
>
|
>
|
||||||
<D20Icon className="h-6 w-6" />
|
<D20Icon className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{!!rollAllMenuPos && (
|
||||||
|
<RollModeMenu
|
||||||
|
position={rollAllMenuPos}
|
||||||
|
onSelect={(mode) => onRollAllInitiative(mode)}
|
||||||
|
onClose={() => setRollAllMenuPos(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
type ConditionId,
|
type ConditionId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
|
type RollMode,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Book, BookOpen, Brain, X } from "lucide-react";
|
import { Book, BookOpen, Brain, X } from "lucide-react";
|
||||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useLongPress } from "../hooks/use-long-press";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { AcShield } from "./ac-shield";
|
import { AcShield } from "./ac-shield";
|
||||||
import { ConditionPicker } from "./condition-picker";
|
import { ConditionPicker } from "./condition-picker";
|
||||||
@@ -13,6 +15,7 @@ import { ConditionTags } from "./condition-tags";
|
|||||||
import { D20Icon } from "./d20-icon";
|
import { D20Icon } from "./d20-icon";
|
||||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
|
import { RollModeMenu } from "./roll-mode-menu";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ interface CombatantRowProps {
|
|||||||
onToggleConcentration: (id: CombatantId) => void;
|
onToggleConcentration: (id: CombatantId) => void;
|
||||||
onShowStatBlock?: () => void;
|
onShowStatBlock?: () => void;
|
||||||
isStatBlockOpen?: boolean;
|
isStatBlockOpen?: boolean;
|
||||||
onRollInitiative?: (id: CombatantId) => void;
|
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditableName({
|
function EditableName({
|
||||||
@@ -279,11 +282,29 @@ function InitiativeDisplay({
|
|||||||
combatantId: CombatantId;
|
combatantId: CombatantId;
|
||||||
dimmed: boolean;
|
dimmed: boolean;
|
||||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||||
onRollInitiative?: (id: CombatantId) => void;
|
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
|
||||||
}>) {
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(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(() => {
|
const commit = useCallback(() => {
|
||||||
if (draft === "") {
|
if (draft === "") {
|
||||||
@@ -328,9 +349,15 @@ function InitiativeDisplay({
|
|||||||
// Empty + bestiary creature → d20 roll button
|
// Empty + bestiary creature → d20 roll button
|
||||||
if (initiative === undefined && onRollInitiative) {
|
if (initiative === undefined && onRollInitiative) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRollInitiative(combatantId)}
|
onClick={() => onRollInitiative(combatantId)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
{...longPress}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
@@ -340,6 +367,14 @@ function InitiativeDisplay({
|
|||||||
>
|
>
|
||||||
<D20Icon className="h-7 w-7" />
|
<D20Icon className="h-7 w-7" />
|
||||||
</button>
|
</button>
|
||||||
|
{!!menuPos && (
|
||||||
|
<RollModeMenu
|
||||||
|
position={menuPos}
|
||||||
|
onSelect={(mode) => onRollInitiative(combatantId, mode)}
|
||||||
|
onClose={() => setMenuPos(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
apps/web/src/components/roll-mode-menu.tsx
Normal file
88
apps/web/src/components/roll-mode-menu.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="card-glow fixed z-50 min-w-40 rounded-lg border border-border bg-card py-1"
|
||||||
|
style={
|
||||||
|
pos
|
||||||
|
? { top: pos.top, left: pos.left }
|
||||||
|
: { visibility: "hidden" as const }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-emerald-400 text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect("advantage");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronsUp className="h-4 w-4" />
|
||||||
|
Advantage
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-red-400 text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect("disadvantage");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronsDown className="h-4 w-4" />
|
||||||
|
Disadvantage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/web/src/hooks/use-long-press.ts
Normal file
32
apps/web/src/hooks/use-long-press.ts
Normal file
@@ -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<ReturnType<typeof setTimeout>>(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 };
|
||||||
|
}
|
||||||
@@ -161,6 +161,48 @@ describe("rollAllInitiativeUseCase", () => {
|
|||||||
expect(store.saved).toBeNull();
|
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", () => {
|
it("saves encounter once at the end", () => {
|
||||||
const enc = encounterWithCombatants([
|
const enc = encounterWithCombatants([
|
||||||
{ name: "A", creatureId: "creature-a" },
|
{ name: "A", creatureId: "creature-a" },
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
store,
|
store,
|
||||||
combatantId("unknown"),
|
combatantId("unknown"),
|
||||||
10,
|
[10],
|
||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
store,
|
store,
|
||||||
combatantId("Fighter"),
|
combatantId("Fighter"),
|
||||||
10,
|
[10],
|
||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
store,
|
store,
|
||||||
combatantId("Goblin"),
|
combatantId("Goblin"),
|
||||||
10,
|
[10],
|
||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
store,
|
store,
|
||||||
combatantId("Goblin"),
|
combatantId("Goblin"),
|
||||||
10,
|
[10],
|
||||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -124,6 +124,42 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
|
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", () => {
|
it("applies initiative proficiency bonus correctly", () => {
|
||||||
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
|
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
|
||||||
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
|
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
|
||||||
@@ -145,7 +181,7 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
store,
|
store,
|
||||||
combatantId("Monster"),
|
combatantId("Monster"),
|
||||||
8,
|
[8],
|
||||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
|
type RollMode,
|
||||||
rollInitiative,
|
rollInitiative,
|
||||||
|
selectRoll,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
@@ -19,6 +21,7 @@ export function rollAllInitiativeUseCase(
|
|||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
rollDice: () => number,
|
rollDice: () => number,
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
|
mode: RollMode = "normal",
|
||||||
): RollAllResult | DomainError {
|
): RollAllResult | DomainError {
|
||||||
let encounter = store.get();
|
let encounter = store.get();
|
||||||
const allEvents: DomainEvent[] = [];
|
const allEvents: DomainEvent[] = [];
|
||||||
@@ -39,7 +42,10 @@ export function rollAllInitiativeUseCase(
|
|||||||
cr: creature.cr,
|
cr: creature.cr,
|
||||||
initiativeProficiency: creature.initiativeProficiency,
|
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)) {
|
if (isDomainError(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
|
type RollMode,
|
||||||
rollInitiative,
|
rollInitiative,
|
||||||
|
selectRoll,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
@@ -14,8 +16,9 @@ import type { EncounterStore } from "./ports.js";
|
|||||||
export function rollInitiativeUseCase(
|
export function rollInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
diceRoll: number,
|
diceRolls: readonly [number, ...number[]],
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
|
mode: RollMode = "normal",
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
const encounter = store.get();
|
||||||
const combatant = encounter.combatants.find((c) => c.id === combatantId);
|
const combatant = encounter.combatants.find((c) => c.id === combatantId);
|
||||||
@@ -50,7 +53,11 @@ export function rollInitiativeUseCase(
|
|||||||
cr: creature.cr,
|
cr: creature.cr,
|
||||||
initiativeProficiency: creature.initiativeProficiency,
|
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)) {
|
if (isDomainError(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { isDomainError } from "../types.js";
|
||||||
import { expectDomainError } from "./test-helpers.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ export {
|
|||||||
removeCombatant,
|
removeCombatant,
|
||||||
} from "./remove-combatant.js";
|
} from "./remove-combatant.js";
|
||||||
export { retreatTurn } from "./retreat-turn.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 SetAcSuccess, setAc } from "./set-ac.js";
|
||||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import type { DomainError } from "./types.js";
|
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.
|
* Pure function that computes initiative from a resolved dice roll and modifier.
|
||||||
* The dice roll must be an integer in [1, 20].
|
* The dice roll must be an integer in [1, 20].
|
||||||
|
|||||||
Reference in New Issue
Block a user