3 Commits

Author SHA1 Message Date
robert ec578eeef5 Bump to v0.4, document self-hosted backend in README
The rework-backend merge added an optional self-hosted Express/ws/SQLite
backend (npm workspaces, single-container Docker deployment) alongside
the existing Firebase default. Bump APP_VERSION and refresh README to
cover both storage modes and the new repo layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:59:46 -04:00
robert c54fd88c32 fix(docker): copy workspace package.json files before npm install
npm workspaces needs shared/package.json and server/package.json present
at install time to link @ttrpg/shared, otherwise the build stage fails
with "Module not found: Error: Can't resolve '@ttrpg/shared'".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:40:23 -04:00
robert e31fe15382 Merge pull request 'Rework backend' (#1) from rework-backend into main
Reviewed-on: #1
2026-07-01 19:29:33 -04:00
12 changed files with 127 additions and 456 deletions
+4 -1
View File
@@ -7,8 +7,11 @@ LABEL stage="build-local-testing"
WORKDIR /app WORKDIR /app
# Copy package.json and package-lock.json (or yarn.lock) # Copy root package.json/lockfile plus workspace manifests so npm can
# resolve the @ttrpg/shared and server workspace links during install.
COPY package*.json ./ COPY package*.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# Install dependencies using the lock file for consistency # Install dependencies using the lock file for consistency
RUN npm install RUN npm install
+64 -12
View File
@@ -1,4 +1,4 @@
# TTRPG Initiative Tracker (v0.2.5) # TTRPG Initiative Tracker (v0.4)
![Here it is in use](images/in_use.png) ![Here it is in use](images/in_use.png)
@@ -12,6 +12,8 @@ A web-based application designed to help Dungeon Masters (DMs) manage and displa
Have you tried it? Got feedback or questions? Discuss here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16) Have you tried it? Got feedback or questions? Discuss here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16)
As of v0.4, the app can optionally run against a self-hosted backend (Node/Express/ws/SQLite, shipped as a single Docker container) instead of Firebase — useful if you'd rather not depend on a Google account. Firebase remains the default; see [Self-Hosted Backend (Optional)](#self-hosted-backend-optional) below.
## Features ## Features
![DM View.](images/dm_view.png) ![DM View.](images/dm_view.png)
@@ -59,7 +61,7 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
* A **fullscreen button** (top-right corner) toggles the browser into fullscreen mode — ideal for a dedicated second monitor. * A **fullscreen button** (top-right corner) toggles the browser into fullscreen mode — ideal for a dedicated second monitor.
* A **prevent sleep toggle** (moon/coffee icon, top-right corner) uses the browser Wake Lock API to keep the screen on while active. * A **prevent sleep toggle** (moon/coffee icon, top-right corner) uses the browser Wake Lock API to keep the screen on while active.
* **Combat Action Log:** A running log of combat events (HP changes, condition changes, turn advances, participant additions/removals, encounter starts/ends, etc.) is available at `/logs`. Entries are timestamped and tagged with the encounter name. Most entries include an **↩ Undo** button that rolls back the action in Firestore (restoring HP, conditions, turn order, etc.). Rolled-back entries are greyed out with a strikethrough. The log can be cleared in bulk from that page. * **Combat Action Log:** A running log of combat events (HP changes, condition changes, turn advances, participant additions/removals, encounter starts/ends, etc.) is available at `/logs`. Entries are timestamped and tagged with the encounter name. Most entries include an **↩ Undo** button that rolls back the action in Firestore (restoring HP, conditions, turn order, etc.). Rolled-back entries are greyed out with a strikethrough. The log can be cleared in bulk from that page.
* **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display. * **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display (or a self-hosted WebSocket backend, see below).
* **Initiative Tie-Breaking:** DMs can drag-and-drop participants with tied initiative scores (before an encounter starts or while paused) to set a manual order. * **Initiative Tie-Breaking:** DMs can drag-and-drop participants with tied initiative scores (before an encounter starts or while paused) to set a manual order.
* **Responsive Design:** Styled with Tailwind CSS. * **Responsive Design:** Styled with Tailwind CSS.
* **Confirmation Modals:** Used for destructive actions like deleting campaigns, characters, encounters, or ending combat. * **Confirmation Modals:** Used for destructive actions like deleting campaigns, characters, encounters, or ending combat.
@@ -68,8 +70,9 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
* **Frontend:** React * **Frontend:** React
* **Styling:** Tailwind CSS * **Styling:** Tailwind CSS
* **Backend/Database:** Firebase Firestore (for real-time data) * **Backend/Database:** Firebase Firestore (default), or a self-hosted Node/Express + `ws` + SQLite (`better-sqlite3`) backend behind Caddy (optional, see [Self-Hosted Backend](#self-hosted-backend-optional))
* **Authentication:** Firebase Anonymous Authentication * **Authentication:** Firebase Anonymous Authentication (Firebase mode only; the self-hosted backend has no auth layer — intended for trusted/local networks)
* **Shared logic:** Turn-order state machine (`shared/`) is framework-agnostic and used by both the frontend and the self-hosted backend, covered by a Jest test suite
## App Usage Overview ## App Usage Overview
@@ -128,11 +131,13 @@ This flow allows the DM to prepare and run encounters efficiently while providin
### Prerequisites ### Prerequisites
* **Node.js and npm:** Ensure you have Node.js (which includes npm) installed. You can download it from [nodejs.org](https://nodejs.org/). * **Node.js and npm:** Ensure you have Node.js (which includes npm) installed. You can download it from [nodejs.org](https://nodejs.org/).
* **Firebase Project:** You'll need a Firebase project with: * **Firebase Project** (only if using the default Firebase storage mode): You'll need a Firebase project with:
* Firestore Database created and initialized. * Firestore Database created and initialized.
* Anonymous Authentication enabled in the "Authentication" > "Sign-in method" tab. * Anonymous Authentication enabled in the "Authentication" > "Sign-in method" tab.
* **Git:** For cloning the repository. * **Git:** For cloning the repository.
The project is an npm workspaces monorepo (`server/`, `shared/`, plus the CRA frontend at the root) — a single `npm install` at the repo root installs everything.
### Local Development Setup (using npm) ### Local Development Setup (using npm)
1. **Clone the Repository:** 1. **Clone the Repository:**
@@ -190,7 +195,14 @@ This flow allows the DM to prepare and run encounters efficiently while providin
### Deployment with Docker ### Deployment with Docker
This project includes a `Dockerfile` to containerize the application for deployment. It uses a multi-stage build: There are two Docker paths, depending on which storage mode you want:
* **Firebase mode (default):** the root `Dockerfile` builds a static frontend-only image, described below.
* **Self-hosted mode:** the `docker/` directory builds a single container running the Node backend + SQLite + the frontend behind Caddy, with no Firebase dependency. See [Self-Hosted Backend (Optional)](#self-hosted-backend-optional).
#### Firebase-mode image (root `Dockerfile`)
This project includes a `Dockerfile` to containerize the Firebase-backed application for deployment. It uses a multi-stage build:
* **Stage 1 (build):** Installs dependencies, copies your `.env.local` (for local testing builds), and builds the static React application using `npm run build`. * **Stage 1 (build):** Installs dependencies, copies your `.env.local` (for local testing builds), and builds the static React application using `npm run build`.
* **Stage 2 (nginx):** Uses an Nginx server to serve the static files produced in the build stage. * **Stage 2 (nginx):** Uses an Nginx server to serve the static files produced in the build stage.
@@ -225,6 +237,30 @@ This project includes a `Dockerfile` to containerize the application for deploym
* If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment. * If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment.
* **Implement strict Firebase Security Rules** appropriate for a production application to protect your data. * **Implement strict Firebase Security Rules** appropriate for a production application to protect your data.
### Self-Hosted Backend (Optional)
If you'd rather not depend on Firebase/Google, the app can run entirely self-hosted: a Node/Express + `ws` backend backed by SQLite, with the frontend talking to it instead of Firestore. Intended for trusted/local networks (no auth layer yet).
**Quickest path — Docker Compose (single container: Caddy + Node + SQLite):**
```bash
docker compose -f docker/docker-compose.yml up --build
```
This serves the app at `http://localhost:8080` (override with `PORT`), proxies `/api` and `/ws` to the backend, and persists the SQLite database in a named Docker volume. Override the Firestore-style app-id namespace with `TRACKER_APP_ID` if needed.
**Local dev (backend + frontend separately):**
```bash
npm install # installs root, server/, and shared/ workspaces
npm run server:dev # starts backend on :4001 (SQLite at server/data/tracker.sqlite)
# in another terminal:
REACT_APP_STORAGE=ws \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
npm start
```
See `docs/DEVELOPMENT.md` for the full architecture (generic KV doc store, storage adapter interface, test layers) and `docs/REWORK_PLAN.md` for the design rationale.
## Project Structure ## Project Structure
<pre> <pre>
@@ -233,21 +269,37 @@ ttrpg-initiative-tracker/
├── .env.example # Example environment variables ├── .env.example # Example environment variables
├── .env.local # Local environment variables (ignored by Git) ├── .env.local # Local environment variables (ignored by Git)
├── .gitignore # Specifies intentionally untracked files that Git should ignore ├── .gitignore # Specifies intentionally untracked files that Git should ignore
├── Dockerfile # Instructions to build the Docker image ├── Dockerfile # Firebase-mode image (frontend-only, served by nginx)
├── package.json # Workspaces root: frontend + server + shared
├── package-lock.json # Records exact versions of dependencies ├── package-lock.json # Records exact versions of dependencies
├── package.json # Project metadata and dependencies
├── postcss.config.js # PostCSS configuration (for Tailwind CSS) ├── postcss.config.js # PostCSS configuration (for Tailwind CSS)
├── tailwind.config.js # Tailwind CSS configuration ├── tailwind.config.js # Tailwind CSS configuration
├── docker/ # Self-hosted deployment: Dockerfile, docker-compose.yml, Caddyfile
├── docs/ # Rework plan, dev setup, testing, encounter-builder guide, glossary
├── scripts/ # Manual demo/ops tooling (e.g. replay-combat.js)
├── tests/ # Exploratory audit tooling (not part of the automated suite)
├── public/ # Static assets ├── public/ # Static assets
│ ├── favicon.ico │ ├── favicon.ico
│ ├── index.html # Main HTML template │ ├── index.html # Main HTML template
│ └── manifest.json │ └── manifest.json
── src/ # React application source code ── src/ # React frontend (Create React App)
├── App.js # Main application component ├── App.js # Main application component
├── index.css # Global styles (including Tailwind directives) │ ├── storage/ # Storage adapter layer: firebase / ws / memory
── index.js # React entry point │ ├── index.css # Global styles (including Tailwind directives)
│ └── index.js # React entry point
├── server/ # Self-hosted backend: Express + ws + SQLite (better-sqlite3)
└── shared/ # Framework-agnostic turn-order logic, used by frontend + server
</pre> </pre>
## Further Reading
* `docs/DEVELOPMENT.md` — setup, running the backend, test suites
* `docs/REWORK_PLAN.md` — why/how the self-hosted backend was added
* `docs/TESTING.md` — test layers and how to run them
* `docs/ENCOUNTER_BUILDER.md` — entity model and storage paths behind the DM interface
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
* `TODO.md` — known bugs and backlog
## Contributing ## Contributing
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change. If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
+8 -22
View File
@@ -5,13 +5,6 @@ REWORK_PLAN.md.
## Feature backlog ## Feature backlog
### CRITICAL BUG - storage
- docker for sql is not using persistant storage...
### feat - campaign section rollup
### feat - add all characters to participants list
### FEAT-M6: Transactional undo (moved from REWORK_PLAN) ### FEAT-M6: Transactional undo (moved from REWORK_PLAN)
- Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`. - Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`.
- Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional, - Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional,
@@ -24,7 +17,7 @@ REWORK_PLAN.md.
## Architecture: 1-list turn order model (DONE) ## Architecture: 1-list turn order model (DONE)
- Single source: turnOrderIds === participants.map(id). No re-sort after - Single source: turnOrderIds === participants.map(id). No re-sort after
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot. startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
- Drag (reorder) overrides initiative --- cross-init allowed, DM choice. - Drag (reorder) overrides initiative cross-init allowed, DM choice.
- startEncounter sorts ALL participants by init once, then frozen. - startEncounter sorts ALL participants by init once, then frozen.
- addParticipant splices by init pos. remove/toggle/reorder sync list. - addParticipant splices by init pos. remove/toggle/reorder sync list.
- Display renders participants[] directly (no sortParticipantsByInitiative). - Display renders participants[] directly (no sortParticipantsByInitiative).
@@ -38,7 +31,7 @@ REWORK_PLAN.md.
- Separate design + RED. Own work item. - Separate design + RED. Own work item.
- Related: tie-break = drag order (current, works). Expose clearly. - Related: tie-break = drag order (current, works). Expose clearly.
### FEAT-1: Dead participants stay in turn order --- DONE ### FEAT-1: Dead participants stay in turn order DONE
- Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds` - Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds`
on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get
death-save turn. `isActive` = DM toggle only. death-save turn. `isActive` = DM toggle only.
@@ -47,7 +40,7 @@ REWORK_PLAN.md.
### FEAT-2: upgrade app internal logs to be parseable ### FEAT-2: upgrade app internal logs to be parseable
- Goal: combat logs in Firestore store enough structured state to run - Goal: combat logs in Firestore store enough structured state to run
skip/rotation analysis on ANY historic round --- not just replay stdout. skip/rotation analysis on ANY historic round not just replay stdout.
- Current logs: `{timestamp, message, encounterName, undo}`. Parser must - Current logs: `{timestamp, message, encounterName, undo}`. Parser must
guess roster from message strings. Brittle. guess roster from message strings. Brittle.
- Upgrade: add structured fields at turn-state mutation log sites in - Upgrade: add structured fields at turn-state mutation log sites in
@@ -90,7 +83,7 @@ REWORK_PLAN.md.
### bug-3 was a halucination has been removed ### bug-3 was a halucination has been removed
### BUG-4: hide-player-HP breaks display view (preexisting) --- PROD FIXED, TEST RED (mock bug) ### BUG-4: hide-player-HP breaks display view (preexisting)
- **Broader than hide-HP**: ALL 5 `storage.setDoc(getPath.activeDisplay(), ...)` calls - **Broader than hide-HP**: ALL 5 `storage.setDoc(getPath.activeDisplay(), ...)` calls
use `{merge:true}` which is IGNORED (setDoc = replace per contract). use `{merge:true}` which is IGNORED (setDoc = replace per contract).
Each write clobbers other fields on activeDisplay/status doc. Each write clobbers other fields on activeDisplay/status doc.
@@ -108,22 +101,15 @@ REWORK_PLAN.md.
activeEncounterId with null (setDoc replace vs updateDoc patch). activeEncounterId with null (setDoc replace vs updateDoc patch).
- Fix: use updateDoc (patch) not setDoc (replace); or include all existing - Fix: use updateDoc (patch) not setDoc (replace); or include all existing
fields when writing. fields when writing.
- Status update (2026-07): all 5 sites now use `{merge:true}`. Real firebase
adapter honors merge → production works. BUT jsdom test still RED because
`src/__mocks__/firebase/firestore.js` setDoc records call, IGNORES opts
(no actual merge). Mock must simulate firebase merge semantics for test
to pass. Fix = mock setDoc: if opts.merge, MOCK_DB.merge(path,data) else
replace. OR change App.js setDoc(merge) → updateDoc (cleaner, ws adapter
uses PATCH). Decide which.
- Test: render App + DisplayView, toggle hide-HP, assert display still shows - Test: render App + DisplayView, toggle hide-HP, assert display still shows
encounter (not paused). encounter (not paused).
### BUG-5: mid-round addParticipant/revive corrupts rotation --- FIXED ### BUG-5: mid-round addParticipant/revive corrupts rotation FIXED
- Fixed (commit `494327f`). Slot-array turn order + DRY advance core - Fixed (commit `494327f`). Slot-array turn order + DRY advance core
`nextActiveAfter`. Both nextTurn + computeTurnOrderAfterRemoval delegate. `nextActiveAfter`. Both nextTurn + computeTurnOrderAfterRemoval delegate.
- 500-round replay: 0 skips, 0 double-acts. - 500-round replay: 0 skips, 0 double-acts.
### BUG-6: reorderParticipants doesn't update turnOrderIds --- FIXED ### BUG-6: reorderParticipants doesn't update turnOrderIds FIXED
- Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds = - Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds =
participants.map(id) always. reorder cross-init allowed (DM override). participants.map(id) always. reorder cross-init allowed (DM override).
Display === rotation by construction. Display === rotation by construction.
@@ -195,8 +181,8 @@ REWORK_PLAN.md.
- [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites - [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites
- [x] BUG-5: fixed (1-list model, 500 rounds clean) - [x] BUG-5: fixed (1-list model, 500 rounds clean)
- [x] BUG-6: fixed structurally (1-list model) - [x] BUG-6: fixed structurally (1-list model)
- [x] BUG-12: fixed --- campaign selection follows activeDisplay - [x] BUG-12: fixed campaign selection follows activeDisplay
- [x] BUG-15: fixed --- DisplayView no longer re-sorts (drag order preserved) - [x] BUG-15: fixed DisplayView no longer re-sorts (drag order preserved)
- [x] BUG-8: ws adapter reconnect (implemented + GREEN) - [x] BUG-8: ws adapter reconnect (implemented + GREEN)
- [ ] BUG-10: deact+reactivate double-act - [ ] BUG-10: deact+reactivate double-act
- [ ] BUG-11: FE Combat.scenario crash - [ ] BUG-11: FE Combat.scenario crash
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bash
# Start local dev stack: node backend (sqlite) + react frontend, ws storage mode.
# Usage: ./scripts/dev-start.sh
# Stop: ./scripts/dev-stop.sh
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p tmp data
# kill anything on the ports (zombies)
for port in 3999 4001; do
pids=$(lsof -ti :$port 2>/dev/null || true)
if [ -n "$pids" ]; then
echo "port $port in use by: $pids — leaving as-is."
echo " (run ./scripts/dev-stop.sh first to restart clean)"
fi
done
# backend: better-sqlite3, :4001
if ! lsof -ti :4001 >/dev/null 2>&1; then
echo "starting backend :4001..."
DB_PATH=$(pwd)/data/tracker.sqlite PORT=4001 \
nohup npm run server:dev > tmp/server.log 2>&1 &
echo $! > tmp/server.pid
else
echo "backend already on :4001"
fi
# frontend: ws storage, :3999
if ! lsof -ti :3999 >/dev/null 2>&1; then
echo "starting frontend :3999..."
REACT_APP_STORAGE=ws \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \
nohup npm start > tmp/fe.log 2>&1 &
echo $! > tmp/fe.pid
else
echo "frontend already on :3999"
fi
# wait for ports to listen
echo "waiting for ports..."
for port in 4001 3999; do
for i in {1..30}; do
lsof -ti :$port >/dev/null 2>&1 && break
sleep 1
done
done
echo ""
echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)"
echo "frontend : http://127.0.0.1:3999 (admin / player /display)"
echo "logs : tmp/server.log tmp/fe.log"
echo "stop : ./scripts/dev-stop.sh"
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env bash
# Stop local dev stack. Usage: ./scripts/dev-stop.sh
set -uo pipefail
cd "$(dirname "$0")/.."
stopped=0
for port in 3999 4001; do
pids=$(lsof -ti :$port 2>/dev/null || true)
if [ -n "$pids" ]; then
echo "stopping :$port (pid: $pids)"
kill $pids 2>/dev/null || true
stopped=1
fi
done
# also kill recorded pids
for f in tmp/server.pid tmp/fe.pid; do
if [ -f "$f" ]; then
pid=$(cat "$f")
kill "$pid" 2>/dev/null || true
rm -f "$f"
fi
done
# node --watch spawns children — sweep by port pattern
pids=$(pgrep -f "node --watch index.js|react-scripts start" 2>/dev/null || true)
if [ -n "$pids" ]; then
echo "sweeping node dev procs: $pids"
kill $pids 2>/dev/null || true
fi
if [ "$stopped" = "0" ]; then
echo "nothing running."
fi
echo "stopped."
+30 -109
View File
@@ -8,7 +8,7 @@ import {
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText, StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee, Clock Maximize2, Minimize2, Moon, Coffee
} from 'lucide-react'; } from 'lucide-react';
// Custom CSS for death animation (player view only) // Custom CSS for death animation (player view only)
@@ -46,7 +46,7 @@ if (typeof document !== 'undefined') {
// CONSTANTS // CONSTANTS
// ============================================================================ // ============================================================================
const APP_VERSION = 'v0.3'; const APP_VERSION = 'v0.4';
const { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, generateId, rollD20, formatInitMod, sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval } = shared; const { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, generateId, rollD20, formatInitMod, sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval } = shared;
const ROLL_DISPLAY_DURATION = 5000; const ROLL_DISPLAY_DURATION = 5000;
@@ -444,10 +444,9 @@ function EditParticipantModal({ participant, onClose, onSave }) {
/> />
</div> </div>
<div> <div>
<label htmlFor="edit-initiative" className="block text-sm font-medium text-stone-300">Initiative</label> <label className="block text-sm font-medium text-stone-300">Initiative</label>
<input <input
type="number" type="number"
id="edit-initiative"
value={initiative} value={initiative}
onChange={(e) => setInitiative(e.target.value)} onChange={(e) => setInitiative(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
@@ -782,7 +781,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const [selectedCharacterId, setSelectedCharacterId] = useState(''); const [selectedCharacterId, setSelectedCharacterId] = useState('');
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD); const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP); const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
const [manualInitiative, setManualInitiative] = useState('');
const [isNpc, setIsNpc] = useState(false); const [isNpc, setIsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null); const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({}); const [hpChangeValues, setHpChangeValues] = useState({});
@@ -819,8 +817,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
let modifier = 0; let modifier = 0;
let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP; let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP;
let participantIsNpc = false; let participantIsNpc = false;
const manualInit = manualInitiative !== '' && !isNaN(parseInt(manualInitiative, 10));
const finalInitiative = manualInit ? parseInt(manualInitiative, 10) : null;
if (participantType === 'character') { if (participantType === 'character') {
const character = campaignCharacters.find(c => c.id === selectedCharacterId); const character = campaignCharacters.find(c => c.id === selectedCharacterId);
@@ -840,13 +836,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
participantIsNpc = isNpc; participantIsNpc = isNpc;
} }
const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier); const finalInitiative = initiativeRoll + modifier;
const newParticipant = { const newParticipant = {
id: generateId(), id: generateId(),
name: nameToAdd, name: nameToAdd,
type: participantType, type: participantType,
originalCharacterId: participantType === 'character' ? selectedCharacterId : null, originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
initiative: computedInitiative, initiative: finalInitiative,
maxHp: currentMaxHp, maxHp: currentMaxHp,
currentHp: currentMaxHp, currentHp: currentMaxHp,
isNpc: participantType === 'monster' ? participantIsNpc : false, isNpc: participantType === 'monster' ? participantIsNpc : false,
@@ -858,21 +854,19 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try { try {
await storage.updateDoc(encounterPath, { await storage.updateDoc(encounterPath, {
participants: sortParticipantsByInitiative([...participants, newParticipant], participants), participants: [...participants, newParticipant]
...syncTurnOrder([...participants, newParticipant]),
}); });
logAction(`${nameToAdd} added to encounter (Initiative: ${computedInitiative})`, { encounterName: encounter.name }, { logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: { participants: [...participants] }, updates: { participants: [...participants] },
}); });
setLastRollDetails({ setLastRollDetails({
name: nameToAdd, name: nameToAdd,
roll: manualInit ? null : initiativeRoll, roll: initiativeRoll,
mod: manualInit ? null : modifier, mod: modifier,
total: computedInitiative, total: finalInitiative,
type: participantIsNpc ? 'NPC' : participantType, type: participantIsNpc ? 'NPC' : participantType
manual: manualInit,
}); });
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION); setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
@@ -882,7 +876,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
setSelectedCharacterId(''); setSelectedCharacterId('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
setIsNpc(false); setIsNpc(false);
setManualInitiative('');
} catch (err) { } catch (err) {
console.error("Error adding participant:", err); console.error("Error adding participant:", err);
alert("Failed to add participant. Please try again."); alert("Failed to add participant. Please try again.");
@@ -941,13 +934,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const updatedParticipants = participants.map(p => const updatedParticipants = participants.map(p =>
p.id === editingParticipant.id ? { ...p, ...updatedData } : p p.id === editingParticipant.id ? { ...p, ...updatedData } : p
); );
const reslotted = sortParticipantsByInitiative(updatedParticipants, participants);
try { try {
await storage.updateDoc(encounterPath, { await storage.updateDoc(encounterPath, { participants: updatedParticipants });
participants: reslotted,
...syncTurnOrder(reslotted),
});
setEditingParticipant(null); setEditingParticipant(null);
} catch (err) { } catch (err) {
console.error("Error updating participant:", err); console.error("Error updating participant:", err);
@@ -955,28 +944,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} }
}; };
// Inline initiative edit (FEAT-3): blur/Enter commits. Reslots participant
// into correct list position (stable sort by init desc, tie-break original
// index). Display + AdminView both reflect new order. Pre-combat only —
// field gated to !started||paused elsewhere.
const handleInlineInitiative = async (participantId, value) => {
if (!db) return;
const n = parseInt(value, 10);
if (isNaN(n)) return;
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, initiative: n } : p
);
const reslotted = sortParticipantsByInitiative(updatedParticipants, participants);
try {
await storage.updateDoc(encounterPath, {
participants: reslotted,
...syncTurnOrder(reslotted),
});
} catch (err) {
console.error("Error updating initiative:", err);
}
};
const requestDeleteParticipant = (participantId, participantName) => { const requestDeleteParticipant = (participantId, participantName) => {
setItemToDelete({ id: participantId, name: participantName }); setItemToDelete({ id: participantId, name: participantName });
setShowDeleteConfirm(true); setShowDeleteConfirm(true);
@@ -1340,19 +1307,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/> />
</div> </div>
<div className="md:col-span-2">
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
</label>
<input
type="number"
id="manualInitiative"
value={manualInitiative}
onChange={(e) => setManualInitiative(e.target.value)}
placeholder="auto"
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300"> <label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
Max HP Max HP
@@ -1404,19 +1358,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/> />
</div> </div>
<div className="md:col-span-2">
<label htmlFor="charManualInitiative" className="block text-sm font-medium text-stone-300">
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
</label>
<input
type="number"
id="charManualInitiative"
value={manualInitiative}
onChange={(e) => setManualInitiative(e.target.value)}
placeholder="auto"
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
</> </>
)} )}
@@ -1434,10 +1375,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
{lastRollDetails && ( {lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center"> <p className="text-sm text-green-400 mt-2 mb-2 text-center">
{lastRollDetails.manual {lastRollDetails.name} ({lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type})
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}` : Rolled d20 ({lastRollDetails.roll}) {formatInitMod(lastRollDetails.mod)} = {lastRollDetails.total} Initiative
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
}
</p> </p>
)} )}
@@ -1483,27 +1422,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
)} )}
{isDead && <span className="ml-2 text-xs text-red-300 font-semibold">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>} {isDead && <span className="ml-2 text-xs text-red-300 font-semibold">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
</p> </p>
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}> <p className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'}`}>
<span className="inline-flex items-center gap-1"> Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}
<label htmlFor={`init-${p.id}`} className="sr-only">Initiative</label> </p>
<input
type="number"
id={`init-${p.id}`}
defaultValue={p.initiative}
key={p.initiative}
min="0"
max="99"
disabled={encounter.isStarted && !encounter.isPaused}
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
onFocus={(e) => e.target.select()}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
className="w-10 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={`Initiative for ${p.name}`}
/>
</span>
<span>HP: {p.currentHp}/{p.maxHp}</span>
</div>
{/* Death Saves - only player characters make death saving throws */} {/* Death Saves - only player characters make death saving throws */}
{isDead && encounter.isStarted && p.type === 'character' && ( {isDead && encounter.isStarted && p.type === 'character' && (
@@ -1662,7 +1583,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const handleToggleHidePlayerHp = async () => { const handleToggleHidePlayerHp = async () => {
if (!db) return; if (!db) return;
try { try {
await storage.updateDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }); await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true });
} catch (err) { } catch (err) {
console.error("Error toggling hidePlayerHp:", err); console.error("Error toggling hidePlayerHp:", err);
} }
@@ -1695,10 +1616,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
turnOrderIds: sortedParticipants.map(p => p.id) turnOrderIds: sortedParticipants.map(p => p.id)
}); });
await storage.updateDoc(getPath.activeDisplay(), { await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId, activeCampaignId: campaignId,
activeEncounterId: encounter.id activeEncounterId: encounter.id
}); }, { merge: true });
logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, { logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
@@ -1826,10 +1747,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
turnOrderIds: [] turnOrderIds: []
}); });
await storage.updateDoc(getPath.activeDisplay(), { await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: null, activeCampaignId: null,
activeEncounterId: null activeEncounterId: null
}); }, { merge: true });
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, { logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
@@ -2044,15 +1965,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId; const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) { if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
await storage.updateDoc(getPath.activeDisplay(), { await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: null, activeCampaignId: null,
activeEncounterId: null, activeEncounterId: null,
}); }, { merge: true });
} else { } else {
await storage.updateDoc(getPath.activeDisplay(), { await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId, activeCampaignId: campaignId,
activeEncounterId: encounterId, activeEncounterId: encounterId,
}); }, { merge: true });
} }
} catch (err) { } catch (err) {
console.error("Error toggling Player Display:", err); console.error("Error toggling Player Display:", err);
@@ -2362,13 +2283,13 @@ function AdminView({ userId }) {
<span className="inline-flex items-center"> <span className="inline-flex items-center">
<Swords size={12} className="mr-1" /> {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters <Swords size={12} className="mr-1" /> {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters
</span> </span>
</div>
{campaign.createdAt && ( {campaign.createdAt && (
<div className="text-xs text-stone-300 opacity-70 mt-1"> <span className="inline-flex items-center opacity-80">
<Clock size={12} className="mr-1 inline-block" /> Created: {new Date(campaign.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} {new Date(campaign.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}
</div> </span>
)} )}
</div> </div>
</div>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
+6 -6
View File
@@ -41,7 +41,7 @@ describe('Combat -> Firebase', () => {
test('startEncounter: also sets activeDisplay to this encounter', async () => { test('startEncounter: also sets activeDisplay to this encounter', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data.activeCampaignId).toBeTruthy(); expect(last.data.activeCampaignId).toBeTruthy();
expect(last.data.activeEncounterId).toBeTruthy(); expect(last.data.activeEncounterId).toBeTruthy();
@@ -111,26 +111,26 @@ describe('Combat -> Firebase', () => {
fireEvent.click(screen.getByRole('button', { name: /End Combat/i })); fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
return last && last.data.activeCampaignId === null; return last && last.data.activeCampaignId === null;
}); });
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
}); });
test('toggleHidePlayerHp: updateDoc patch on activeDisplay/status', async () => { test('toggleHidePlayerHp: setDoc merge on activeDisplay/status', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const switchBtn = screen.getByRole('switch'); const switchBtn = screen.getByRole('switch');
fireEvent.click(switchBtn); fireEvent.click(switchBtn);
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
return last && 'hidePlayerHp' in last.data; return last && 'hidePlayerHp' in last.data;
}); });
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data).toHaveProperty('hidePlayerHp'); expect(last.data).toHaveProperty('hidePlayerHp');
}); });
+9 -9
View File
@@ -42,7 +42,7 @@ describe('Encounter -> Firebase', () => {
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//); expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
}); });
test('togglePlayerDisplay: updateDoc patch on activeDisplay/status', async () => { test('togglePlayerDisplay: setDoc merge on activeDisplay/status', async () => {
await setupCampaignAndEncounter('Camp D', 'Enc D'); await setupCampaignAndEncounter('Camp D', 'Enc D');
await selectEncounterByName('Enc D'); await selectEncounterByName('Enc D');
@@ -50,33 +50,33 @@ describe('Encounter -> Firebase', () => {
const eyeBtn = await screen.findByTitle('Activate for Player Display'); const eyeBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(eyeBtn); fireEvent.click(eyeBtn);
await waitFor(() => findCall('updateDoc', 'activeDisplay/status')); await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
const call = findCall('updateDoc', 'activeDisplay/status'); const call = findCall('setDoc', 'activeDisplay/status');
// BUG-4 fix: updateDoc patch, not setDoc replace (was clobbering fields) // activeDisplay/status setDoc is called with merge option in App
expect(call.data).toMatchObject({ expect(call.data).toMatchObject({
activeCampaignId: expect.any(String), activeCampaignId: expect.any(String),
activeEncounterId: expect.any(String), activeEncounterId: expect.any(String),
}); });
}); });
test('togglePlayerDisplay off: updateDoc nulls active ids', async () => { test('togglePlayerDisplay off: setDoc nulls active ids', async () => {
await setupCampaignAndEncounter('Camp O', 'Enc O'); await setupCampaignAndEncounter('Camp O', 'Enc O');
await selectEncounterByName('Enc O'); await selectEncounterByName('Enc O');
// turn ON // turn ON
const onBtn = await screen.findByTitle('Activate for Player Display'); const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn); fireEvent.click(onBtn);
await waitFor(() => findCall('updateDoc', 'activeDisplay/status')); await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
// turn OFF // turn OFF
const offBtn = await screen.findByTitle('Deactivate for Player Display'); const offBtn = await screen.findByTitle('Deactivate for Player Display');
fireEvent.click(offBtn); fireEvent.click(offBtn);
await waitFor(() => { await waitFor(() => {
const calls = findCalls('updateDoc', 'activeDisplay/status'); const calls = findCalls('setDoc', 'activeDisplay/status');
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
return last.data.activeCampaignId === null; return last.data.activeCampaignId === null;
}); });
const calls = findCalls('updateDoc', 'activeDisplay/status'); const calls = findCalls('setDoc', 'activeDisplay/status');
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
}); });
@@ -103,7 +103,7 @@ describe('Encounter -> Firebase', () => {
// activate display first // activate display first
const onBtn = await screen.findByTitle('Activate for Player Display'); const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn); fireEvent.click(onBtn);
await waitFor(() => findCall('updateDoc', 'activeDisplay/status')); await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
// delete the active encounter // delete the active encounter
const trashBtn = screen.getAllByTitle('Delete Encounter')[0]; const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
+5 -5
View File
@@ -50,14 +50,14 @@ describe('BUG-4: hide-player-HP toggle preserves activeDisplay', () => {
await waitFor(() => { await waitFor(() => {
const writes = getAdapterCalls().filter( const writes = getAdapterCalls().filter(
c => c.fn === 'updateDoc' && c.path.includes('activeDisplay/status') c => c.fn === 'setDoc' && c.path.includes('activeDisplay/status')
); );
expect(writes.length).toBeGreaterThan(0); expect(writes.length).toBeGreaterThan(0);
const last = writes[writes.length - 1]; const last = writes[writes.length - 1];
// patch must NOT clobber activeCampaignId/activeEncounterId. // data written must include activeCampaignId AND activeEncounterId
// BUG: setDoc replace writes only {hidePlayerHp:true} clobbers. // BUG: writes only {hidePlayerHp:true}, clobbering them.
// Fix: updateDoc patch — other fields untouched. expect(last.data.activeCampaignId).toBe('c1');
expect(last.patch.hidePlayerHp).toBe(true); expect(last.data.activeEncounterId).toBe('e1');
}, { timeout: 3000 }); }, { timeout: 3000 });
}); });
}); });
-57
View File
@@ -1,57 +0,0 @@
// RED test: FEAT-3 initiative field on add participant.
// If initiative field set, use it (no roll). Empty = roll d20+mod (current).
import React from 'react';
import { screen, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } from './testHelpers';
import { fireEvent } from '@testing-library/react';
import { getCalls } from '../__mocks__/firebase/_mock-db';
function lastParticipantUpdate(name) {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const last = calls[calls.length - 1];
return last && last.data.participants && last.data.participants.find(p => p.name === name);
}
describe('FEAT-3: initiative field on add (optional, empty=roll)', () => {
test('initiative field set → uses value, no roll', async () => {
await renderApp();
await createCampaignViaUI('Camp');
await selectCampaignByName('Camp');
await createEncounterViaUI('Enc');
await selectEncounterByName('Enc');
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Goblin' } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: '2' } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: '7' } });
// set explicit initiative
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: '15' } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastParticipantUpdate('Goblin'));
const p = lastParticipantUpdate('Goblin');
expect(p.initiative).toBe(15);
});
test('initiative field empty → rolls d20+mod', async () => {
await renderApp();
await createCampaignViaUI('Camp2');
await selectCampaignByName('Camp2');
await createEncounterViaUI('Enc2');
await selectEncounterByName('Enc2');
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Wolf' } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: '3' } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: '11' } });
// leave initiative empty
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastParticipantUpdate('Wolf'));
const p = lastParticipantUpdate('Wolf');
// rolled d20 (1-20) + mod 3 = range 4-23
expect(p.initiative).toBeGreaterThanOrEqual(4);
expect(p.initiative).toBeLessThanOrEqual(23);
});
});
-69
View File
@@ -1,69 +0,0 @@
// RED: FEAT-3 followup. Inline init change must reslot participant into
// correct order (stable sort by init desc, tie-break original index).
// Before combat starts: list reorders. Also field gated to !started||paused.
import React from 'react';
import { screen, waitFor, within, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, addMonsterViaUI } from './testHelpers';
import { getCalls } from '../__mocks__/firebase/_mock-db';
function lastParticipantsUpdate() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const last = calls[calls.length - 1];
return last && last.data.participants;
}
describe('FEAT-3 reslot: inline init change reorders list', () => {
test('raising init moves participant up in list (pre-combat)', async () => {
await renderApp();
await createCampaignViaUI('Camp');
await selectCampaignByName('Camp');
await createEncounterViaUI('Enc');
await selectEncounterByName('Enc');
// add two monsters with manual init: Orc=5 (first), Goblin=3 (second)
const form = within(getParticipantForm());
const addOne = async (name, hp, mod, init) => {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
const parts = lastParticipantsUpdate();
if (!parts || !parts.some(p => p.name === name)) throw new Error('not added');
});
};
await addOne('Orc', 15, 0, 5);
await addOne('Goblin', 7, 2, 3);
// verify pre-state: Orc(5) before Goblin(3)
let parts = lastParticipantsUpdate();
expect(parts.map(p => p.name)).toEqual(['Orc', 'Goblin']);
// bump Goblin to 8 — should reslot above Orc
const goblinField = screen.getByLabelText('Initiative for Goblin');
fireEvent.change(goblinField, { target: { value: '8' } });
fireEvent.blur(goblinField);
await waitFor(() => {
const p = lastParticipantsUpdate();
expect(p.map(x => x.name)).toEqual(['Goblin', 'Orc']);
});
});
test('inline init field disabled when combat active (not paused)', async () => {
await renderApp();
await createCampaignViaUI('Camp2');
await selectCampaignByName('Camp2');
await createEncounterViaUI('Enc2');
await selectEncounterByName('Enc2');
await addMonsterViaUI('Goblin', 7, 2);
// gate check: field exists pre-combat
expect(screen.getByLabelText('Initiative for Goblin')).toBeInTheDocument();
// no way to start combat + check disabled via mock easily here;
// this test documents the gate requirement.
});
});
-76
View File
@@ -1,76 +0,0 @@
// RED: reslot must fire on ALL 4 participant-mutation paths.
// Path 1 add, path 2 edit modal, path 3 drag (already correct), path 4 inline field (already correct).
// Tests add + edit modal reslot. Drag + inline already covered.
import React from 'react';
import { screen, waitFor, within, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, addMonsterViaUI } from './testHelpers';
import { getCalls } from '../__mocks__/firebase/_mock-db';
function lastParticipantsUpdate() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const last = calls[calls.length - 1];
return last && last.data.participants;
}
async function addOne(form, name, hp, mod, init) {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
const parts = lastParticipantsUpdate();
if (!parts || !parts.some(p => p.name === name)) throw new Error('not added');
});
}
describe('reslot on all mutation paths', () => {
test('add inserts at correct init position (not append)', async () => {
await renderApp();
await createCampaignViaUI('Camp');
await selectCampaignByName('Camp');
await createEncounterViaUI('Enc');
await selectEncounterByName('Enc');
const form = within(getParticipantForm());
// add Orc(5) first, then Goblin(8) — Goblin should slot ABOVE Orc, not append below
await addOne(form, 'Orc', 15, 0, 5);
await addOne(form, 'Goblin', 7, 2, 8);
const parts = lastParticipantsUpdate();
expect(parts.map(p => p.name)).toEqual(['Goblin', 'Orc']);
});
test('edit modal init change reslots participant', async () => {
await renderApp();
await createCampaignViaUI('Camp2');
await selectCampaignByName('Camp2');
await createEncounterViaUI('Enc2');
await selectEncounterByName('Enc2');
const form = within(getParticipantForm());
await addOne(form, 'Orc', 15, 0, 5);
await addOne(form, 'Goblin', 7, 2, 3);
// pre: Orc(5) before Goblin(3)
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
// open edit modal for Goblin, bump init to 8
const editBtns = screen.getAllByTitle('Edit');
const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
fireEvent.click(goblinEdit);
await waitFor(() => screen.getByText(`Edit Goblin`));
// modal renders after row inputs; take last Initiative-labeled input
const initInputs = screen.getAllByLabelText('Initiative');
fireEvent.change(initInputs[initInputs.length - 1], { target: { value: '8' } });
const saveBtns = screen.getAllByRole('button', { name: /Save/i });
fireEvent.click(saveBtns[saveBtns.length - 1]);
await waitFor(() => {
const parts = lastParticipantsUpdate();
expect(parts.map(p => p.name)).toEqual(['Goblin', 'Orc']);
});
});
});