Compare commits
2 Commits
0.9.0
...
6584d8d064
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6584d8d064 | ||
|
|
7f38cbab73 |
@@ -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);
|
||||
const handleRollAllInitiative = useCallback(
|
||||
(mode: RollMode = "normal") => {
|
||||
const result = rollAllInitiativeUseCase(
|
||||
makeStore(),
|
||||
rollDice,
|
||||
getCreature,
|
||||
mode,
|
||||
);
|
||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||
setRollSkippedCount(result.skippedNoSource);
|
||||
}
|
||||
}, [makeStore, getCreature]);
|
||||
},
|
||||
[makeStore, getCreature],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
@@ -210,9 +222,9 @@ export function App() {
|
||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||
useEffect(() => {
|
||||
if (activeCreatureId && sidePanel.panelView.mode === "creature") {
|
||||
sidePanel.showCreature(activeCreatureId);
|
||||
sidePanel.updateCreature(activeCreatureId);
|
||||
}
|
||||
}, [activeCreatureId, sidePanel.panelView.mode, sidePanel.showCreature]);
|
||||
}, [activeCreatureId, sidePanel.panelView.mode, sidePanel.updateCreature]);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -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({
|
||||
<Button type="submit">Add</Button>
|
||||
)}
|
||||
{showRollAllInitiative && !!onRollAllInitiative && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={onRollAllInitiative}
|
||||
onClick={() => onRollAllInitiative()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openRollAllMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...rollAllLongPress}
|
||||
disabled={rollAllInitiativeDisabled}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!rollAllMenuPos && (
|
||||
<RollModeMenu
|
||||
position={rollAllMenuPos}
|
||||
onSelect={(mode) => onRollAllInitiative(mode)}
|
||||
onClose={() => setRollAllMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||
</form>
|
||||
|
||||
@@ -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<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(() => {
|
||||
if (draft === "") {
|
||||
@@ -328,9 +349,15 @@ function InitiativeDisplay({
|
||||
// Empty + bestiary creature → d20 roll button
|
||||
if (initiative === undefined && onRollInitiative) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRollInitiative(combatantId)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...longPress}
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||
dimmed && "opacity-50",
|
||||
@@ -340,6 +367,14 @@ function InitiativeDisplay({
|
||||
>
|
||||
<D20Icon className="h-7 w-7" />
|
||||
</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 };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ interface SidePanelState {
|
||||
|
||||
interface SidePanelActions {
|
||||
showCreature: (creatureId: CreatureId) => void;
|
||||
updateCreature: (creatureId: CreatureId) => void;
|
||||
showBulkImport: () => void;
|
||||
showSourceManager: () => void;
|
||||
dismissPanel: () => void;
|
||||
@@ -52,6 +53,10 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
setIsRightPanelCollapsed(false);
|
||||
}, []);
|
||||
|
||||
const updateCreature = useCallback((creatureId: CreatureId) => {
|
||||
setPanelView({ mode: "creature", creatureId });
|
||||
}, []);
|
||||
|
||||
const showBulkImport = useCallback(() => {
|
||||
setPanelView({ mode: "bulk-import" });
|
||||
setIsRightPanelCollapsed(false);
|
||||
@@ -91,6 +96,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
pinnedCreatureId,
|
||||
isWideDesktop,
|
||||
showCreature,
|
||||
updateCreature,
|
||||
showBulkImport,
|
||||
showSourceManager,
|
||||
dismissPanel,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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].
|
||||
|
||||
Reference in New Issue
Block a user