Compare commits
12 Commits
2ce0ff50b9
...
0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
768e7a390f | ||
|
|
7feaf90eab | ||
|
|
b39e4923e1 | ||
|
|
369feb3cc8 | ||
|
|
51bdb799ae | ||
|
|
1baddad939 | ||
|
|
e701e4dd70 | ||
|
|
e2b0e7d5ee | ||
|
|
635e9c0705 | ||
|
|
582a42e62d | ||
|
|
fc43f440aa | ||
|
|
1cf30b3622 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
.specify
|
||||||
|
specs
|
||||||
|
docs
|
||||||
49
.gitea/workflows/ci.yml
Normal file
49
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm check
|
||||||
|
|
||||||
|
build-image:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
needs: check
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.bahamut.nitrix.one -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
|
||||||
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
docker build -t $IMAGE:$TAG -t $IMAGE:latest .
|
||||||
|
docker push $IMAGE:$TAG
|
||||||
|
docker push $IMAGE:latest
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
|
||||||
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
docker stop initiative || true
|
||||||
|
docker rm initiative || true
|
||||||
|
docker run -d --name initiative --restart unless-stopped -p 8080:80 $IMAGE:$TAG
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-slim AS build
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY packages/domain/package.json packages/domain/
|
||||||
|
COPY packages/application/package.json packages/application/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm --filter web build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
rollInitiativeUseCase,
|
rollInitiativeUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ActionBar } from "./components/action-bar";
|
import { ActionBar } from "./components/action-bar";
|
||||||
import { CombatantRow } from "./components/combatant-row";
|
import { CombatantRow } from "./components/combatant-row";
|
||||||
@@ -150,7 +151,9 @@ export function App() {
|
|||||||
|
|
||||||
const handlePin = useCallback(() => {
|
const handlePin = useCallback(() => {
|
||||||
if (selectedCreatureId) {
|
if (selectedCreatureId) {
|
||||||
setPinnedCreatureId(selectedCreatureId);
|
setPinnedCreatureId((prev) =>
|
||||||
|
prev === selectedCreatureId ? null : selectedCreatureId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [selectedCreatureId]);
|
}, [selectedCreatureId]);
|
||||||
|
|
||||||
@@ -158,6 +161,8 @@ export function App() {
|
|||||||
setPinnedCreatureId(null);
|
setPinnedCreatureId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Auto-scroll to the active combatant when the turn changes
|
// Auto-scroll to the active combatant when the turn changes
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -203,11 +208,17 @@ export function App() {
|
|||||||
|
|
||||||
{/* Scrollable area — combatant list */}
|
{/* Scrollable area — combatant list */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
<div className="flex flex-col px-2 py-2">
|
<div
|
||||||
|
className={`flex flex-col px-2 py-2${encounter.combatants.length === 0 ? " h-full items-center justify-center" : ""}`}
|
||||||
|
>
|
||||||
{encounter.combatants.length === 0 ? (
|
{encounter.combatants.length === 0 ? (
|
||||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
<button
|
||||||
No combatants yet — add one to get started
|
type="button"
|
||||||
</p>
|
onClick={() => actionBarInputRef.current?.focus()}
|
||||||
|
className="animate-breathe cursor-pointer text-muted-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<Plus className="size-14" />
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
encounter.combatants.map((c, i) => (
|
encounter.combatants.map((c, i) => (
|
||||||
<CombatantRow
|
<CombatantRow
|
||||||
@@ -247,6 +258,7 @@ export function App() {
|
|||||||
onViewStatBlock={handleViewStatBlock}
|
onViewStatBlock={handleViewStatBlock}
|
||||||
onBulkImport={handleBulkImport}
|
onBulkImport={handleBulkImport}
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||||
|
inputRef={actionBarInputRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
|
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
|
||||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
import {
|
||||||
|
type FormEvent,
|
||||||
|
type RefObject,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
import type { SearchResult } from "../hooks/use-bestiary.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";
|
||||||
@@ -20,6 +26,7 @@ interface ActionBarProps {
|
|||||||
onViewStatBlock?: (result: SearchResult) => void;
|
onViewStatBlock?: (result: SearchResult) => void;
|
||||||
onBulkImport?: () => void;
|
onBulkImport?: () => void;
|
||||||
bulkImportDisabled?: boolean;
|
bulkImportDisabled?: boolean;
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
function creatureKey(r: SearchResult): string {
|
||||||
@@ -34,6 +41,7 @@ export function ActionBar({
|
|||||||
onViewStatBlock,
|
onViewStatBlock,
|
||||||
onBulkImport,
|
onBulkImport,
|
||||||
bulkImportDisabled,
|
bulkImportDisabled,
|
||||||
|
inputRef,
|
||||||
}: ActionBarProps) {
|
}: ActionBarProps) {
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
@@ -222,15 +230,32 @@ export function ActionBar({
|
|||||||
>
|
>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={nameInput}
|
value={nameInput}
|
||||||
onChange={(e) => handleNameChange(e.target.value)}
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Search creatures to add..."
|
placeholder="+ Add combatants"
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
/>
|
/>
|
||||||
{suggestions.length > 0 && (
|
{suggestions.length > 0 && (
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||||
|
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||||
|
Esc
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
<ul className="max-h-48 overflow-y-auto py-1">
|
<ul className="max-h-48 overflow-y-auto py-1">
|
||||||
{suggestions.map((result, i) => {
|
{suggestions.map((result, i) => {
|
||||||
const key = creatureKey(result);
|
const key = creatureKey(result);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
import { StatBlock } from "./stat-block.js";
|
import { StatBlock } from "./stat-block.js";
|
||||||
@@ -176,6 +177,8 @@ function MobileDrawer({
|
|||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<button
|
<button
|
||||||
@@ -184,7 +187,13 @@ function MobileDrawer({
|
|||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
aria-label="Close stat block"
|
aria-label="Close stat block"
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
|
<div
|
||||||
|
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
||||||
|
style={
|
||||||
|
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||||
|
}
|
||||||
|
{...handlers}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import type {
|
|||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
combatantId,
|
combatantId,
|
||||||
createEncounter,
|
|
||||||
isDomainError,
|
isDomainError,
|
||||||
creatureId as makeCreatureId,
|
creatureId as makeCreatureId,
|
||||||
resolveCreatureName,
|
resolveCreatureName,
|
||||||
@@ -33,24 +32,16 @@ import {
|
|||||||
saveEncounter,
|
saveEncounter,
|
||||||
} from "../persistence/encounter-storage.js";
|
} from "../persistence/encounter-storage.js";
|
||||||
|
|
||||||
function createDemoEncounter(): Encounter {
|
const EMPTY_ENCOUNTER: Encounter = {
|
||||||
const result = createEncounter([
|
combatants: [],
|
||||||
{ id: combatantId("1"), name: "Aria" },
|
activeIndex: 0,
|
||||||
{ id: combatantId("2"), name: "Brak" },
|
roundNumber: 1,
|
||||||
{ id: combatantId("3"), name: "Cael" },
|
};
|
||||||
]);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
throw new Error(`Failed to create demo encounter: ${result.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeEncounter(): Encounter {
|
function initializeEncounter(): Encounter {
|
||||||
const stored = loadEncounter();
|
const stored = loadEncounter();
|
||||||
if (stored !== null) return stored;
|
if (stored !== null) return stored;
|
||||||
return createDemoEncounter();
|
return EMPTY_ENCOUNTER;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveNextId(encounter: Encounter): number {
|
function deriveNextId(encounter: Encounter): number {
|
||||||
|
|||||||
72
apps/web/src/hooks/use-swipe-to-dismiss.ts
Normal file
72
apps/web/src/hooks/use-swipe-to-dismiss.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const DISMISS_THRESHOLD = 0.35;
|
||||||
|
const VELOCITY_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
interface SwipeState {
|
||||||
|
offsetX: number;
|
||||||
|
isSwiping: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSwipeToDismiss(onDismiss: () => void) {
|
||||||
|
const [swipe, setSwipe] = useState<SwipeState>({
|
||||||
|
offsetX: 0,
|
||||||
|
isSwiping: false,
|
||||||
|
});
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const startTime = useRef(0);
|
||||||
|
const panelWidth = useRef(0);
|
||||||
|
const directionLocked = useRef<"horizontal" | "vertical" | null>(null);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
startX.current = touch.clientX;
|
||||||
|
startY.current = touch.clientY;
|
||||||
|
startTime.current = Date.now();
|
||||||
|
directionLocked.current = null;
|
||||||
|
const el = e.currentTarget as HTMLElement;
|
||||||
|
panelWidth.current = el.getBoundingClientRect().width;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - startX.current;
|
||||||
|
const dy = touch.clientY - startY.current;
|
||||||
|
|
||||||
|
if (!directionLocked.current) {
|
||||||
|
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
|
||||||
|
directionLocked.current =
|
||||||
|
Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directionLocked.current === "vertical") return;
|
||||||
|
|
||||||
|
const clampedX = Math.max(0, dx);
|
||||||
|
setSwipe({ offsetX: clampedX, isSwiping: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(() => {
|
||||||
|
if (directionLocked.current !== "horizontal") {
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = (Date.now() - startTime.current) / 1000;
|
||||||
|
const velocity = swipe.offsetX / elapsed / panelWidth.current;
|
||||||
|
const ratio =
|
||||||
|
panelWidth.current > 0 ? swipe.offsetX / panelWidth.current : 0;
|
||||||
|
|
||||||
|
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
}, [swipe.offsetX, onDismiss]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetX: swipe.offsetX,
|
||||||
|
isSwiping: swipe.isSwiping,
|
||||||
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -80,6 +80,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.4;
|
||||||
|
scale: 0.9;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
scale: 1.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-breathe {
|
||||||
|
animation: breathe 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@custom-variant pointer-coarse (@media (pointer: coarse));
|
@custom-variant pointer-coarse (@media (pointer: coarse));
|
||||||
|
|
||||||
@utility animate-confirm-pulse {
|
@utility animate-confirm-pulse {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"!.claude/**",
|
"!.claude/**",
|
||||||
"!.specify/**",
|
"!.specify/**",
|
||||||
"!specs/**",
|
"!specs/**",
|
||||||
"!coverage/**"
|
"!coverage/**",
|
||||||
|
"!.pnpm-store/**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
|
|||||||
9
nginx.conf
Normal file
9
nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,9 +169,9 @@ describe("advanceTurn", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("invariants", () => {
|
describe("invariants", () => {
|
||||||
it("INV-1: createEncounter rejects empty combatant list", () => {
|
it("INV-1: createEncounter accepts empty combatant list", () => {
|
||||||
const result = createEncounter([]);
|
const result = createEncounter([]);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expect(isDomainError(result)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("INV-2: activeIndex always in bounds across all scenarios", () => {
|
it("INV-2: activeIndex always in bounds across all scenarios", () => {
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ function domainError(code: string, message: string): DomainError {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
|
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
|
||||||
* - INV-1: At least one combatant required.
|
* - INV-1: An encounter MAY have zero combatants.
|
||||||
* - INV-2: activeIndex defaults to 0 (always in bounds).
|
* - INV-2: activeIndex defaults to 0 (always in bounds when combatants exist).
|
||||||
* - INV-3: roundNumber defaults to 1 (positive integer).
|
* - INV-3: roundNumber defaults to 1 (positive integer).
|
||||||
*/
|
*/
|
||||||
export function createEncounter(
|
export function createEncounter(
|
||||||
@@ -47,13 +47,10 @@ export function createEncounter(
|
|||||||
activeIndex = 0,
|
activeIndex = 0,
|
||||||
roundNumber = 1,
|
roundNumber = 1,
|
||||||
): Encounter | DomainError {
|
): Encounter | DomainError {
|
||||||
if (combatants.length === 0) {
|
if (
|
||||||
return domainError(
|
combatants.length > 0 &&
|
||||||
"invalid-encounter",
|
(activeIndex < 0 || activeIndex >= combatants.length)
|
||||||
"An encounter must have at least one combatant",
|
) {
|
||||||
);
|
|
||||||
}
|
|
||||||
if (activeIndex < 0 || activeIndex >= combatants.length) {
|
|
||||||
return domainError(
|
return domainError(
|
||||||
"invalid-encounter",
|
"invalid-encounter",
|
||||||
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,
|
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,
|
||||||
|
|||||||
Reference in New Issue
Block a user