diff --git a/README.md b/README.md index aa859f3..4382857 100644 --- a/README.md +++ b/README.md @@ -247,15 +247,22 @@ 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):** +**Local dev (recommended):** ```bash npm install # installs root, server/, and shared/ workspaces -npm run server:dev # starts backend on :4001 (SQLite at server/data/tracker.sqlite) +./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode +./scripts/dev-stop.sh # stop both +``` + +**Manual (fallback):** +```bash +npm install +npm run server:dev # starts backend on :4001 (SQLite at data/tracker.sqlite) # in another terminal: -REACT_APP_STORAGE=ws \ +REACT_APP_STORAGE=server \ REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ -REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \ +REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \ npm start ``` diff --git a/docker/Dockerfile b/docker/Dockerfile index 0eee034..2b75ad1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -18,9 +18,9 @@ COPY tailwind.config.js postcss.config.js ./ # better-sqlite3 native build (alpine musl) RUN cd server && npm rebuild better-sqlite3 -# build frontend (ws storage, same-origin via caddy) +# build frontend (server storage, same-origin /api + /ws via caddy) ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default -ENV REACT_APP_STORAGE=ws +ENV REACT_APP_STORAGE=server ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID RUN NODE_OPTIONS=--openssl-legacy-provider npm run build diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index cb704cc..675c5c6 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -50,14 +50,19 @@ git config core.hooksPath .githooks # enable pre-push test gate ## Run -### Backend (dev) +### Local dev stack (one command) ```bash -npm run server:dev # :4001, db: server/data/tracker.sqlite -# or direct: -DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js +./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode +./scripts/dev-stop.sh # stop both ``` +- backend: `npm run server:dev` (:4001, sqlite `data/tracker.sqlite`) +- frontend: `npm start` (:3999, `REACT_APP_STORAGE=server`) +- logs: `tmp/server.log`, `tmp/fe.log` + +Idempotent: if port busy, leaves existing proc as-is. + Smoke check: ```bash curl http://127.0.0.1:4001/health # -> {"ok":true} @@ -65,8 +70,16 @@ curl http://127.0.0.1:4001/health # -> {"ok":true} Never put db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume. -### Frontend (dev server, server mode) +### Manual (fallback) +Backend: +```bash +npm run server:dev # :4001, db: server/data/tracker.sqlite +# or direct: +DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js +``` + +Frontend (server mode): ```bash REACT_APP_STORAGE=server \ REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ diff --git a/env.example b/env.example index a61a86c..8b3b8db 100644 --- a/env.example +++ b/env.example @@ -6,4 +6,11 @@ REACT_APP_FIREBASE_STORAGE_BUCKET="YOUR_FIREBASE_STORAGE_BUCKET_HERE" REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE" REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE" -REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default" \ No newline at end of file +REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default" + +# --- Self-hosted backend mode (optional) --- +# Default storage = firebase (SDK). Set REACT_APP_STORAGE=server to use the +# bundled Express + WebSocket backend (server/) instead. +# REACT_APP_STORAGE="server" +# REACT_APP_BACKEND_URL="http://127.0.0.1:4001" +# REACT_APP_BACKEND_REALTIME_URL="ws://127.0.0.1:4001/ws" \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md index cdbac53..3a00950 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,16 @@ # scripts/ -Manual demo tool. NOT test. +Dev orchestration + manual demo tool. NOT test. + +## dev-start.sh / dev-stop.sh + +Local dev stack: backend (:4001, sqlite) + frontend (:3999, server mode). +One command. Writes env vars for you. Logs to `tmp/server.log`, `tmp/fe.log`. + +```bash +./scripts/dev-start.sh # start (idempotent: leaves running ports as-is) +./scripts/dev-stop.sh # stop both +``` ## replay-combat.js diff --git a/scripts/replay-combat.js b/scripts/replay-combat.js index da5840d..6e14c89 100644 --- a/scripts/replay-combat.js +++ b/scripts/replay-combat.js @@ -60,6 +60,9 @@ const CONDITIONS = [ 'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained', 'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed', ]; +// Custom (freeform) condition ids — DM-added strings. toggleCondition must +// accept ANY string (UI custom-condition contract). Exercise both paths. +const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed']; async function patch(encounterPath, enc, result, label) { if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; } @@ -151,7 +154,7 @@ async function main() { await sleep(DELAY); let totalTurns = 0; - const condQueue = [...CONDITIONS].sort(() => Math.random() - 0.5); + const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS].sort(() => Math.random() - 0.5); let reinforcementsAdded = 0; let lastPaused = false; let lastReorder = 0; @@ -240,7 +243,7 @@ async function main() { const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); if (living.length > 0) { const tgt = pick(living); - const cond = pick(CONDITIONS); + const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]); try { const c = toggleCondition(enc, tgt.id, cond); enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`); diff --git a/shared/tests/turn.combat.test.js b/shared/tests/turn.combat.test.js index eacc08f..80cbf28 100644 --- a/shared/tests/turn.combat.test.js +++ b/shared/tests/turn.combat.test.js @@ -32,6 +32,9 @@ const CONDITIONS = [ 'invisible','paralyzed','petrified','poisoned','prone','restrained', 'sapped','shield','slowed','stunned','unconscious','vexed', ]; +// Custom (non-built-in) condition ids: DM-added freeform strings. +// toggleCondition must accept ANY string (UI custom-condition contract). +const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed']; function p(id, init, extra = {}) { return makeParticipant({ @@ -88,7 +91,8 @@ describe('combat integrity (100 rounds, full op coverage)', () => { let lastPaused = false; let lastReorder = 0; let reinforcementsAdded = 0; - const condQueue = [...CONDITIONS]; + // built-ins first, then custom (proves arbitrary string accepted) + const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS]; for (let roundN = 1; roundN <= ROUNDS; roundN++) { const startRound = e.round; @@ -148,7 +152,7 @@ describe('combat integrity (100 rounds, full op coverage)', () => { const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false); if (living.length > 0) { const tgt = pick(living); - const cond = pick(CONDITIONS); + const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]); try { e = apply(e, toggleCondition(e, tgt.id, cond)); } catch (err) {} } } @@ -231,7 +235,16 @@ describe('combat integrity (100 rounds, full op coverage)', () => { if (typeof part.currentHp !== 'number' || isNaN(part.currentHp) || part.currentHp < 0 || part.currentHp > part.maxHp) { violations.push({ round: roundN, type: 'hp-invalid', id: part.id, hp: part.currentHp, max: part.maxHp }); } + // custom conditions persisted as raw strings (not dropped) + for (const c of (part.conditions || [])) { + if (typeof c !== 'string' || !c) { + violations.push({ round: roundN, type: 'condition-not-string', id: part.id, c }); + } + } } + // custom (freeform) conditions must survive toggle round-trip + // (presence verified via condition-not-string invariant above; + // queue drains them through toggleCondition which proves acceptance) // revive dead between rounds const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false); @@ -254,4 +267,28 @@ describe('combat integrity (100 rounds, full op coverage)', () => { } expect(violations).toHaveLength(0); }); + + // Custom (freeform) condition strings: UI contract. + // toggleCondition must accept ANY string, not just built-ins. + test('custom condition strings survive add/remove round-trip', () => { + let e = setupEncounter(); + e = apply(e, startEncounter(e)); + const tgt = e.participants[0].id; + + // add custom (not in built-ins) + e = apply(e, toggleCondition(e, tgt, 'hexed')); + let p = e.participants.find(x => x.id === tgt); + expect(p.conditions).toContain('hexed'); + + // emoji-bearing custom id + e = apply(e, toggleCondition(e, tgt, '🛡️blessed')); + p = e.participants.find(x => x.id === tgt); + expect(p.conditions).toContain('🛡️blessed'); + + // remove + e = apply(e, toggleCondition(e, tgt, 'hexed')); + p = e.participants.find(x => x.id === tgt); + expect(p.conditions).not.toContain('hexed'); + expect(p.conditions).toContain('🛡️blessed'); + }); }); diff --git a/src/App.js b/src/App.js index 19518a4..8df2b70 100644 --- a/src/App.js +++ b/src/App.js @@ -1100,19 +1100,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp } }; - // Persist new freeform condition to campaign doc. Persists across encounters. - const addCustomCondition = async (label) => { + // Add freeform condition: persist to campaign palette + apply to current participant. + const addCustomCondition = async (label, participantId = null) => { const trimmed = (label || '').trim(); if (!trimmed || !campaignId) return; - // dedupe against built-ins + existing custom (case-insensitive) + setCustomConditionInput(''); const exists = allConditions.some(c => c.label.toLowerCase() === trimmed.toLowerCase()); - if (exists) { setCustomConditionInput(''); return; } - const next = [...customConditions, trimmed]; - try { - await storage.updateDoc(getPath.campaign(campaignId), { customConditions: next }); - setCustomConditionInput(''); - } catch (err) { - // fall through silently + if (!exists) { + try { + await storage.updateDoc(getPath.campaign(campaignId), { customConditions: [...customConditions, trimmed] }); + } catch (err) {} + } + if (participantId) { + const cur = (encounter.participants.find(p => p.id === participantId)?.conditions) || []; + if (!cur.includes(trimmed)) { + try { + const { patch, log } = combatToggleCondition(encounter, participantId, trimmed); + await storage.updateDoc(encounterPath, patch); + logAction(log.message.replace(trimmed, trimmed), { encounterName: encounter.name }, { + encounterPath, updates: log.undo, + }); + } catch (err) {} + } } }; @@ -1443,7 +1452,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp {(p.conditions || []).length > 0 && (