fix: custom conditions apply on add; correct server WS env docs
Custom conditions (per-campaign freeform) previously only persisted to the
campaign palette — Add button did NOT apply them to the targeted participant,
and badges rendered via CONDITIONS (missing custom ids) so applied custom
conditions did not display. Now:
- addCustomCondition(label, participantId) persists to palette AND toggles
onto the participant in one step. Enter + Add button both pass p.id.
- Admin badge lookup uses allConditions (built-ins + custom), not CONDITIONS.
Shared contract: toggleCondition accepts ANY string. Prove it in tests:
- turn.combat.test.js adds CUSTOM_CONDITIONS ('hexed','rager',
'marked_for_death','🛡️blessed') to the seeded queue + random pool, asserts
condition-not-string invariant, and a dedicated add/remove round-trip test.
- replay-combat.js mirrors the same CUSTOM_CONDITIONS through the live backend.
Server-mode docs/env: REACT_APP_BACKEND_WS (stale pre-rename) -> correct
REACT_APP_BACKEND_REALTIME_URL=ws://.../ws in README, env.example,
docker/Dockerfile (STORAGE=ws -> server). DEVELOPMENT.md + scripts/README.md
now point to ./scripts/dev-start.sh as the primary dev entrypoint.
133 shared tests green.
This commit is contained in:
@@ -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.
|
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
|
```bash
|
||||||
npm install # installs root, server/, and shared/ workspaces
|
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:
|
# in another terminal:
|
||||||
REACT_APP_STORAGE=ws \
|
REACT_APP_STORAGE=server \
|
||||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
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
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -18,9 +18,9 @@ COPY tailwind.config.js postcss.config.js ./
|
|||||||
# better-sqlite3 native build (alpine musl)
|
# better-sqlite3 native build (alpine musl)
|
||||||
RUN cd server && npm rebuild better-sqlite3
|
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
|
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
|
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
|
||||||
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
|
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -50,14 +50,19 @@ git config core.hooksPath .githooks # enable pre-push test gate
|
|||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
### Backend (dev)
|
### Local dev stack (one command)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run server:dev # :4001, db: server/data/tracker.sqlite
|
./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode
|
||||||
# or direct:
|
./scripts/dev-stop.sh # stop both
|
||||||
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- 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:
|
Smoke check:
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:4001/health # -> {"ok":true}
|
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.
|
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
|
```bash
|
||||||
REACT_APP_STORAGE=server \
|
REACT_APP_STORAGE=server \
|
||||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||||
|
|||||||
+8
-1
@@ -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_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE"
|
||||||
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE"
|
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE"
|
||||||
|
|
||||||
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default"
|
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"
|
||||||
+11
-1
@@ -1,6 +1,16 @@
|
|||||||
# scripts/
|
# 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
|
## replay-combat.js
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ const CONDITIONS = [
|
|||||||
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
|
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
|
||||||
'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed',
|
'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) {
|
async function patch(encounterPath, enc, result, label) {
|
||||||
if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; }
|
if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; }
|
||||||
@@ -151,7 +154,7 @@ async function main() {
|
|||||||
await sleep(DELAY);
|
await sleep(DELAY);
|
||||||
|
|
||||||
let totalTurns = 0;
|
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 reinforcementsAdded = 0;
|
||||||
let lastPaused = false;
|
let lastPaused = false;
|
||||||
let lastReorder = 0;
|
let lastReorder = 0;
|
||||||
@@ -240,7 +243,7 @@ async function main() {
|
|||||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||||
if (living.length > 0) {
|
if (living.length > 0) {
|
||||||
const tgt = pick(living);
|
const tgt = pick(living);
|
||||||
const cond = pick(CONDITIONS);
|
const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
|
||||||
try {
|
try {
|
||||||
const c = toggleCondition(enc, tgt.id, cond);
|
const c = toggleCondition(enc, tgt.id, cond);
|
||||||
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
|
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ const CONDITIONS = [
|
|||||||
'invisible','paralyzed','petrified','poisoned','prone','restrained',
|
'invisible','paralyzed','petrified','poisoned','prone','restrained',
|
||||||
'sapped','shield','slowed','stunned','unconscious','vexed',
|
'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 = {}) {
|
function p(id, init, extra = {}) {
|
||||||
return makeParticipant({
|
return makeParticipant({
|
||||||
@@ -88,7 +91,8 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
|
|||||||
let lastPaused = false;
|
let lastPaused = false;
|
||||||
let lastReorder = 0;
|
let lastReorder = 0;
|
||||||
let reinforcementsAdded = 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++) {
|
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||||
const startRound = e.round;
|
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);
|
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||||
if (living.length > 0) {
|
if (living.length > 0) {
|
||||||
const tgt = pick(living);
|
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) {}
|
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) {
|
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 });
|
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
|
// revive dead between rounds
|
||||||
const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
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);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-13
@@ -1100,19 +1100,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Persist new freeform condition to campaign doc. Persists across encounters.
|
// Add freeform condition: persist to campaign palette + apply to current participant.
|
||||||
const addCustomCondition = async (label) => {
|
const addCustomCondition = async (label, participantId = null) => {
|
||||||
const trimmed = (label || '').trim();
|
const trimmed = (label || '').trim();
|
||||||
if (!trimmed || !campaignId) return;
|
if (!trimmed || !campaignId) return;
|
||||||
// dedupe against built-ins + existing custom (case-insensitive)
|
setCustomConditionInput('');
|
||||||
const exists = allConditions.some(c => c.label.toLowerCase() === trimmed.toLowerCase());
|
const exists = allConditions.some(c => c.label.toLowerCase() === trimmed.toLowerCase());
|
||||||
if (exists) { setCustomConditionInput(''); return; }
|
if (!exists) {
|
||||||
const next = [...customConditions, trimmed];
|
try {
|
||||||
try {
|
await storage.updateDoc(getPath.campaign(campaignId), { customConditions: [...customConditions, trimmed] });
|
||||||
await storage.updateDoc(getPath.campaign(campaignId), { customConditions: next });
|
} catch (err) {}
|
||||||
setCustomConditionInput('');
|
}
|
||||||
} catch (err) {
|
if (participantId) {
|
||||||
// fall through silently
|
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 && (
|
{(p.conditions || []).length > 0 && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{(p.conditions || []).map(cId => {
|
{(p.conditions || []).map(cId => {
|
||||||
const cond = CONDITIONS.find(c => c.id === cId);
|
const cond = allConditions.find(c => c.id === cId);
|
||||||
return cond ? (
|
return cond ? (
|
||||||
<span
|
<span
|
||||||
key={cId}
|
key={cId}
|
||||||
@@ -1493,7 +1502,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
value={customConditionInput}
|
value={customConditionInput}
|
||||||
onChange={(e) => setCustomConditionInput(e.target.value)}
|
onChange={(e) => setCustomConditionInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') { e.preventDefault(); addCustomCondition(customConditionInput); }
|
if (e.key === 'Enter') { e.preventDefault(); addCustomCondition(customConditionInput, p.id); }
|
||||||
}}
|
}}
|
||||||
placeholder="Add custom condition..."
|
placeholder="Add custom condition..."
|
||||||
className="flex-grow px-2 py-1 text-xs rounded bg-stone-800 border border-stone-600 text-stone-200 placeholder-stone-500 focus:outline-none focus:border-amber-500"
|
className="flex-grow px-2 py-1 text-xs rounded bg-stone-800 border border-stone-600 text-stone-200 placeholder-stone-500 focus:outline-none focus:border-amber-500"
|
||||||
@@ -1501,7 +1510,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => addCustomCondition(customConditionInput)}
|
onClick={() => addCustomCondition(customConditionInput, p.id)}
|
||||||
disabled={!customConditionInput.trim()}
|
disabled={!customConditionInput.trim()}
|
||||||
className="px-2 py-1 rounded text-xs bg-amber-700 border border-amber-500 text-white hover:bg-amber-600 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="px-2 py-1 rounded text-xs bg-amber-700 border border-amber-500 text-white hover:bg-amber-600 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user