Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a92c667c5 | ||
|
|
6f11edab94 | ||
|
|
2dfa155469 | ||
|
|
0fa3928bf8 | ||
|
|
9870d4df0d | ||
|
|
43b1f6ce41 | ||
|
|
907484fc7f | ||
|
|
9dbbb88730 | ||
|
|
1078062b53 | ||
|
|
94126567e7 | ||
|
|
f3ed971592 | ||
|
|
95db6fdfdc | ||
|
|
a5b6d61e83 | ||
|
|
76f5dc09ab | ||
|
|
c2bb5cca29 | ||
|
|
7955bed4a9 | ||
|
|
66f3cc1380 | ||
|
|
19de9fcf75 |
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: ttrpg-encounter-builder
|
||||
description: |
|
||||
Seeds D&D encounter rosters into the TTRPG Initiative Tracker via its REST API
|
||||
(no UI clicking). Use when the user asks to "set up encounters", "build
|
||||
encounters", "add participants to a campaign", "seed this encounter list",
|
||||
or provides a statblock list (name + HP + init) for a campaign running on a
|
||||
self-hosted tracker instance running in SERVER MODE (bundled Express
|
||||
backend). NOT for Firebase SDK mode — that has no HTTP backend; write path
|
||||
differs entirely. Harness-agnostic: pi, Claude Code, and Codex all run the
|
||||
same script + curl recipes.
|
||||
---
|
||||
|
||||
# TTRPG Encounter Builder
|
||||
|
||||
Build encounter rosters directly via the tracker REST API. No UI clicking, no
|
||||
browser automation. One batch write per session, then verify.
|
||||
|
||||
## When to use
|
||||
|
||||
User provides a campaign name + a list of encounters, each with named
|
||||
participants carrying HP and initiative modifiers (or rolled initiative).
|
||||
Examples:
|
||||
|
||||
- "set up these encounters in the 2026 D&D campaign on bee.icantclick.org:8080"
|
||||
- "build encounter: Barrowdeep 1 - Ankheg HP 45 Init +0"
|
||||
- "seed these statblocks into my tracker"
|
||||
|
||||
Do NOT use for: starting combat, rolling HP mid-fight, editing live combat
|
||||
state, or driving the player display. Those are in-combat ops, not build ops.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running tracker instance in **SERVER MODE** (`REACT_APP_STORAGE=server`,
|
||||
bundled Express backend mounted). Reachable over HTTP.
|
||||
- **Firebase SDK mode NOT supported.** No `/api/*` endpoints exist; data
|
||||
lives in Firestore via the in-browser SDK with different auth + write
|
||||
paths. This skill cannot reach it. If the user is on firebase mode, tell
|
||||
them: switch to server mode, or build encounters via the UI / a
|
||||
firebase-admin script (out of scope here).
|
||||
- Node.js 18+ (for `fetch` + `crypto.randomUUID`). Helper script uses only
|
||||
builtins — no `npm install`.
|
||||
- Campaign must already exist (created via UI or `POST /api/collection`).
|
||||
This skill adds encounters to an existing campaign.
|
||||
|
||||
Confirm host reachable + server mode before building:
|
||||
|
||||
```bash
|
||||
curl -s <HOST>/api/collection?path=campaigns | head -c 200
|
||||
```
|
||||
|
||||
Returns JSON array of campaign docs = server mode confirmed. HTML (SPA shell)
|
||||
= firebase mode OR backend not mounted. Either way, this skill won't work —
|
||||
tell the user.
|
||||
|
||||
## Data model (read this before writing)
|
||||
|
||||
Three nested entities, all opaque JSON docs in a path-keyed KV store:
|
||||
|
||||
```
|
||||
Campaign campaigns/{campaignId}
|
||||
└─ Encounter campaigns/{campaignId}/encounters/{encounterId}
|
||||
└─ Participant (inline array item on the encounter doc)
|
||||
```
|
||||
|
||||
**Participants are inline.** There is NO `participants` sub-collection. A
|
||||
`participants` path returns empty. Each encounter doc carries its full roster
|
||||
in a `participants[]` array.
|
||||
|
||||
### Path normalization (gotcha)
|
||||
|
||||
App code uses firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`).
|
||||
The REST API uses **bare canonical** paths (`campaigns/...`). The adapter
|
||||
strips the prefix server-side.
|
||||
|
||||
- REST / scripts / direct DB → bare: `campaigns/{cid}/encounters/{eid}`
|
||||
- If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used
|
||||
the prefixed form. Drop the prefix.
|
||||
|
||||
### Encounter doc shape
|
||||
|
||||
```js
|
||||
{
|
||||
name: 'Barrowdeep 1 - Antechamber',
|
||||
createdAt: new Date().toISOString(),
|
||||
participants: [], // inline roster
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
ruleset: '5e',
|
||||
}
|
||||
```
|
||||
|
||||
Build encounters with `round: 0`, `isStarted: false`. Starting combat is a
|
||||
separate in-combat op (UI Start button or a later skill).
|
||||
|
||||
### Participant object shape
|
||||
|
||||
```js
|
||||
{
|
||||
id: <uuid>, // crypto.randomUUID(), client-generated
|
||||
name: 'Ankheg',
|
||||
type: 'monster', // 'monster' | 'npc' | 'character'
|
||||
originalCharacterId: null, // set only if type === 'character'
|
||||
initiative: <int>, // rolled ONCE at add; stored, not re-derived
|
||||
maxHp: 45,
|
||||
currentHp: 45, // start at full
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0, // character/npc only
|
||||
deathSaveFailures: 0, // character/npc only
|
||||
}
|
||||
```
|
||||
|
||||
### type field
|
||||
|
||||
- `monster` — DM-controlled, dead at 0 HP (auto-inactive). Default.
|
||||
- `npc` — DM-controlled but keeps death saves (revivable). Use for named
|
||||
allies/villains the DM runs.
|
||||
- `character` — player character. Requires `originalCharacterId` linking to a
|
||||
campaign roster entry. For build-from-statblock, use `monster` or `npc`,
|
||||
not `character`.
|
||||
|
||||
### Initiative semantics
|
||||
|
||||
- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], rolled fresh per
|
||||
participant).
|
||||
- Manual override: set `initiative` directly (pre-rolled values), skip the
|
||||
roll. `initMod` is NOT stored on the participant — only the final
|
||||
`initiative` value persists.
|
||||
- Fixed at add time. Never re-derived after.
|
||||
|
||||
### id format
|
||||
|
||||
`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`).
|
||||
Generate participant ids client-side so they're stable across batch writes.
|
||||
Server `POST /api/collection` auto-generates a UUID for the doc id — fine for
|
||||
campaigns/encounters, but for inline participants you generate them yourself.
|
||||
|
||||
## REST endpoints
|
||||
|
||||
All paths bare canonical.
|
||||
|
||||
| Method | Path | Body / Query | Action |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/doc` | `?path=` | read one doc |
|
||||
| `PUT` | `/api/doc` | `{path, data}` | replace doc |
|
||||
| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss |
|
||||
| `DELETE` | `/api/doc` | `?path=` | delete one doc |
|
||||
| `GET` | `/api/collection` | `?path=` | list docs under collection |
|
||||
| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection |
|
||||
| `POST` | `/api/batch` | `{ops:[{type, path, data?}]}` | atomic multi-write |
|
||||
|
||||
`POST /api/batch` is the workhorse for seeding. One batch, all encounters,
|
||||
atomic.
|
||||
|
||||
## Build flow
|
||||
|
||||
### 1. Parse the user's request
|
||||
|
||||
Extract:
|
||||
- **Host** — the running tracker URL (e.g. `http://bee.icantclick.org:8080`).
|
||||
If omitted, ask. Do not guess.
|
||||
- **Campaign name** — must match an existing campaign doc's `name` field.
|
||||
- **Encounters** — list of `{name, participants:[{name, type?, maxHp, initMod?|initiative?}]}`.
|
||||
|
||||
A statblock line like `Ankheg - HP 45 - Init +0` parses to:
|
||||
`{name:"Ankheg", type:"monster", maxHp:45, initMod:0}`.
|
||||
|
||||
### 2. Resolve the campaign id
|
||||
|
||||
Campaigns are keyed by id, not name. Look up by name:
|
||||
|
||||
```bash
|
||||
curl -s "<HOST>/api/collection?path=campaigns" \
|
||||
| jq -r '.[] | select(.name=="<CAMPAIGN NAME>") | .id'
|
||||
```
|
||||
|
||||
Exactly one match expected. Zero → campaign doesn't exist (tell user to create
|
||||
it first). Multiple → ambiguous name, ask user which id.
|
||||
|
||||
### 3. Write encounters (two options)
|
||||
|
||||
#### Option A — helper script (recommended)
|
||||
|
||||
`scripts/build-encounters.js` handles id gen, initiative rolling, batch write,
|
||||
and verification. Reads a spec file or stdin.
|
||||
|
||||
Spec format (JSON array):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Optional Road/Trail Ankheg",
|
||||
"participants": [
|
||||
{ "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Barrowdeep 2 - Minor Burial Chamber",
|
||||
"participants": [
|
||||
{ "name": "Cult Fanatic", "type": "monster", "maxHp": 39, "initMod": 2 },
|
||||
{ "name": "Cultist 1", "type": "monster", "maxHp": 9, "initMod": 2 },
|
||||
{ "name": "Cultist 2", "type": "monster", "maxHp": 9, "initMod": 2 }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Run (resolve script path relative to this skill dir):
|
||||
```bash
|
||||
node scripts/build-encounters.js <HOST> "<CAMPAIGN NAME>" spec.json
|
||||
# or stdin:
|
||||
cat spec.json | node scripts/build-encounters.js <HOST> "<CAMPAIGN NAME>" -
|
||||
```
|
||||
|
||||
Script prints each encounter id + participant init rolls, then verifies.
|
||||
|
||||
#### Option B — raw curl (any harness, no node)
|
||||
|
||||
Build the batch payload inline. Generate UUIDs and roll initiative yourself.
|
||||
One `POST /api/batch` writes everything:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "<HOST>/api/batch" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"ops": [
|
||||
{
|
||||
"type": "set",
|
||||
"path": "campaigns/CID/encounters/EID1",
|
||||
"data": {
|
||||
"name": "Optional Road/Trail Ankheg",
|
||||
"createdAt": "2026-07-07T00:00:00.000Z",
|
||||
"participants": [
|
||||
{ "id": "UUID1", "name": "Ankheg", "type": "monster", "originalCharacterId": null, "initiative": 15, "maxHp": 45, "currentHp": 45, "conditions": [], "isActive": true, "status": "conscious", "deathSaveSuccesses": 0, "deathSaveFailures": 0 }
|
||||
],
|
||||
"round": 0, "currentTurnParticipantId": null, "isStarted": false, "isPaused": false, "ruleset": "5e"
|
||||
}
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
Response: `{"ok":true}`. If you get an error, check path is bare (no
|
||||
`artifacts/` prefix) and JSON is valid.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
Always verify after write. Don't trust the batch `ok` alone.
|
||||
|
||||
```bash
|
||||
curl -s "<HOST>/api/collection?path=campaigns/CID/encounters" \
|
||||
| jq '.[] | {name, participants: (.participants|length), started: .isStarted}'
|
||||
```
|
||||
|
||||
Expect one object per encounter with correct participant count and
|
||||
`started: false`.
|
||||
|
||||
### 5. Report back to user
|
||||
|
||||
Tell the user:
|
||||
- Host + campaign name resolved
|
||||
- Per encounter: name, id, participant count, rolled initiatives
|
||||
- Verification result (counts matched)
|
||||
- That combat is NOT started (encounters sit at `round: 0, isStarted: false`).
|
||||
User starts combat via the UI Start button when ready.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `GET /api/collection?path=artifacts/.../campaigns` returns `[]` | used firebase-prefixed path | drop `artifacts/{APP_ID}/public/data/` prefix, use bare `campaigns` |
|
||||
| `GET /health` or collection returns HTML (SPA shell) | server in firebase mode, or backend not mounted | confirm `REACT_APP_STORAGE=server` + backend running on the instance |
|
||||
| Batch returns `ok` but encounter missing | wrong campaign id, or path typo | re-resolve campaign id by name; check path segments |
|
||||
| Participant count mismatch on verify | participant objects malformed / dropped in JSON | validate each has all required fields; use script to avoid hand-JSON errors |
|
||||
| Initiative looks wrong | forgot `initiative` is stored value, not re-derived | roll `d20 + initMod` once at build, store the int. No `initMod` field on participant. |
|
||||
| Duplicate cultist names across encounters | fine — names need only be unique WITHIN an encounter, not across | no fix needed |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Creating campaigns (use UI or `POST /api/collection` with a campaign doc)
|
||||
- Starting combat / advancing turns / HP damage / death saves (in-combat ops)
|
||||
- Player display control (`activeDisplay/status`)
|
||||
- Firebase SDK mode (this skill is server-mode REST only)
|
||||
- Campaign roster characters (`players[]` on campaign doc) — those are
|
||||
templates; this skill adds inline combatants, not roster entries
|
||||
|
||||
## Reference
|
||||
|
||||
Full entity model, combat flow, and storage paths: `docs/ENCOUNTER_BUILDER.md`
|
||||
in the repo root. REST + DB internals: `docs/DEVELOPMENT.md` and
|
||||
`server/index.js`.
|
||||
@@ -0,0 +1,149 @@
|
||||
// build-encounters.js — seed TTRPG encounters via REST API.
|
||||
// Cross-harness: pi, claude, codex all call same script.
|
||||
// No deps beyond node builtins. Reads spec JSON, writes batch, verifies.
|
||||
//
|
||||
// Usage:
|
||||
// node build-encounters.js <host> <campaignName> <specFile>
|
||||
// cat spec.json | node build-encounters.js <host> <campaignName> -
|
||||
//
|
||||
// specFile = JSON array of encounters:
|
||||
// [
|
||||
// {
|
||||
// "name": "Barrowdeep 1 - Antechamber",
|
||||
// "participants": [
|
||||
// { "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 },
|
||||
// { "name": "Boss", "type": "npc", "maxHp": 39, "initMod": 2, "initiative": 15 }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
//
|
||||
// type: 'monster' | 'npc' | 'character'. Default 'monster'.
|
||||
// initiative: optional manual override. If omitted, rolled d20 + initMod.
|
||||
// initMod default 0. maxHp default 10.
|
||||
//
|
||||
// Output: written encounter ids + rolled initiatives. Exit 0 on success.
|
||||
|
||||
'use strict';
|
||||
const crypto = require('crypto');
|
||||
|
||||
const genId = () =>
|
||||
(typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
||||
|
||||
function makeParticipant(spec) {
|
||||
const type = spec.type || 'monster';
|
||||
const maxHp = spec.maxHp || 10;
|
||||
const initMod = spec.initMod || 0;
|
||||
const manual = spec.initiative !== undefined && spec.initiative !== null;
|
||||
return {
|
||||
id: genId(),
|
||||
name: spec.name,
|
||||
type,
|
||||
originalCharacterId: spec.originalCharacterId || null,
|
||||
initiative: manual ? Number(spec.initiative) : rollD20() + initMod,
|
||||
maxHp,
|
||||
currentHp: maxHp,
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0,
|
||||
deathSaveFailures: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function getJson(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`GET ${url} -> ${r.status}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
async function postJson(url, body) {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`POST ${url} -> ${r.status}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function findCampaignId(host, name) {
|
||||
const list = await getJson(`${host}/api/collection?path=campaigns`);
|
||||
const matches = list.filter(c => c.name === name);
|
||||
if (matches.length === 0) {
|
||||
console.error(`No campaign named "${name}". Existing:`);
|
||||
list.forEach(c => console.error(` ${c.name}`));
|
||||
process.exit(2);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
console.error(`Multiple campaigns named "${name}". Use unique name or pass id.`);
|
||||
matches.forEach(c => console.error(` ${c.id} ${c.name}`));
|
||||
process.exit(2);
|
||||
}
|
||||
return matches[0].id;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [host, campaignName, specArg] = process.argv.slice(2);
|
||||
if (!host || !campaignName || !specArg) {
|
||||
console.error('Usage: node build-encounters.js <host> <campaignName> <specFile|->');
|
||||
process.exit(1);
|
||||
}
|
||||
const specRaw = specArg === '-'
|
||||
? require('fs').readFileSync(0, 'utf8')
|
||||
: require('fs').readFileSync(specArg, 'utf8');
|
||||
const encounters = JSON.parse(specRaw);
|
||||
if (!Array.isArray(encounters)) throw new Error('spec must be a JSON array of encounters');
|
||||
|
||||
const cid = await findCampaignId(host, campaignName);
|
||||
console.log(`campaign: "${campaignName}" -> ${cid}`);
|
||||
|
||||
const encPath = `campaigns/${cid}/encounters`;
|
||||
const now = new Date().toISOString();
|
||||
const ops = [];
|
||||
const summary = [];
|
||||
for (const enc of encounters) {
|
||||
const id = genId();
|
||||
const path = `${encPath}/${id}`;
|
||||
const parts = (enc.participants || []).map(makeParticipant);
|
||||
ops.push({
|
||||
type: 'set',
|
||||
path,
|
||||
data: {
|
||||
name: enc.name,
|
||||
createdAt: now,
|
||||
participants: parts,
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
ruleset: enc.ruleset || '5e',
|
||||
},
|
||||
});
|
||||
summary.push({ name: enc.name, id, parts });
|
||||
}
|
||||
|
||||
const res = await postJson(`${host}/api/batch`, { ops });
|
||||
if (!res.ok) throw new Error(`batch failed: ${JSON.stringify(res)}`);
|
||||
console.log(`batch: ${ops.length} encounter(s) written\n`);
|
||||
|
||||
// Verify
|
||||
const written = await getJson(`${host}/api/collection?path=${encPath}`);
|
||||
const byId = new Map(written.map(e => [e.id, e]));
|
||||
|
||||
for (const s of summary) {
|
||||
const got = byId.get(s.id);
|
||||
const ok = got && got.name === s.name && (got.participants || []).length === s.parts.length;
|
||||
console.log(`${ok ? '✓' : '✗'} ${s.name}`);
|
||||
console.log(` id: ${s.id}`);
|
||||
if (!ok) { console.log(` VERIFY FAILED: got ${JSON.stringify(got && { name: got.name, n: (got.participants || []).length })}`); continue; }
|
||||
s.parts.forEach(p => {
|
||||
const i = p.initiative >= 0 ? `+${p.initiative}` : `${p.initiative}`;
|
||||
console.log(` - ${p.name} (${p.type}, hp ${p.maxHp}, init ${i})`);
|
||||
});
|
||||
}
|
||||
console.log('\nDone.');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('FAIL:', e.message); process.exit(1); });
|
||||
Vendored
+172
@@ -0,0 +1,172 @@
|
||||
// Jenkinsfile — build + publish both images to the thinkserver:5000 registry.
|
||||
//
|
||||
// ttrpg-tracker-firebase : root Dockerfile, nginx serving the static SPA,
|
||||
// Firebase storage (config baked in at build time).
|
||||
// ttrpg-tracker-sqlite : docker/Dockerfile, Caddy + Node, server/SQLite
|
||||
// storage (REACT_APP_STORAGE=server).
|
||||
//
|
||||
// Requirements on the Jenkins agent:
|
||||
// * Docker CLI available, agent user in the docker group (or DooD socket).
|
||||
// * thinkserver:5000 reachable and trusted by the Docker daemon. If it is a
|
||||
// plain-HTTP registry, add it to /etc/docker/daemon.json on the AGENT host:
|
||||
// { "insecure-registries": ["thinkserver:5000"] } (then restart docker)
|
||||
//
|
||||
// Jenkins credentials this pipeline expects:
|
||||
// * ttrpg-firebase-env-local (Secret file) — the .env.local contents with
|
||||
// REACT_APP_FIREBASE_* values. Baked into the firebase image at build time.
|
||||
// * thinkserver-registry (Username/pw) — OPTIONAL. Only needed if the
|
||||
// registry requires auth; the login stage is guarded below.
|
||||
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
timeout(time: 30, unit: 'MINUTES')
|
||||
}
|
||||
|
||||
parameters {
|
||||
string(name: 'TRACKER_APP_ID',
|
||||
defaultValue: 'ttrpg-initiative-tracker-default',
|
||||
description: 'Firestore/app namespace baked into the SQLite image (REACT_APP_TRACKER_APP_ID).')
|
||||
booleanParam(name: 'PUSH_LATEST', defaultValue: true,
|
||||
description: 'Also tag and push :latest in addition to the commit-SHA tag.')
|
||||
booleanParam(name: 'REGISTRY_AUTH', defaultValue: false,
|
||||
description: 'Enable docker login using the thinkserver-registry credential.')
|
||||
booleanParam(name: 'REDEPLOY_PORTAINER', defaultValue: true,
|
||||
description: 'After pushing, POST to the Portainer stack webhooks to re-pull and redeploy.')
|
||||
}
|
||||
|
||||
environment {
|
||||
REGISTRY = 'thinkserver:5000'
|
||||
FIREBASE_IMAGE = "${REGISTRY}/ttrpg-tracker-firebase"
|
||||
SQLITE_IMAGE = "${REGISTRY}/ttrpg-tracker-sqlite"
|
||||
DOCKER_BUILDKIT = '1'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
script {
|
||||
env.SHORT_SHA = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
|
||||
// Only build/push on main. BRANCH_NAME is set by Multibranch jobs; it
|
||||
// is unset in a plain "Pipeline from SCM" job, which already only ever
|
||||
// checks out main, so treat unset as main too.
|
||||
env.IS_MAIN = (env.BRANCH_NAME == null || env.BRANCH_NAME == 'main').toString()
|
||||
if (env.IS_MAIN != 'true') {
|
||||
echo "Branch '${env.BRANCH_NAME}' is not main — skipping image build/push."
|
||||
} else {
|
||||
echo "Building tag ${env.SHORT_SHA} (build #${env.BUILD_NUMBER})"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Registry login') {
|
||||
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REGISTRY_AUTH } } }
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: 'thinkserver-registry',
|
||||
usernameVariable: 'REG_USER',
|
||||
passwordVariable: 'REG_PASS')]) {
|
||||
sh 'echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build firebase image') {
|
||||
when { expression { env.IS_MAIN == 'true' } }
|
||||
steps {
|
||||
// Firebase config is compiled into the static bundle, so it must exist
|
||||
// at build time. Inject .env.local from the Jenkins secret file, then
|
||||
// the root Dockerfile's `COPY .env.local .env` picks it up.
|
||||
withCredentials([file(credentialsId: 'ttrpg-firebase-env-local', variable: 'FIREBASE_ENV')]) {
|
||||
sh '''
|
||||
cp "$FIREBASE_ENV" .env.local
|
||||
docker build \
|
||||
-f Dockerfile \
|
||||
-t "$FIREBASE_IMAGE:$SHORT_SHA" \
|
||||
.
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build sqlite image') {
|
||||
when { expression { env.IS_MAIN == 'true' } }
|
||||
steps {
|
||||
// Server/SQLite build. BuildKit is required (syntax directive + cache
|
||||
// mount in docker/Dockerfile). Context is the repo root.
|
||||
sh '''
|
||||
docker build \
|
||||
-f docker/Dockerfile \
|
||||
--build-arg REACT_APP_TRACKER_APP_ID="$TRACKER_APP_ID" \
|
||||
-t "$SQLITE_IMAGE:$SHORT_SHA" \
|
||||
.
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Tag latest') {
|
||||
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.PUSH_LATEST } } }
|
||||
steps {
|
||||
sh '''
|
||||
docker tag "$FIREBASE_IMAGE:$SHORT_SHA" "$FIREBASE_IMAGE:latest"
|
||||
docker tag "$SQLITE_IMAGE:$SHORT_SHA" "$SQLITE_IMAGE:latest"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Push') {
|
||||
when { expression { env.IS_MAIN == 'true' } }
|
||||
steps {
|
||||
sh '''
|
||||
docker push "$FIREBASE_IMAGE:$SHORT_SHA"
|
||||
docker push "$SQLITE_IMAGE:$SHORT_SHA"
|
||||
'''
|
||||
script {
|
||||
if (params.PUSH_LATEST) {
|
||||
sh '''
|
||||
docker push "$FIREBASE_IMAGE:latest"
|
||||
docker push "$SQLITE_IMAGE:latest"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Redeploy (Portainer webhooks)') {
|
||||
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REDEPLOY_PORTAINER } } }
|
||||
steps {
|
||||
// Webhook URLs are Secret-text credentials, so they never appear in the
|
||||
// repo and Jenkins masks them in the console. They are bound to shell
|
||||
// env vars and referenced inside a single-quoted sh block, so Groovy
|
||||
// never interpolates the value into the pipeline log.
|
||||
withCredentials([
|
||||
string(credentialsId: 'portainer-webhook-firebase', variable: 'WEBHOOK_FIREBASE'),
|
||||
string(credentialsId: 'portainer-webhook-sqlite', variable: 'WEBHOOK_SQLITE'),
|
||||
]) {
|
||||
sh '''
|
||||
set +x
|
||||
echo "Triggering Portainer redeploy (firebase stack)"
|
||||
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_FIREBASE"
|
||||
echo "Triggering Portainer redeploy (sqlite stack)"
|
||||
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_SQLITE"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
// Never leave Firebase secrets in the workspace or log out cleanly.
|
||||
sh 'rm -f .env.local || true'
|
||||
sh 'docker logout "$REGISTRY" || true'
|
||||
}
|
||||
success {
|
||||
echo "Pushed ${FIREBASE_IMAGE}:${SHORT_SHA} and ${SQLITE_IMAGE}:${SHORT_SHA}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,23 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
## Open
|
||||
|
||||
|
||||
fullscreen and dont lock on main app dm view and the no-game-player view
|
||||
|
||||
also better vert tab layout - labelt friendly
|
||||
|
||||
needs AC for players dude
|
||||
|
||||
and quick entry hp
|
||||
|
||||
hp do not carry from encounter to ecnounter!!!
|
||||
|
||||
|
||||
hp wont go over max and no temp hp support
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### dm list - keep active particpant in view (scroll)
|
||||
not sure good way to do this
|
||||
@@ -14,6 +31,11 @@ not sure good way to do this
|
||||
lots of updates
|
||||
|
||||
|
||||
## monsters per campaign and npcs
|
||||
## or/and ....copy from encoubnter?
|
||||
|
||||
|
||||
|
||||
|
||||
### TEST GAP: current branch changes need focused coverage
|
||||
- Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`.
|
||||
@@ -50,19 +72,6 @@ lots of updates
|
||||
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
|
||||
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
|
||||
|
||||
### BUG: select campaign during active combat = screen flicker, no action
|
||||
- In active encounter, click different campaign → flicker, no nav, no end.
|
||||
- Either block (toast: end encounter first) or auto-end current + switch.
|
||||
- Decide UX before fix.
|
||||
|
||||
|
||||
### FEAT: generic/non-5e rules mode
|
||||
- Campaign/encounter ruleset toggle: `5e` vs `generic`.
|
||||
- Generic mode turns off death saves/state machine.
|
||||
- Generic mode allows negative HP.
|
||||
- 5e mode rejects negative damage/heal and clamps HP at 0.
|
||||
|
||||
|
||||
|
||||
### FEAT: clarify "Is NPC" in add-participant
|
||||
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# docker/portainer-stack.yml — Portainer stack for the SQLite/server build.
|
||||
# Deploys the prebuilt image from the local registry (built + pushed by Jenkins).
|
||||
# Paste into Portainer's web editor (Stacks -> Add stack -> Web editor).
|
||||
#
|
||||
# TLS is terminated by the external nginx, which should reverse_proxy to
|
||||
# http://<docker-host>:8080 (and pass the WebSocket upgrade headers for /ws).
|
||||
services:
|
||||
app:
|
||||
image: thinkserver:5000/ttrpg-tracker-sqlite:latest
|
||||
container_name: ttrpg-tracker
|
||||
ports:
|
||||
- "8080:80" # host:container — nginx upstream is :8080
|
||||
volumes:
|
||||
- app-data:/data # SQLite DB lives here; back this up
|
||||
environment:
|
||||
- DB_PATH=/data/tracker.sqlite
|
||||
- PORT=4001 # node backend port (internal; caddy proxies to it)
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
@@ -64,6 +64,15 @@ Object in `encounter.participants[]`:
|
||||
| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
|
||||
| `deathSaveFailures` | int | character/NPC only, 0-3 failures |
|
||||
|
||||
## Path normalization
|
||||
|
||||
App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). Server-mode adapter `norm()` strips prefix → bare canonical (`campaigns/...`).
|
||||
|
||||
- **UI / app code** → prefixed paths (`getPath.*` helpers)
|
||||
- **REST API / direct DB / scripts** → bare canonical paths (`campaigns/...`)
|
||||
|
||||
Mixing = empty results. If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used the prefixed form. Drop the prefix.
|
||||
|
||||
## Build flow (UI)
|
||||
|
||||
Admin view at `/`. Steps:
|
||||
@@ -121,6 +130,101 @@ Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initia
|
||||
|
||||
During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
|
||||
|
||||
## Build flow (API / scripts)
|
||||
|
||||
Same logical steps as UI, via REST. For LLM agents or seed scripts.
|
||||
|
||||
**Server mode only.** REST endpoints (`/api/*`) exist only when tracker runs
|
||||
with `REACT_APP_STORAGE=server` (bundled Express backend mounted). Firebase
|
||||
SDK mode has no HTTP backend — data lives in Firestore via in-browser SDK,
|
||||
different auth + write paths. This section does not apply to firebase mode.
|
||||
|
||||
Confirm server mode before scripting:
|
||||
```bash
|
||||
curl -s <HOST>/api/collection?path=campaigns | head -c 200
|
||||
```
|
||||
JSON array = server mode. HTML (SPA shell) = firebase mode or backend not
|
||||
mounted — API path won't work.
|
||||
|
||||
### REST endpoints
|
||||
|
||||
| Method | Path | Body / Query | Action |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/doc` | `?path=` | read one doc |
|
||||
| `PUT` | `/api/doc` | `{path, data}` | replace doc |
|
||||
| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss |
|
||||
| `DELETE` | `/api/doc` | `?path=` | delete one doc |
|
||||
| `GET` | `/api/collection` | `?path=` | list docs under collection |
|
||||
| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection |
|
||||
| `POST` | `/api/batch` | `{ops:[{type:'set'\|'update'\|'delete', path, data?}]}` | atomic multi-write |
|
||||
|
||||
All paths bare canonical (no `artifacts/...` prefix).
|
||||
|
||||
### id format
|
||||
|
||||
`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`). Use either. Server `POST /api/collection` generates its own UUID — fine for campaigns/encounters. Participants are inline array items; generate their ids client-side so they're stable across batch writes.
|
||||
|
||||
### Object templates
|
||||
|
||||
Encounter doc (full, for `PUT` / batch `set`):
|
||||
```js
|
||||
{
|
||||
name: 'Barrowdeep 1 - Antechamber',
|
||||
createdAt: new Date().toISOString(),
|
||||
participants: [], // inline, NOT a sub-collection
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
ruleset: '5e',
|
||||
}
|
||||
```
|
||||
|
||||
Participant object (item in `encounter.participants[]`):
|
||||
```js
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'Ankheg',
|
||||
type: 'monster', // 'character' | 'npc' | 'monster'
|
||||
originalCharacterId: null, // set only if type==='character'
|
||||
initiative: rollD20() + initMod, // rolled ONCE at add; stored, not re-derived
|
||||
maxHp: 45,
|
||||
currentHp: 45, // start at full
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0, // character/npc only
|
||||
deathSaveFailures: 0, // character/npc only
|
||||
}
|
||||
```
|
||||
|
||||
### Initiative semantics
|
||||
|
||||
- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], roll fresh per participant).
|
||||
- Manual override (e.g. pre-rolled values): set `initiative` directly, skip the roll.
|
||||
- Fixed at add time. NOT re-derived from `initMod` after — `initMod` is not stored on participant.
|
||||
|
||||
### Recipe: build encounters for a campaign
|
||||
|
||||
1. **Find campaign id by name:**
|
||||
```bash
|
||||
curl -s "$HOST/api/collection?path=campaigns" \
|
||||
| jq -r '.[] | select(.name=="2026 D&D") | .id'
|
||||
```
|
||||
2. **Build encounter docs + participants client-side** (object templates above), roll initiative per participant.
|
||||
3. **Write all encounters in one batch:**
|
||||
```bash
|
||||
curl -s -X POST "$HOST/api/batch" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ops":[{"type":"set","path":"campaigns/CID/encounters/EID1","data":{...}},{"type":"set","path":"campaigns/CID/encounters/EID2","data":{...}}]}'
|
||||
```
|
||||
4. **Verify:**
|
||||
```bash
|
||||
curl -s "$HOST/api/collection?path=campaigns/CID/encounters" | jq '.[] | {name, count: (.participants|length)}'
|
||||
```
|
||||
|
||||
Participants go inline in the encounter doc — there is no `participants` sub-collection. A single batch `set` per encounter writes the whole roster atomically.
|
||||
|
||||
## Combat flow (UI)
|
||||
|
||||
InitiativeControls panel (sticky, right side).
|
||||
@@ -198,6 +302,8 @@ No re-sort after `startEncounter`.
|
||||
|
||||
## Storage paths quick reference
|
||||
|
||||
Bare canonical (server mode). App code prefixes these with `artifacts/{APP_ID}/public/data/` — adapter strips it.
|
||||
|
||||
```
|
||||
campaigns/{cid} campaign doc
|
||||
campaigns/{cid}/encounters/{eid} encounter doc (participants[])
|
||||
@@ -206,6 +312,8 @@ activeDisplay/status player display control
|
||||
logs/{logId} action log entry
|
||||
```
|
||||
|
||||
REST mapping: `campaigns/{cid}` doc → `GET/PUT/PATCH/DELETE /api/doc?path=campaigns/{cid}`. Collection parent (`campaigns`, `campaigns/{cid}/encounters`) → `GET/POST /api/collection?path=...`.
|
||||
|
||||
## DM tips
|
||||
|
||||
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"short_name": "TTRPG Display",
|
||||
"name": "TTRPG Initiative Tracker — Player Display",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": "/display",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "landscape",
|
||||
"theme_color": "#1A202C",
|
||||
"background_color": "#1A202C"
|
||||
}
|
||||
@@ -28,6 +28,7 @@ fi
|
||||
# frontend: server storage, :3999
|
||||
if ! lsof -ti :3999 >/dev/null 2>&1; then
|
||||
echo "starting frontend :3999..."
|
||||
NODE_ENV=development REACT_APP_DEV_TOOLS=1 \
|
||||
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 \
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// addParticipants (bulk) must slot by initiative, not append.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
const { buildCharacterParticipant, buildMonsterParticipant, addParticipants, makeParticipant } = shared;
|
||||
|
||||
function enc(ps = []) {
|
||||
return { name:'t', participants: ps, isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [] };
|
||||
}
|
||||
function p(id, init) {
|
||||
return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 10, currentHp: 10 });
|
||||
}
|
||||
|
||||
describe('addParticipants slot order', () => {
|
||||
test('bulk add slots by initiative desc', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([]);
|
||||
e = await addParticipants(e, [p('a', 5), p('b', 20), p('c', 10)], ctx);
|
||||
expect(e.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
test('bulk add preserves existing order + slots new', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a', 20), p('b', 5)]);
|
||||
e = await addParticipants(e, [p('c', 10)], ctx);
|
||||
expect(e.participants.map(x => x.id)).toEqual(['a', 'c', 'b']);
|
||||
});
|
||||
|
||||
test('bulk add same-init appends after existing (stable tie)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a', 10)]);
|
||||
e = await addParticipants(e, [p('b', 10), p('c', 10)], ctx);
|
||||
expect(e.participants.map(x => x.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('bulk add mixed inits into existing list', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a', 15), p('b', 5)]);
|
||||
e = await addParticipants(e, [p('c', 20), p('d', 10), p('e', 15)], ctx);
|
||||
expect(e.participants.map(x => x.id)).toEqual(['c', 'a', 'e', 'd', 'b']);
|
||||
});
|
||||
|
||||
test('bulk add updates turnOrderIds to match', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([]);
|
||||
e = await addParticipants(e, [p('a', 5), p('b', 20)], ctx);
|
||||
expect(e.turnOrderIds).toEqual(['b', 'a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
// Generic (non-5e) ruleset: no death saves, negative HP allowed, down status.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
const {
|
||||
buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, applyHpChange, deathSave, markDead, reviveParticipant,
|
||||
} = shared;
|
||||
|
||||
function char(id, hp = 100) {
|
||||
const { participant } = buildCharacterParticipant({ id: `orig-${id}`, name: id, defaultMaxHp: hp, defaultInitMod: 2 });
|
||||
participant.id = id;
|
||||
return participant;
|
||||
}
|
||||
function mon(id, hp = 100) {
|
||||
const { participant } = buildMonsterParticipant({ name: id, maxHp: hp, initMod: 2 });
|
||||
participant.id = id;
|
||||
return participant;
|
||||
}
|
||||
function enc(ps, ruleset = 'generic') {
|
||||
return {
|
||||
name: 't', participants: ps, isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
ruleset,
|
||||
};
|
||||
}
|
||||
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||
|
||||
describe('generic ruleset', () => {
|
||||
test('damage past 0 = negative HP, status down (character)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 15, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.currentHp).toBe(-5);
|
||||
expect(p.status).toBe('down');
|
||||
});
|
||||
|
||||
test('damage exactly to 0 = status down', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.currentHp).toBe(0);
|
||||
expect(p.status).toBe('down');
|
||||
});
|
||||
|
||||
test('heal from negative = conscious', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5
|
||||
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.currentHp).toBe(5);
|
||||
expect(p.status).toBe('conscious');
|
||||
});
|
||||
|
||||
test('heal caps at maxHp', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 5, ctx); // 5
|
||||
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.currentHp).toBe(10);
|
||||
expect(p.status).toBe('conscious');
|
||||
});
|
||||
|
||||
test('monster death = auto-inactive (generic)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([mon('m', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await applyHpChange(e, 'm', 'damage', 20, ctx);
|
||||
const p = newEnc.participants.find(x => x.id.startsWith('m'));
|
||||
expect(p.currentHp).toBe(-10);
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('monster at exactly 0 = dead + inactive (generic)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([mon('m', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await applyHpChange(e, 'm', 'damage', 10, ctx);
|
||||
const p = newEnc.participants.find(x => x.id.startsWith('m'));
|
||||
expect(p.currentHp).toBe(0);
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('deathSave throws in generic mode', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||
await expect(deathSave(e, 'a', 'fail', ctx)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('revive in generic mode: dead→conscious (no stable)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||
e = await markDead(e, 'a', ctx); // dead
|
||||
const newEnc = await reviveParticipant(e, 'a', ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.status).toBe('conscious');
|
||||
expect(p.currentHp).toBe(0); // HP unchanged from down
|
||||
});
|
||||
|
||||
test('revive generic reactivates if inactive', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([mon('m', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await markDead(e, e.participants[0].id, ctx); // dead+inactive
|
||||
const newEnc = await reviveParticipant(e, e.participants[0].id, ctx);
|
||||
const p = newEnc.participants[0];
|
||||
expect(p.status).toBe('conscious');
|
||||
expect(p.isActive).toBe(true);
|
||||
});
|
||||
|
||||
test('markDead sets status dead (character, stays active)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||
const newEnc = await markDead(e, 'a', ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.isActive).toBe(true);
|
||||
});
|
||||
|
||||
test('markDead on monster = dead + inactive', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([mon('m', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'm', 'damage', 5, ctx); // down (5hp)
|
||||
const newEnc = await markDead(e, e.participants[0].id, ctx);
|
||||
const p = newEnc.participants[0];
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('markDead on conscious = dead', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await markDead(e, 'a', ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.status).toBe('dead');
|
||||
});
|
||||
|
||||
test('damage while down = more negative (no death saves)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 down
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.currentHp).toBe(-10);
|
||||
expect(p.status).toBe('down');
|
||||
});
|
||||
|
||||
test('no unconscious condition auto-added (generic)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.conditions).not.toContain('unconscious');
|
||||
});
|
||||
|
||||
test('down→conscious clears dead? no, dead is separate', async () => {
|
||||
// sanity: down heal to conscious, dead untouched
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.status).toBe('conscious');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// HP formula roll: parse "2d6+9" style, roll, return total.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { rollHpFormula } = shared;
|
||||
|
||||
describe('rollHpFormula', () => {
|
||||
test('basic NdM', () => {
|
||||
const r = rollHpFormula('2d6');
|
||||
expect(r).toBeGreaterThanOrEqual(2);
|
||||
expect(r).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
test('NdM+bonus', () => {
|
||||
const r = rollHpFormula('2d6+9');
|
||||
expect(r).toBeGreaterThanOrEqual(11);
|
||||
expect(r).toBeLessThanOrEqual(21);
|
||||
});
|
||||
|
||||
test('NdM-bonus', () => {
|
||||
const r = rollHpFormula('3d8-2');
|
||||
expect(r).toBeGreaterThanOrEqual(1);
|
||||
expect(r).toBeLessThanOrEqual(22);
|
||||
});
|
||||
|
||||
test('single die', () => {
|
||||
const r = rollHpFormula('1d20');
|
||||
expect(r).toBeGreaterThanOrEqual(1);
|
||||
expect(r).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test('case insensitive', () => {
|
||||
const r = rollHpFormula('2D6+5');
|
||||
expect(r).toBeGreaterThanOrEqual(7);
|
||||
expect(r).toBeLessThanOrEqual(17);
|
||||
});
|
||||
|
||||
test('whitespace tolerant', () => {
|
||||
const r = rollHpFormula(' 2d6 + 9 ');
|
||||
expect(r).toBeGreaterThanOrEqual(11);
|
||||
expect(r).toBeLessThanOrEqual(21);
|
||||
});
|
||||
|
||||
test('large die', () => {
|
||||
const r = rollHpFormula('4d12+10');
|
||||
expect(r).toBeGreaterThanOrEqual(14);
|
||||
expect(r).toBeLessThanOrEqual(58);
|
||||
});
|
||||
|
||||
test('invalid returns null', () => {
|
||||
expect(rollHpFormula('abc')).toBeNull();
|
||||
expect(rollHpFormula('')).toBeNull();
|
||||
expect(rollHpFormula(null)).toBeNull();
|
||||
expect(rollHpFormula(undefined)).toBeNull();
|
||||
expect(rollHpFormula('2d')).toBeNull();
|
||||
expect(rollHpFormula('d6')).toBeNull();
|
||||
});
|
||||
|
||||
test('zero dice invalid', () => {
|
||||
expect(rollHpFormula('0d6')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,13 @@ function enc(ps) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||
}
|
||||
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||
const snap = (e) => {
|
||||
const o = JSON.parse(JSON.stringify(e));
|
||||
// normalize nullable timestamp fields: null = absent (undo clears via null)
|
||||
if (o.startedAt === null) delete o.startedAt;
|
||||
if (o.endedAt === null) delete o.endedAt;
|
||||
return o;
|
||||
};
|
||||
|
||||
describe('undo + redo roundtrip', () => {
|
||||
test('startEncounter', async () => {
|
||||
|
||||
+174
-14
@@ -28,6 +28,22 @@ const generateId = () =>
|
||||
|
||||
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
||||
|
||||
// Parse HP formula like "2d6+9" or "3d8-2" or "1d20". Roll, return total.
|
||||
// Returns null on invalid input. Whitespace/case tolerant.
|
||||
function rollHpFormula(str) {
|
||||
if (!str || typeof str !== 'string') return null;
|
||||
const m = str.trim().toLowerCase().match(/^(\d+)d(\d+)\s*([+-]\s*\d+)?$/);
|
||||
if (!m) return null;
|
||||
const count = parseInt(m[1], 10);
|
||||
const sides = parseInt(m[2], 10);
|
||||
if (count === 0 || sides === 0) return null;
|
||||
let bonus = 0;
|
||||
if (m[3]) bonus = parseInt(m[3].replace(/\s/g, ''), 10);
|
||||
let total = 0;
|
||||
for (let i = 0; i < count; i++) total += Math.floor(Math.random() * sides) + 1;
|
||||
return total + bonus;
|
||||
}
|
||||
|
||||
const formatInitMod = (mod) => {
|
||||
if (mod === undefined || mod === null) return 'N/A';
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
@@ -102,6 +118,8 @@ function snapshotOf(enc) {
|
||||
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
||||
turnOrderIds: [...(enc.turnOrderIds || [])],
|
||||
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
||||
startedAt: enc.startedAt ?? null,
|
||||
endedAt: enc.endedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,6 +225,7 @@ function expandUndo(entry, currentEnc) {
|
||||
case 'death_save':
|
||||
case 'stabilize':
|
||||
case 'revive':
|
||||
case 'mark_dead':
|
||||
case 'deactivate_dead_monster': {
|
||||
// Full death-state restore: currentHp, status, death-save counters,
|
||||
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
|
||||
@@ -304,12 +323,18 @@ function expandUndo(entry, currentEnc) {
|
||||
redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
|
||||
};
|
||||
}
|
||||
case 'start_encounter':
|
||||
case 'start_encounter': {
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { ...u, startedAt: u.startedAt ?? null },
|
||||
redo: { isStarted: true, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], startedAt: snap.startedAt ?? Date.now() },
|
||||
};
|
||||
}
|
||||
case 'end_encounter': {
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: u,
|
||||
redo: { isStarted: entry.type === 'start_encounter', isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
|
||||
updates: { ...u, endedAt: u.endedAt ?? null },
|
||||
redo: { isStarted: false, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], endedAt: snap.endedAt ?? Date.now() },
|
||||
};
|
||||
}
|
||||
default:
|
||||
@@ -335,6 +360,7 @@ function makeParticipant(opts) {
|
||||
status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'),
|
||||
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: opts.deathSaveFailures || 0,
|
||||
hpFormula: opts.hpFormula || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,11 +382,22 @@ function buildCharacterParticipant(character) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
|
||||
function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) {
|
||||
const initiativeRoll = rollD20();
|
||||
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
||||
const finalInitiative = initiativeRoll + modifier;
|
||||
const hp = maxHp || DEFAULT_MAX_HP;
|
||||
// maxHp wins if both set; else roll formula; else default.
|
||||
let hp;
|
||||
let hpRoll = null;
|
||||
if (maxHp) {
|
||||
hp = maxHp;
|
||||
} else if (hpFormula) {
|
||||
const rolled = rollHpFormula(hpFormula);
|
||||
hp = rolled || DEFAULT_MAX_HP;
|
||||
hpRoll = rolled;
|
||||
} else {
|
||||
hp = DEFAULT_MAX_HP;
|
||||
}
|
||||
return {
|
||||
participant: makeParticipant({
|
||||
name,
|
||||
@@ -368,8 +405,9 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
|
||||
initiative: finalInitiative,
|
||||
maxHp: hp,
|
||||
currentHp: hp,
|
||||
hpFormula: hpFormula || null,
|
||||
}),
|
||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,6 +428,8 @@ async function startEncounter(encounter, ctx) {
|
||||
participants: sortedParticipants,
|
||||
currentTurnParticipantId: firstActive.id,
|
||||
turnOrderIds: sortedParticipants.map(p => p.id),
|
||||
startedAt: Date.now(),
|
||||
endedAt: null,
|
||||
};
|
||||
const log = {
|
||||
type: 'start_encounter',
|
||||
@@ -402,6 +442,8 @@ async function startEncounter(encounter, ctx) {
|
||||
round: encounter.round ?? 0,
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
startedAt: encounter.startedAt ?? null,
|
||||
endedAt: encounter.endedAt ?? null,
|
||||
},
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
@@ -478,14 +520,20 @@ async function addParticipant(encounter, participant, ctx) {
|
||||
}
|
||||
|
||||
async function addParticipants(encounter, newParticipants, ctx) {
|
||||
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
|
||||
// Slot each new participant into list by initiative (desc), preserve
|
||||
// existing order + drag-established ties. Matches addParticipant semantics.
|
||||
let base = [...(encounter.participants || [])];
|
||||
for (const np of newParticipants) {
|
||||
const idx = slotIndexForInit(base, np.initiative);
|
||||
base.splice(idx, 0, np);
|
||||
}
|
||||
const names = newParticipants.map(p => p.name).join(', ');
|
||||
const patch = { participants: updatedParticipants };
|
||||
const patch = { participants: base, ...syncTurnOrder(base) };
|
||||
const log = {
|
||||
type: 'add_participants',
|
||||
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
|
||||
delta: { count: newParticipants.length },
|
||||
undo: { participants: newParticipants },
|
||||
undo: { participants: newParticipants, newTurnOrderIds: patch.turnOrderIds },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -574,6 +622,7 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
|
||||
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
|
||||
const ctx = maybeCtx || optionsOrCtx;
|
||||
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
||||
const ruleset = encounter.ruleset || '5e';
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
if (isNaN(amount) || amount === 0) return encounter;
|
||||
@@ -596,6 +645,12 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
let logDelta = { amount, from: participant.currentHp };
|
||||
const status = oldValues.status;
|
||||
|
||||
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
|
||||
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||
if (ruleset === 'generic') {
|
||||
return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx);
|
||||
}
|
||||
|
||||
if (changeType === 'damage') {
|
||||
if (participant.currentHp === 0) {
|
||||
if (status === 'dead') {
|
||||
@@ -683,7 +738,76 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
// GENERIC ruleset HP change: no death saves, negative HP, down status at <=0.
|
||||
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
|
||||
const participantId = participant.id;
|
||||
let updates, message, logDelta = { amount, from: participant.currentHp };
|
||||
|
||||
if (changeType === 'damage') {
|
||||
if (oldValues.status === 'dead') {
|
||||
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, isActive: false } : p
|
||||
);
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp, status: newP.status,
|
||||
deathSaveSuccesses: 0, deathSaveFailures: 0,
|
||||
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants }, {
|
||||
type: 'deactivate_dead_monster', participantId, participantName: participant.name,
|
||||
message: `${participant.name} marked inactive (dead monster)`,
|
||||
delta: { status: 'dead', isActive: false },
|
||||
undo: { oldValues, newValues },
|
||||
}, ctx);
|
||||
}
|
||||
return encounter;
|
||||
}
|
||||
const newHp = participant.currentHp - amount;
|
||||
const isMonster = participant.type === 'monster';
|
||||
if (newHp <= 0 && isMonster) {
|
||||
updates = { currentHp: newHp, status: 'dead', isActive: false };
|
||||
} else if (newHp <= 0) {
|
||||
updates = { currentHp: newHp, status: 'down' };
|
||||
} else {
|
||||
updates = { currentHp: newHp, status: 'conscious' };
|
||||
}
|
||||
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||
} else if (changeType === 'heal') {
|
||||
if (oldValues.status === 'dead') return encounter;
|
||||
const newHp = Math.min(participant.maxHp, participant.currentHp + amount);
|
||||
updates = { currentHp: newHp, status: 'conscious' };
|
||||
message = `${participant.name} healed for ${amount} (${participant.currentHp} → ${newHp} HP)`;
|
||||
logDelta = { ...logDelta, to: newHp, status: 'conscious' };
|
||||
} else {
|
||||
throw new Error(`Unknown HP change type: ${changeType}`);
|
||||
}
|
||||
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...updates } : p
|
||||
);
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp, status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const undoAmount = changeType === 'damage' ? amount : -amount;
|
||||
return commit(encounter, { participants: updatedParticipants }, {
|
||||
type: changeType, participantId, participantName: participant.name,
|
||||
message, delta: logDelta,
|
||||
undo: { amount: undoAmount, oldValues, newValues },
|
||||
}, ctx);
|
||||
}
|
||||
|
||||
async function deathSave(encounter, participantId, outcome, ctx) {
|
||||
if ((encounter.ruleset || '5e') === 'generic') {
|
||||
throw new Error('Death saves not available in generic ruleset.');
|
||||
}
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||
@@ -764,6 +888,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
|
||||
}
|
||||
|
||||
async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
if ((encounter.ruleset || '5e') === 'generic') {
|
||||
throw new Error('Stabilize not available in generic ruleset.');
|
||||
}
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||
@@ -806,12 +933,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
}
|
||||
|
||||
async function reviveParticipant(encounter, participantId, ctx) {
|
||||
const ruleset = encounter.ruleset || '5e';
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||
if (status !== 'dead') return encounter;
|
||||
|
||||
const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
|
||||
// Generic: dead→conscious (no stable state). HP unchanged (may be negative).
|
||||
// 5e: dead→stable at 0 HP, unconscious, clears death-save counters.
|
||||
const updates = ruleset === 'generic'
|
||||
? { status: 'conscious', ...(participant.type === 'monster' ? {} : {}), ...(participant.isActive === false ? { isActive: true } : {}) }
|
||||
: { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||
);
|
||||
@@ -845,6 +977,34 @@ async function reviveParticipant(encounter, participantId, ctx) {
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
// markDead: DM manually sets status dead. Both rulesets. Monster = auto-inactive.
|
||||
async function markDead(encounter, participantId, ctx) {
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
if (participant.status === 'dead') return encounter;
|
||||
const oldValues = {
|
||||
currentHp: participant.currentHp,
|
||||
status: participant.status || 'conscious',
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
const updates = { status: 'dead', ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||
);
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp, status: newP.status,
|
||||
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants }, {
|
||||
type: 'mark_dead', participantId, participantName: participant.name,
|
||||
message: `${participant.name} marked dead`,
|
||||
delta: { status: 'dead' },
|
||||
undo: { oldValues, newValues },
|
||||
}, ctx);
|
||||
}
|
||||
|
||||
async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
||||
const participants = [...(encounter.participants || [])];
|
||||
const dragged = participants.find(p => p.id === draggedId);
|
||||
@@ -884,12 +1044,12 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
||||
}
|
||||
|
||||
async function endEncounter(encounter, ctx) {
|
||||
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] };
|
||||
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [], endedAt: Date.now() };
|
||||
const log = {
|
||||
type: 'end_encounter',
|
||||
message: `Combat ended: "${encounter.name}"`,
|
||||
delta: {},
|
||||
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])] },
|
||||
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], endedAt: encounter.endedAt ?? null },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -913,13 +1073,13 @@ async function toggleHidePlayerHp(currentValue, ctx) {
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
|
||||
generateId, rollD20, formatInitMod,
|
||||
generateId, rollD20, rollHpFormula, formatInitMod,
|
||||
sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval,
|
||||
snapshotOf, buildEntry, expandUndo,
|
||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
|
||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
||||
reorderParticipants, endEncounter,
|
||||
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
||||
};
|
||||
|
||||
+336
-56
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react';
|
||||
import * as shared from '@ttrpg/shared';
|
||||
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage';
|
||||
import { isDevToolsEnabled } from './config/devTools';
|
||||
import {
|
||||
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
||||
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
||||
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
|
||||
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
|
||||
StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText,
|
||||
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
|
||||
Undo2, Redo2, Crosshair
|
||||
} from 'lucide-react';
|
||||
@@ -125,6 +126,7 @@ const {
|
||||
deathSave: combatDeathSave,
|
||||
stabilizeParticipant,
|
||||
reviveParticipant,
|
||||
markDead,
|
||||
toggleCondition: combatToggleCondition,
|
||||
reorderParticipants,
|
||||
} = shared;
|
||||
@@ -448,11 +450,12 @@ function ErrorDisplay({ message, critical = false }) {
|
||||
function CreateCampaignForm({ onCreate, onCancel }) {
|
||||
const [name, setName] = useState('');
|
||||
const [backgroundUrl, setBackgroundUrl] = useState('');
|
||||
const [ruleset, setRuleset] = useState('5e');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onCreate(name, backgroundUrl);
|
||||
onCreate(name, backgroundUrl, ruleset);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -484,6 +487,19 @@ function CreateCampaignForm({ onCreate, onCancel }) {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||||
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
|
||||
D&D 5e (death saves)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||||
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
|
||||
Generic (no death saves, negative HP)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -503,13 +519,14 @@ function CreateCampaignForm({ onCreate, onCancel }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CreateEncounterForm({ onCreate, onCancel }) {
|
||||
function CreateEncounterForm({ onCreate, onCancel, defaultRuleset = '5e' }) {
|
||||
const [name, setName] = useState('');
|
||||
const [ruleset, setRuleset] = useState(defaultRuleset);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onCreate(name);
|
||||
onCreate(name, ruleset);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -528,6 +545,19 @@ function CreateEncounterForm({ onCreate, onCancel }) {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||||
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
|
||||
D&D 5e (death saves)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||||
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
|
||||
Generic (no death saves, negative HP)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -552,15 +582,26 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
const [initiative, setInitiative] = useState(participant.initiative);
|
||||
const [currentHp, setCurrentHp] = useState(participant.currentHp);
|
||||
const [maxHp, setMaxHp] = useState(participant.maxHp);
|
||||
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
|
||||
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
let finalMaxHp = parseInt(maxHp, 10);
|
||||
// formula rolls only if maxHp empty. maxHp wins if both.
|
||||
if (isNaN(finalMaxHp) && hpFormula.trim()) {
|
||||
const rolled = shared.rollHpFormula(hpFormula);
|
||||
if (rolled) finalMaxHp = rolled;
|
||||
}
|
||||
if (isNaN(finalMaxHp)) finalMaxHp = participant.maxHp;
|
||||
let finalCurrentHp = parseInt(currentHp, 10);
|
||||
if (isNaN(finalCurrentHp)) finalCurrentHp = finalMaxHp;
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
initiative: parseInt(initiative, 10),
|
||||
currentHp: parseInt(currentHp, 10),
|
||||
maxHp: parseInt(maxHp, 10),
|
||||
currentHp: finalCurrentHp,
|
||||
maxHp: finalMaxHp,
|
||||
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
|
||||
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
|
||||
});
|
||||
};
|
||||
@@ -607,6 +648,32 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(participant.type === 'monster' || participant.type === 'npc') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-300 mb-1">HP Formula</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={hpFormula}
|
||||
onChange={(e) => setHpFormula(e.target.value)}
|
||||
placeholder="2d6+9"
|
||||
className="flex-1 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!shared.rollHpFormula(hpFormula)}
|
||||
onClick={() => {
|
||||
const rolled = shared.rollHpFormula(hpFormula);
|
||||
if (rolled) setMaxHp(rolled);
|
||||
}}
|
||||
className="px-3 py-2 text-sm font-medium text-white bg-violet-700 hover:bg-violet-800 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Roll formula, set Max HP"
|
||||
>
|
||||
<Dices size={16} className="inline" /> Reroll
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(participant.type === 'monster' || participant.type === 'npc') && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
@@ -653,7 +720,30 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
const [editingCharacter, setEditingCharacter] = useState(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [draggedCharId, setDraggedCharId] = useState(null);
|
||||
|
||||
const handleCharDragStart = (e, id) => { setDraggedCharId(id); e.dataTransfer.effectAllowed = 'move'; };
|
||||
const handleCharDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; };
|
||||
const handleCharDrop = async (e, targetId) => {
|
||||
e.preventDefault();
|
||||
if (!campaignId || !draggedCharId || draggedCharId === targetId) { setDraggedCharId(null); return; }
|
||||
const reordered = [...campaignCharacters];
|
||||
const fromIdx = reordered.findIndex(c => c.id === draggedCharId);
|
||||
const toIdx = reordered.findIndex(c => c.id === targetId);
|
||||
if (fromIdx === -1 || toIdx === -1) { setDraggedCharId(null); return; }
|
||||
const [moved] = reordered.splice(fromIdx, 1);
|
||||
reordered.splice(toIdx, 0, moved);
|
||||
try { await storage.updateDoc(getPath.campaign(campaignId), { players: reordered }); } catch (err) {}
|
||||
setDraggedCharId(null);
|
||||
};
|
||||
const [isOpen, setIsOpen] = useState(() => {
|
||||
try { return localStorage.getItem('ttrpg.charactersCollapsed') !== 'true'; }
|
||||
catch { return true; }
|
||||
});
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('ttrpg.charactersCollapsed', String(!isOpen)); }
|
||||
catch {}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleAddCharacter = async () => {
|
||||
if (!db || !characterName.trim() || !campaignId) return;
|
||||
@@ -740,15 +830,14 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
<>
|
||||
<div className="p-4 bg-stone-900 rounded-lg shadow">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center">
|
||||
<Users size={24} className="mr-2" /> Campaign Characters
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-1 text-stone-400 hover:text-stone-200"
|
||||
aria-label={isOpen ? "Collapse" : "Expand"}
|
||||
className="flex items-center text-xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
{isOpen ? <ChevronDown size={20} className="mr-1" /> : <ChevronRight size={20} className="mr-1" />}
|
||||
<Users size={24} className="mr-2" /> Campaign Characters
|
||||
<span className="text-sm font-normal text-stone-400 ml-1">({campaignCharacters.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -806,7 +895,17 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
|
||||
<ul className="space-y-2">
|
||||
{campaignCharacters.map(character => (
|
||||
<li key={character.id} className="flex justify-between items-center p-3 bg-stone-800 rounded-md">
|
||||
<li
|
||||
key={character.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleCharDragStart(e, character.id)}
|
||||
onDragOver={handleCharDragOver}
|
||||
onDrop={(e) => handleCharDrop(e, character.id)}
|
||||
onDragEnd={() => setDraggedCharId(null)}
|
||||
className={`flex justify-between items-center p-3 bg-stone-800 rounded-md cursor-grab ${draggedCharId === character.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
|
||||
>
|
||||
<div className="flex items-center flex-grow">
|
||||
<ChevronsUpDown size={16} className="text-stone-400 flex-shrink-0 mr-2" title="Drag to reorder" />
|
||||
{editingCharacter && editingCharacter.id === character.id ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@@ -882,6 +981,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -910,7 +1010,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const [participantType, setParticipantType] = useState('monster');
|
||||
const [selectedCharacterId, setSelectedCharacterId] = useState('');
|
||||
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
|
||||
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
|
||||
const [maxHp, setMaxHp] = useState('');
|
||||
const [hpFormula, setHpFormula] = useState('');
|
||||
const [manualInitiative, setManualInitiative] = useState('');
|
||||
const [asNpc, setAsNpc] = useState(false);
|
||||
const [editingParticipant, setEditingParticipant] = useState(null);
|
||||
@@ -940,11 +1041,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
if (selectedChar && selectedChar.defaultMaxHp) {
|
||||
setMaxHp(selectedChar.defaultMaxHp);
|
||||
} else {
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
setMaxHp('');
|
||||
}
|
||||
setAsNpc(false);
|
||||
} else if (participantType === 'monster') {
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
setMaxHp('');
|
||||
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
||||
}
|
||||
}, [selectedCharacterId, participantType, campaignCharacters]);
|
||||
@@ -977,6 +1078,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
} else {
|
||||
modifier = parseInt(monsterInitMod, 10) || 0;
|
||||
participantAsNpc = asNpc;
|
||||
// maxHp wins if both set; else roll formula
|
||||
if (!maxHp && hpFormula.trim()) {
|
||||
const rolled = shared.rollHpFormula(hpFormula);
|
||||
if (rolled) currentMaxHp = rolled;
|
||||
}
|
||||
}
|
||||
|
||||
const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier);
|
||||
@@ -988,6 +1094,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
initiative: computedInitiative,
|
||||
maxHp: currentMaxHp,
|
||||
currentHp: currentMaxHp,
|
||||
hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null,
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
status: 'conscious',
|
||||
@@ -1009,7 +1116,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
|
||||
|
||||
setParticipantName('');
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
setMaxHp('');
|
||||
setHpFormula('');
|
||||
setSelectedCharacterId('');
|
||||
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
||||
setAsNpc(false);
|
||||
@@ -1153,6 +1261,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkDead = async (participantId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await markDead(encounter, participantId, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
showToast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCritDamage = async (participantId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
@@ -1261,7 +1378,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
e.preventDefault();
|
||||
handleAddParticipant();
|
||||
}}
|
||||
className="grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end"
|
||||
className="grid grid-cols-1 md:grid-cols-12 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end"
|
||||
>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-stone-300">Type</label>
|
||||
@@ -1281,7 +1398,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
|
||||
{participantType === 'monster' ? (
|
||||
<>
|
||||
<div className="md:col-span-4">
|
||||
<div className="md:col-span-10">
|
||||
<label htmlFor="monsterName" className="block text-sm font-medium text-stone-300">
|
||||
Monster Name
|
||||
</label>
|
||||
@@ -1294,7 +1411,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
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-3">
|
||||
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-stone-300">
|
||||
Init Mod
|
||||
</label>
|
||||
@@ -1306,7 +1423,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
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-3">
|
||||
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
|
||||
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
|
||||
</label>
|
||||
@@ -1319,7 +1436,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
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-3">
|
||||
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
|
||||
Max HP
|
||||
</label>
|
||||
@@ -1331,7 +1448,20 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
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 flex items-center pt-5">
|
||||
<div className="md:col-span-3">
|
||||
<label htmlFor="monsterHpFormula" className="block text-sm font-medium text-stone-300">
|
||||
HP Formula (e.g. 2d6+9)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="monsterHpFormula"
|
||||
value={hpFormula}
|
||||
onChange={(e) => setHpFormula(e.target.value)}
|
||||
placeholder="2d6+9"
|
||||
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-12 flex items-center pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="asNpc"
|
||||
@@ -1386,7 +1516,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="md:col-span-6 flex justify-end mt-2">
|
||||
<div className="md:col-span-12 flex justify-end mt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={encounter.isStarted && !encounter.isPaused}
|
||||
@@ -1414,14 +1544,18 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
||||
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
|
||||
const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character');
|
||||
const hasDeathSaves = p.type === 'character' || p.type === 'npc';
|
||||
const isGeneric = (encounter.ruleset || '5e') === 'generic';
|
||||
const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc');
|
||||
|
||||
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
|
||||
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
|
||||
|
||||
const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious');
|
||||
const isZeroHp = p.currentHp === 0;
|
||||
const participantStatus = p.status || (isGeneric
|
||||
? (p.currentHp <= 0 ? 'down' : 'conscious')
|
||||
: (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious'));
|
||||
const isZeroHp = p.currentHp <= 0;
|
||||
const isDead = participantStatus === 'dead';
|
||||
const isDown = participantStatus === 'down';
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -1452,6 +1586,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
)}
|
||||
{hasDeathSaves && participantStatus === 'dying' && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Dying)</span>}
|
||||
{hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && <span className="ml-2 text-xs text-emerald-300 font-semibold">(Unconscious)</span>}
|
||||
{isDown && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Down)</span>}
|
||||
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
|
||||
</p>
|
||||
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
|
||||
@@ -1477,11 +1612,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
</div>
|
||||
|
||||
{participantStatus === 'dead' && (
|
||||
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive to 0 HP, stable and unconscious">
|
||||
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive (clear dead status)">
|
||||
<HeartPulse size={14} /> Revive
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isGeneric && !isDead && (
|
||||
<button onClick={() => handleMarkDead(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-red-900 hover:bg-red-800 text-white" title="Mark dead">
|
||||
<HeartCrack size={14} /> Mark Dead
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasDeathSaves && participantStatus === 'stable' && (
|
||||
<div className="mt-2 text-xs text-emerald-300/80 italic">Stable — regains 1 HP after 1d4 hours</div>
|
||||
)}
|
||||
@@ -1944,18 +2085,20 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
// ENCOUNTER MANAGER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
|
||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) {
|
||||
const { showToast } = useUIFeedback();
|
||||
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
||||
campaignId ? getPath.encounters(campaignId) : null
|
||||
);
|
||||
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
|
||||
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
|
||||
|
||||
const [encounters, setEncounters] = useState([]);
|
||||
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
const [draggedEncounterId, setDraggedEncounterId] = useState(null);
|
||||
|
||||
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
||||
|
||||
@@ -1963,6 +2106,45 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
if (encountersData) setEncounters(encountersData);
|
||||
}, [encountersData]);
|
||||
|
||||
// Sort by order field (fallback createdAt). Drag reorder writes order.
|
||||
const sortedEncounters = [...(encounters || [])].sort((a, b) => {
|
||||
const ao = a.order ?? null, bo = b.order ?? null;
|
||||
if (ao !== null && bo !== null) return ao - bo;
|
||||
if (ao !== null) return -1;
|
||||
if (bo !== null) return 1;
|
||||
return (a.createdAt || '').localeCompare(b.createdAt || '');
|
||||
});
|
||||
|
||||
const handleEncounterDragStart = (e, id) => {
|
||||
setDraggedEncounterId(id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
const handleEncounterDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
const handleEncounterDrop = async (e, targetId) => {
|
||||
e.preventDefault();
|
||||
if (!db || !draggedEncounterId || draggedEncounterId === targetId) {
|
||||
setDraggedEncounterId(null);
|
||||
return;
|
||||
}
|
||||
const reordered = [...sortedEncounters];
|
||||
const fromIdx = reordered.findIndex(x => x.id === draggedEncounterId);
|
||||
const toIdx = reordered.findIndex(x => x.id === targetId);
|
||||
if (fromIdx === -1 || toIdx === -1) { setDraggedEncounterId(null); return; }
|
||||
const [moved] = reordered.splice(fromIdx, 1);
|
||||
reordered.splice(toIdx, 0, moved);
|
||||
// assign order = index, batch update
|
||||
const ops = reordered.map((enc, i) => ({
|
||||
type: 'update',
|
||||
path: `${getPath.encounters(campaignId)}/${enc.id}`,
|
||||
data: { order: i },
|
||||
}));
|
||||
try { await storage.batchWrite(ops); } catch (err) {}
|
||||
setDraggedEncounterId(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
selectedEncounterIdRef.current = selectedEncounterId;
|
||||
}, [selectedEncounterId]);
|
||||
@@ -1992,7 +2174,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
}
|
||||
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
|
||||
|
||||
const handleCreateEncounter = async (name) => {
|
||||
const handleCreateEncounter = async (name, ruleset = '5e') => {
|
||||
if (!db || !name.trim() || !campaignId) return;
|
||||
|
||||
const newEncounterId = generateId();
|
||||
@@ -2005,7 +2187,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
ruleset: ruleset || '5e',
|
||||
order: (encounters || []).reduce((max, e) => Math.max(max, e.order ?? -1), -1) + 1,
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
@@ -2079,6 +2263,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
|
||||
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
|
||||
|
||||
useEffect(() => {
|
||||
if (encounterStartedRef) {
|
||||
encounterStartedRef.current = !!(selectedEncounter && selectedEncounter.isStarted && !selectedEncounter.isPaused);
|
||||
}
|
||||
if (encounterActiveRef) {
|
||||
encounterActiveRef.current = !!(selectedEncounter && selectedEncounter.isStarted);
|
||||
}
|
||||
}, [selectedEncounter, encounterStartedRef, encounterActiveRef]);
|
||||
|
||||
if (isLoadingEncounters && campaignId) {
|
||||
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
|
||||
}
|
||||
@@ -2103,7 +2296,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{encounters?.map(encounter => {
|
||||
{sortedEncounters.map(encounter => {
|
||||
const isLive = activeDisplayInfo &&
|
||||
activeDisplayInfo.activeCampaignId === campaignId &&
|
||||
activeDisplayInfo.activeEncounterId === encounter.id;
|
||||
@@ -2111,14 +2304,24 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
return (
|
||||
<div
|
||||
key={encounter.id}
|
||||
className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleEncounterDragStart(e, encounter.id)}
|
||||
onDragOver={handleEncounterDragOver}
|
||||
onDrop={(e) => handleEncounterDrop(e, encounter.id)}
|
||||
onDragEnd={() => setDraggedEncounterId(null)}
|
||||
className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
|
||||
<h4 className="font-medium text-white">{encounter.name}</h4>
|
||||
<h4 className="font-medium text-white">{encounter.name} <span className={`ml-1 px-1.5 py-0.5 rounded text-xs font-bold tracking-wide ${encounter.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}>{encounter.ruleset === 'generic' ? 'GEN' : '5e'}</span></h4>
|
||||
<p className="text-xs text-stone-300">
|
||||
Participants: {encounter.participants?.length || 0}
|
||||
{encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}
|
||||
</p>
|
||||
{(encounter.startedAt || encounter.endedAt) && (
|
||||
<p className="text-xs text-green-400/80 mt-0.5">
|
||||
{encounter.startedAt && `⚔ Started: ${new Date(encounter.startedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`}{encounter.startedAt && encounter.endedAt && ' · '}{encounter.endedAt && `Ended: ${new Date(encounter.endedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`}
|
||||
</p>
|
||||
)}
|
||||
{isLive && (
|
||||
<span className="text-xs text-green-400 font-semibold block mt-1">
|
||||
LIVE ON PLAYER DISPLAY
|
||||
@@ -2126,6 +2329,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ChevronsUpDown
|
||||
size={18}
|
||||
className="text-stone-400 flex-shrink-0 cursor-grab"
|
||||
title="Drag to reorder"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleTogglePlayerDisplay(encounter.id)}
|
||||
className={`p-1 rounded transition-colors ${isLive ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-amber-400 hover:text-amber-300 bg-stone-700 hover:bg-stone-600'}`}
|
||||
@@ -2153,8 +2361,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
{showCreateModal && (
|
||||
<Modal onClose={() => setShowCreateModal(false)} title="Create New Encounter">
|
||||
<CreateEncounterForm
|
||||
key={campaignId}
|
||||
onCreate={handleCreateEncounter}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
defaultRuleset={campaignDoc?.ruleset || '5e'}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
@@ -2211,7 +2421,21 @@ function AdminView({ userId }) {
|
||||
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
|
||||
|
||||
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
||||
const [draggedCampaignId, setDraggedCampaignId] = useState(null);
|
||||
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
|
||||
const encounterStartedRef = useRef(false);
|
||||
const encounterActiveRef = useRef(false);
|
||||
const manualSelectRef = useRef(false);
|
||||
const prevDisplayCampaignRef = useRef(null);
|
||||
|
||||
// External display change (replay/other DM) = clear manual override, allow follow.
|
||||
useEffect(() => {
|
||||
const cur = initialActiveInfo?.activeCampaignId || null;
|
||||
if (cur !== prevDisplayCampaignRef.current) {
|
||||
manualSelectRef.current = false;
|
||||
prevDisplayCampaignRef.current = cur;
|
||||
}
|
||||
}, [initialActiveInfo]);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
@@ -2244,8 +2468,12 @@ function AdminView({ userId }) {
|
||||
})
|
||||
);
|
||||
|
||||
// newest first by createdAt (fallback to name for stable order).
|
||||
// sort by order field (fallback createdAt)
|
||||
detailedCampaigns.sort((a, b) => {
|
||||
const ao = a.order ?? null, bo = b.order ?? null;
|
||||
if (ao !== null && bo !== null) return ao - bo;
|
||||
if (ao !== null) return -1;
|
||||
if (bo !== null) return 1;
|
||||
const at = a.createdAt || 0;
|
||||
const bt = b.createdAt || 0;
|
||||
if (at === bt) return (a.name || '').localeCompare(b.name || '');
|
||||
@@ -2258,6 +2486,10 @@ function AdminView({ userId }) {
|
||||
fetchDetails();
|
||||
} else if (campaignsData) {
|
||||
const sorted = [...campaignsData].sort((a, b) => {
|
||||
const ao = a.order ?? null, bo = b.order ?? null;
|
||||
if (ao !== null && bo !== null) return ao - bo;
|
||||
if (ao !== null) return -1;
|
||||
if (bo !== null) return 1;
|
||||
const at = a.createdAt || 0;
|
||||
const bt = b.createdAt || 0;
|
||||
if (at === bt) return (a.name || '').localeCompare(b.name || '');
|
||||
@@ -2271,10 +2503,15 @@ function AdminView({ userId }) {
|
||||
}, [campaignsData]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip follow only if user manually selected AND display hasn't changed.
|
||||
// (external display change clears manualSelectRef via prevDisplay effect)
|
||||
if (manualSelectRef.current && selectedCampaignId !== initialActiveInfo?.activeCampaignId) return;
|
||||
if (
|
||||
initialActiveInfo &&
|
||||
initialActiveInfo.activeCampaignId &&
|
||||
campaignsWithDetails.length > 0
|
||||
campaignsWithDetails.length > 0 &&
|
||||
!encounterStartedRef.current &&
|
||||
!encounterActiveRef.current
|
||||
) {
|
||||
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
|
||||
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
|
||||
@@ -2283,7 +2520,7 @@ function AdminView({ userId }) {
|
||||
}
|
||||
}, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]);
|
||||
|
||||
const handleCreateCampaign = async (name, backgroundUrl) => {
|
||||
const handleCreateCampaign = async (name, backgroundUrl, ruleset = '5e') => {
|
||||
if (!db || !name.trim()) return;
|
||||
|
||||
const newCampaignId = generateId();
|
||||
@@ -2295,6 +2532,8 @@ function AdminView({ userId }) {
|
||||
ownerId: userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
players: [],
|
||||
ruleset: ruleset || '5e',
|
||||
order: (campaignsWithDetails || []).reduce((max, c) => Math.max(max, c.order ?? -1), -1) + 1,
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
@@ -2441,12 +2680,36 @@ function AdminView({ userId }) {
|
||||
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` }
|
||||
: {};
|
||||
|
||||
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl'}`;
|
||||
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-grab transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl'} ${draggedCampaignId === campaign.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={campaign.id}
|
||||
onClick={() => setSelectedCampaignId(campaign.id)}
|
||||
draggable
|
||||
onDragStart={(e) => { setDraggedCampaignId(campaign.id); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!db || !draggedCampaignId || draggedCampaignId === campaign.id) { setDraggedCampaignId(null); return; }
|
||||
const reordered = [...campaignsWithDetails];
|
||||
const fromIdx = reordered.findIndex(c => c.id === draggedCampaignId);
|
||||
const toIdx = reordered.findIndex(c => c.id === campaign.id);
|
||||
if (fromIdx === -1 || toIdx === -1) { setDraggedCampaignId(null); return; }
|
||||
const [moved] = reordered.splice(fromIdx, 1);
|
||||
reordered.splice(toIdx, 0, moved);
|
||||
const ops = reordered.map((c, i) => ({ type: 'update', path: getPath.campaign(c.id), data: { order: i } }));
|
||||
try { await storage.batchWrite(ops); } catch (err) {}
|
||||
setDraggedCampaignId(null);
|
||||
}}
|
||||
onDragEnd={() => setDraggedCampaignId(null)}
|
||||
onClick={() => {
|
||||
if (encounterStartedRef.current) {
|
||||
showToast('End or pause active encounter before switching campaigns.');
|
||||
return;
|
||||
}
|
||||
manualSelectRef.current = true;
|
||||
setSelectedCampaignId(campaign.id);
|
||||
}}
|
||||
className={cardClasses}
|
||||
style={cardStyle}
|
||||
>
|
||||
@@ -2454,7 +2717,10 @@ function AdminView({ userId }) {
|
||||
className={`relative z-10 flex flex-col justify-between h-full ${campaign.playerDisplayBackgroundUrl ? 'bg-black bg-opacity-60 p-3' : 'p-4'}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ChevronsUpDown size={14} className="text-white/60 flex-shrink-0" title="Drag to reorder" />
|
||||
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
|
||||
</div>
|
||||
<div className="text-xs text-stone-100 mt-1 space-x-3">
|
||||
<span className="inline-flex items-center">
|
||||
<Users size={12} className="mr-1" /> {campaign.characters?.length || 0} Characters
|
||||
@@ -2478,11 +2744,29 @@ function AdminView({ userId }) {
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" /> Delete
|
||||
</button>
|
||||
<span
|
||||
className={`absolute bottom-2 right-2 z-20 px-2 py-0.5 rounded text-xs font-bold tracking-wide ${campaign.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}
|
||||
title={campaign.ruleset === 'generic' ? 'Generic ruleset' : 'D&D 5e ruleset'}
|
||||
>
|
||||
{campaign.ruleset === 'generic' ? 'GEN' : '5e'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isDevToolsEnabled() && campaignsWithDetails.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowDeleteAllConfirm(true)}
|
||||
className="px-3 py-1.5 text-xs rounded bg-red-900 hover:bg-red-800 text-red-100 border border-red-700"
|
||||
title="DEV ONLY: Delete every campaign, encounter, and log. Irreversible."
|
||||
>
|
||||
<Trash2 size={12} className="inline mr-1" />DEV: Delete All Campaigns
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -2514,6 +2798,8 @@ function AdminView({ userId }) {
|
||||
: null
|
||||
}
|
||||
campaignCharacters={selectedCampaign.characters || []}
|
||||
encounterStartedRef={encounterStartedRef}
|
||||
encounterActiveRef={encounterActiveRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2527,18 +2813,6 @@ function AdminView({ userId }) {
|
||||
message={`Are you sure you want to delete the campaign "${itemToDelete?.name}" and all its encounters? This action cannot be undone.`}
|
||||
/>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && campaignsWithDetails.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowDeleteAllConfirm(true)}
|
||||
className="px-3 py-1.5 text-xs rounded bg-red-900 hover:bg-red-800 text-red-100 border border-red-700"
|
||||
title="DEV ONLY: Delete every campaign, encounter, and log. Irreversible."
|
||||
>
|
||||
<Trash2 size={12} className="inline mr-1" />DEV: Delete All Campaigns
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={showDeleteAllConfirm}
|
||||
onClose={() => setShowDeleteAllConfirm(false)}
|
||||
@@ -2847,8 +3121,9 @@ function DisplayView() {
|
||||
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
{participantsToRender.map(p => {
|
||||
const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious');
|
||||
const isZeroHp = p.currentHp === 0;
|
||||
const isGeneric = (activeEncounterData.ruleset || '5e') === 'generic';
|
||||
const status = p.status || (isGeneric ? (p.currentHp <= 0 ? 'down' : 'conscious') : (p.currentHp === 0 ? 'dying' : 'conscious'));
|
||||
const isZeroHp = p.currentHp <= 0;
|
||||
let participantBgColor = p.type === 'monster' || p.type === 'npc'
|
||||
? 'bg-[#8e351c]'
|
||||
: 'bg-indigo-950';
|
||||
@@ -2882,6 +3157,7 @@ function DisplayView() {
|
||||
)}
|
||||
{status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>}
|
||||
{status === 'stable' && (p.conditions || []).includes('unconscious') && <span className="text-emerald-300 text-lg ml-2">(Unconscious)</span>}
|
||||
{status === 'down' && <span className="text-red-300 text-lg ml-2 animate-pulse">(Down)</span>}
|
||||
{status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>}
|
||||
</h3>
|
||||
<span
|
||||
@@ -3302,8 +3578,12 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') {
|
||||
const isDisplay = queryParams.get('playerView') === 'true' || window.location.pathname === '/display';
|
||||
if (isDisplay) {
|
||||
setIsPlayerViewOnlyMode(true);
|
||||
// swap manifest so Android home-screen installs launch /display
|
||||
const link = document.querySelector('link[rel="manifest"]');
|
||||
if (link) link.href = `${process.env.PUBLIC_URL || ''}/display-manifest.json`;
|
||||
}
|
||||
if (window.location.pathname === '/logs') {
|
||||
setIsLogsMode(true);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1.
|
||||
// Safe default: any value other than exactly '1' = off.
|
||||
// Dynamic key access so react-scripts DefinePlugin does NOT inline the value
|
||||
// at build time — allows tests + runtime env changes to take effect.
|
||||
const KEY = 'REACT_APP_' + 'DEV_TOOLS';
|
||||
export const isDevToolsEnabled = () => process.env[KEY] === '1';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isDevToolsEnabled } from '../config/devTools';
|
||||
|
||||
describe('Bulk-delete-all dev-tools gate logic', () => {
|
||||
const orig = process.env.REACT_APP_DEV_TOOLS;
|
||||
|
||||
afterEach(() => {
|
||||
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
|
||||
else process.env.REACT_APP_DEV_TOOLS = orig;
|
||||
});
|
||||
|
||||
test('false when unset (safe default)', () => {
|
||||
delete process.env.REACT_APP_DEV_TOOLS;
|
||||
expect(isDevToolsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('false when 0', () => {
|
||||
process.env.REACT_APP_DEV_TOOLS = '0';
|
||||
expect(isDevToolsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('false when arbitrary string', () => {
|
||||
process.env.REACT_APP_DEV_TOOLS = 'true';
|
||||
expect(isDevToolsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('true only when exactly 1', () => {
|
||||
process.env.REACT_APP_DEV_TOOLS = '1';
|
||||
expect(isDevToolsEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
|
||||
import App from '../App';
|
||||
|
||||
describe('Bulk-delete-all button render gating', () => {
|
||||
const orig = process.env.REACT_APP_DEV_TOOLS;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
|
||||
else process.env.REACT_APP_DEV_TOOLS = orig;
|
||||
});
|
||||
|
||||
async function renderAndCreateCampaign() {
|
||||
window.history.replaceState({}, '', '/');
|
||||
global.alert = jest.fn();
|
||||
global.window.open = jest.fn();
|
||||
render(<App />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
});
|
||||
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Gate Render Test' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
});
|
||||
}
|
||||
|
||||
test('prod safety: button absent when DEV_TOOLS unset', async () => {
|
||||
delete process.env.REACT_APP_DEV_TOOLS;
|
||||
await renderAndCreateCampaign();
|
||||
expect(screen.queryByText(/Delete All Campaigns/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
|
||||
import App from '../App';
|
||||
|
||||
describe('Bulk-delete-all button dev feature', () => {
|
||||
const orig = process.env.REACT_APP_DEV_TOOLS;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
|
||||
else process.env.REACT_APP_DEV_TOOLS = orig;
|
||||
});
|
||||
|
||||
async function renderAndCreateCampaign() {
|
||||
window.history.replaceState({}, '', '/');
|
||||
global.alert = jest.fn();
|
||||
global.window.open = jest.fn();
|
||||
render(<App />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
});
|
||||
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Gate Render Test' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
});
|
||||
}
|
||||
|
||||
test('dev: button present when DEV_TOOLS=1 and campaigns exist', async () => {
|
||||
process.env.REACT_APP_DEV_TOOLS = '1';
|
||||
await renderAndCreateCampaign();
|
||||
await waitFor(() => screen.getByText(/Delete All Campaigns/i), { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user