Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907484fc7f | ||
|
|
9dbbb88730 | ||
|
|
1078062b53 | ||
|
|
94126567e7 | ||
|
|
f3ed971592 | ||
|
|
95db6fdfdc | ||
|
|
a5b6d61e83 | ||
|
|
76f5dc09ab | ||
|
|
c2bb5cca29 | ||
|
|
7955bed4a9 | ||
|
|
66f3cc1380 | ||
|
|
19de9fcf75 | ||
|
|
c41a8aa994 | ||
|
|
02344eb3f5 | ||
|
|
e191aa5ad2 | ||
|
|
c544bc305a | ||
|
|
835a4663f7 | ||
|
|
9159755846 | ||
|
|
dbe018825b | ||
|
|
3b75ec9b3d |
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
.git
|
||||||
|
tmp
|
||||||
|
data
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
# Dockerfile
|
# Dockerfile
|
||||||
|
|
||||||
# Stage 1: Build the React application
|
# Stage 1: Build the React application
|
||||||
FROM node:18-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
LABEL stage="build-local-testing"
|
LABEL stage="build-local-testing"
|
||||||
|
|
||||||
@@ -48,4 +48,4 @@ COPY nginx-docker.conf /etc/nginx/conf.d/default.conf
|
|||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Start Nginx when the container launches
|
# Start Nginx when the container launches
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,17 +10,8 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
|||||||
not sure good way to do this
|
not sure good way to do this
|
||||||
|
|
||||||
|
|
||||||
|
### npm install warnings cleanup pass
|
||||||
### FEAT: player display fade transitions for inactive state
|
lots of updates
|
||||||
- Inactive is DM-triggered via Mark Inactive.
|
|
||||||
- Player display should fade inactive participant out, then remove from display list.
|
|
||||||
- Reactivating should fade participant in.
|
|
||||||
- DM display keeps inactive participant visible.
|
|
||||||
- Dead state does not imply inactive/disabled.
|
|
||||||
- Dying/stable must not fade out or leave layout holes.
|
|
||||||
- Dead can keep skull/Dead label; create some good visual cues, no removal - but a cool transition to death would be nice.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -59,43 +50,11 @@ not sure good way to do this
|
|||||||
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
|
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
|
||||||
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
|
- 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
|
### FEAT: clarify "Is NPC" in add-participant
|
||||||
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
|
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
|
||||||
display-only? skip in turn order?). Clarify intent before UX changes.
|
display-only? skip in turn order?). Clarify intent before UX changes.
|
||||||
|
|
||||||
### FEAT: first-class undo/redo UI buttons (B --- do now)
|
|
||||||
- Toolbar buttons ↶/↷ in AdminView header, not buried in /logs.
|
|
||||||
- Undo = revert latest non-undone log. Redo = re-apply latest undone.
|
|
||||||
- Uses current 2-write undo (non-tx). Race safety = log refactor later.
|
|
||||||
- Disabled when stack empty. Keyboard shortcuts (cmd+z / cmd+shift+z).
|
|
||||||
|
|
||||||
### FEAT-LOG: unified log refactor (was FEAT-2 + M6, batched)
|
|
||||||
- Single event schema, one source of truth:
|
|
||||||
`{ ts, type, payload, undo_payload, undone, encounterId,
|
|
||||||
snapshot:{ round, currentTurnParticipantId, turnOrderIds, activeIds } }`
|
|
||||||
- Common format consumed by: UI log view, download/copy export,
|
|
||||||
replay-combat, analyze-turns. One shape, four consumers.
|
|
||||||
- Transactional undo: server endpoint `POST /api/undo/:eventId`. Single
|
|
||||||
SQLite tx applies undo_payload + flips `undone`. Replaces fragile 2-write
|
|
||||||
(log update + encounter update as separate calls).
|
|
||||||
- Download/copy: exports event stream as JSON for offline analysis.
|
|
||||||
- replay-combat + analyze-turns rewritten to emit/consume same event shape.
|
|
||||||
- Migration: keep old log entries readable; new format for new writes.
|
|
||||||
|
|
||||||
|
|
||||||
### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later)
|
### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later)
|
||||||
|
|
||||||
@@ -107,133 +66,3 @@ not sure good way to do this
|
|||||||
## FEAT - clarify what end encounter does and what initiatives reset means
|
## FEAT - clarify what end encounter does and what initiatives reset means
|
||||||
|
|
||||||
## Done (history)
|
## Done (history)
|
||||||
|
|
||||||
### FEAT: first-class undo/redo UI buttons (DONE)
|
|
||||||
- ↶/↷ pills in InitiativeControls, always visible when encounter open.
|
|
||||||
- Undo = latest non-undone log (per encounter). Redo = latest undone.
|
|
||||||
- encounterPath added to all 14 log contexts (filter key).
|
|
||||||
- redo:patch (forward) added to undoData. Real redo replays forward state.
|
|
||||||
- Disabled when stack empty. Tooltip shows target action.
|
|
||||||
- Uses current 2-write undo (non-tx). Race safety = FEAT-LOG refactor.
|
|
||||||
|
|
||||||
### Architecture: 1-list turn order model (slot, never sort)
|
|
||||||
- Single source: turnOrderIds === participants.map(id). No re-sort after
|
|
||||||
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
|
|
||||||
- Drag (reorder) = same-init tie-break only. Cross-init blocked.
|
|
||||||
- startEncounter sorts ALL participants by init once, then frozen.
|
|
||||||
- addParticipant/updateParticipant slot by init (slotIndexForInit), preserve
|
|
||||||
drag order. Display renders participants[] directly (no sort).
|
|
||||||
- Static guard test errs if `.sort(` reintroduced outside allowlist.
|
|
||||||
- Design doc: docs/INITIATIVE_ORDERING.md.
|
|
||||||
|
|
||||||
### Single source of truth: combat logic
|
|
||||||
- All 15 App.js handlers delegate to @ttrpg/shared. ~498 lines inline dupes deleted.
|
|
||||||
- shared/turn.js = only place turn logic lives.
|
|
||||||
|
|
||||||
### Storage parity (firebase + server adapters)
|
|
||||||
- Neutral queryConstraints ({__type:'orderBy'|'limit'}) honored by both adapters.
|
|
||||||
- Shared contract test runs both identically. Memory adapter deleted; factory
|
|
||||||
throws on unknown mode.
|
|
||||||
- ws storage mode renamed to server (env var + adapter name).
|
|
||||||
|
|
||||||
### Logging contract
|
|
||||||
- Every mutating op logs message + undo payload. No-op = null log.
|
|
||||||
- Structural enforcement: per-op contract test (turn.logging.test.js) +
|
|
||||||
static source-scan guard (static.no-unlogged.test.js).
|
|
||||||
|
|
||||||
### Custom conditions per campaign (DONE)
|
|
||||||
- Freeform per-campaign conditions. Add applies to participant + persists to
|
|
||||||
campaign palette in one step. Badge render uses merged allConditions
|
|
||||||
(built-ins + custom). toggleCondition accepts any string. Dedup
|
|
||||||
case-insensitive. maxLength 40. Combat + replay tests prove arbitrary
|
|
||||||
string ids survive round-trip.
|
|
||||||
|
|
||||||
### Death saves: D&D 5e status model (DONE)
|
|
||||||
- `status` is source of truth: conscious, dying, stable, dead.
|
|
||||||
- Characters and NPCs use death saves; monsters skip death saves and become dead/inactive at 0 HP.
|
|
||||||
- Death-save actions: Success, Fail, Nat1, Nat20, Stabilize.
|
|
||||||
- Revive: dead → 0 HP, stable/unconscious, active.
|
|
||||||
- Dead characters/NPCs stay in encounter/initiative until DM removes or marks inactive.
|
|
||||||
- Player display hides inactive participants; DM display keeps them visible.
|
|
||||||
|
|
||||||
### FEAT-3: initiative first-class entry (DONE)
|
|
||||||
- Initiative field at add-char, add-monster, edit participant.
|
|
||||||
- Inline edit wired. Tie-break = drag order.
|
|
||||||
|
|
||||||
### UI feedback: toast + info modal (DONE)
|
|
||||||
- All 23 native alert() replaced. ToastStack (6s auto-dismiss + manual X)
|
|
||||||
for transient failures. InfoModal (persistent OK) for validations.
|
|
||||||
React context provider wraps all 3 App branches.
|
|
||||||
- Fixed: native alert vanished instantly on browser focus loss.
|
|
||||||
|
|
||||||
### Filter dup chars from add-participant dropdown (DONE)
|
|
||||||
- Character dropdown excludes chars already in encounter. Prevents
|
|
||||||
dup-add at source. No more dup alert path needed.
|
|
||||||
|
|
||||||
### Test timeouts (DONE)
|
|
||||||
- jest.setTimeout(10000) in setupTests.js (CRA blocks config-level timeout).
|
|
||||||
|
|
||||||
### Warning = failure in tests (DONE)
|
|
||||||
- console.error/warn throw in test env.
|
|
||||||
|
|
||||||
### BUG-1: addParticipant + pause/resume corrupts rotation
|
|
||||||
- RESOLVED as side effect of BUG-2 fix.
|
|
||||||
|
|
||||||
### BUG-2: addParticipant allows duplicate id
|
|
||||||
- FIXED (addParticipant throws on dup id).
|
|
||||||
|
|
||||||
### BUG-4: hide-player-HP breaks display view
|
|
||||||
- FIXED --- mock honors setDoc{merge}, all 5 activeDisplay sites use merge.
|
|
||||||
|
|
||||||
### BUG-5: mid-round addParticipant/revive corrupts rotation
|
|
||||||
- FIXED --- slot-array + DRY advance core nextActiveAfter.
|
|
||||||
|
|
||||||
### BUG-6: reorderParticipants doesn't update turnOrderIds
|
|
||||||
- FIXED structurally by 1-list model.
|
|
||||||
|
|
||||||
### BUG-7: reorderParticipants not logged
|
|
||||||
- FIXED --- returns log:{message, undo}. Handler calls logAction. deathSave,
|
|
||||||
addParticipants, updateParticipant logging gaps also closed.
|
|
||||||
|
|
||||||
### BUG-8: server adapter has no reconnect
|
|
||||||
- FIXED --- onclose reconnects + re-subscribes existing paths.
|
|
||||||
|
|
||||||
### BUG-10: deact+reactivate same round double-acts participant
|
|
||||||
- FIXED --- 1-list model keeps slot position on toggle. Reactivate does not
|
|
||||||
grant second turn. Test: turn.bug10.test.js.
|
|
||||||
|
|
||||||
### BUG-11: FE Combat.scenario test crashes
|
|
||||||
- FIXED --- moved to shared/turn.combat.test.js, pure functions, 100 rounds.
|
|
||||||
|
|
||||||
### BUG-12: campaign selection follows activeDisplay
|
|
||||||
- FIXED.
|
|
||||||
|
|
||||||
### BUG-13: reorderParticipants crossing current pointer = ambiguous
|
|
||||||
- FIXED --- block cross-pointer reorder during active encounter (both dirs).
|
|
||||||
Full fix needs actedThisRound tracking. Pragmatic block prevents skip/double.
|
|
||||||
Pre-combat: free reorder. Test: turn.bug13.test.js.
|
|
||||||
|
|
||||||
### BUG-14: addParticipant init-insertion breaks after drag-reorder
|
|
||||||
- FIXED --- slotIndexForInit scans current list (post-drag aware).
|
|
||||||
|
|
||||||
### BUG-15: DisplayView re-sorts (drag order not preserved)
|
|
||||||
- FIXED --- display renders participants[] directly.
|
|
||||||
|
|
||||||
### BUG-16: subscribeCollection hook drops queryConstraints
|
|
||||||
- FIXED --- neutral builders, both adapters honor orderBy/limit.
|
|
||||||
|
|
||||||
### BUG-17: dead SDK imports in App.js
|
|
||||||
- FIXED --- trimmed (auth + getFirestore + getStorage remain).
|
|
||||||
|
|
||||||
### BUG-18: stale comments reference deleted memory adapter
|
|
||||||
- FIXED.
|
|
||||||
|
|
||||||
### FEAT-1: Dead characters/NPCs stay in turn order
|
|
||||||
- DONE --- character/NPC death does not auto-remove. DM controls inactive/remove.
|
|
||||||
- Non-NPC monsters still become inactive automatically at death.
|
|
||||||
|
|
||||||
### combat.scenario 100 rounds not turns
|
|
||||||
- DONE --- loops by actual round-wrap count.
|
|
||||||
|
|
||||||
### feat: add all characters to participants list
|
|
||||||
- DONE --- addParticipants bulk add wired.
|
|
||||||
|
|||||||
+12
-11
@@ -1,34 +1,36 @@
|
|||||||
# docker/Dockerfile — single container: caddy (front) + node (back).
|
# syntax=docker/dockerfile:1
|
||||||
|
# docker/Dockerfile --- single container: caddy (front) + node (back).
|
||||||
# Build context = repo root.
|
# Build context = repo root.
|
||||||
# ---- build stage: frontend + install backend deps ----
|
# ---- build stage: frontend + install backend deps ----
|
||||||
FROM node:18-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY shared/package.json ./shared/
|
COPY shared/package.json ./shared/
|
||||||
COPY server/package.json ./server/
|
COPY server/package.json ./server/
|
||||||
RUN npm install --include-workspace-root
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm install --include-workspace-root --omit=dev --prefer-offline
|
||||||
|
|
||||||
|
# frontend code (changes often)
|
||||||
COPY shared/ ./shared/
|
COPY shared/ ./shared/
|
||||||
COPY server/ ./server/
|
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY public/ ./public/
|
COPY public/ ./public/
|
||||||
COPY tailwind.config.js postcss.config.js ./
|
COPY tailwind.config.js postcss.config.js ./
|
||||||
|
|
||||||
# better-sqlite3 native build (alpine musl)
|
|
||||||
RUN cd server && npm rebuild better-sqlite3
|
|
||||||
|
|
||||||
# build frontend (server storage, same-origin /api + /ws via caddy)
|
# build frontend (server storage, same-origin /api + /ws via caddy)
|
||||||
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
|
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
|
||||||
ENV REACT_APP_STORAGE=server
|
ENV REACT_APP_STORAGE=server
|
||||||
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
|
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
|
||||||
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
|
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
|
||||||
|
|
||||||
# prune backend dev deps for runtime
|
# server after backend - it's fast. so changing server only doesnt invalidate slow frontend.
|
||||||
RUN npm prune --omit=dev
|
COPY server/ ./server/
|
||||||
|
# better-sqlite3 native build (alpine musl). Cached if server unchanged.
|
||||||
|
RUN cd server && npm rebuild better-sqlite3
|
||||||
|
|
||||||
|
|
||||||
# ---- runtime stage: caddy + node ----
|
# ---- runtime stage: caddy + node ----
|
||||||
FROM node:18-alpine
|
FROM node:22-alpine
|
||||||
RUN apk add --no-cache caddy
|
RUN apk add --no-cache caddy
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -38,7 +40,6 @@ COPY --from=build /app/server/node_modules ./server/node_modules
|
|||||||
COPY --from=build /app/package*.json ./
|
COPY --from=build /app/package*.json ./
|
||||||
COPY --from=build /app/shared/package.json ./shared/
|
COPY --from=build /app/shared/package.json ./shared/
|
||||||
COPY --from=build /app/server/package.json ./server/
|
COPY --from=build /app/server/package.json ./server/
|
||||||
COPY shared/ ./shared/
|
|
||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
# built frontend served by caddy
|
# built frontend served by caddy
|
||||||
COPY --from=build /app/build /srv
|
COPY --from=build /app/build /srv
|
||||||
|
|||||||
@@ -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:
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/docker-restart.sh — rebuild + restart docker container.
|
||||||
|
# Usage: ./scripts/docker-restart.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "=== stopping existing container ==="
|
||||||
|
docker compose -f docker/docker-compose.yml down 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== rebuilding image ==="
|
||||||
|
docker compose -f docker/docker-compose.yml build
|
||||||
|
|
||||||
|
echo "=== starting container ==="
|
||||||
|
docker compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
|
echo "=== status ==="
|
||||||
|
docker compose -f docker/docker-compose.yml ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "app: http://localhost:${PORT:-8080}"
|
||||||
|
echo "logs: docker compose -f docker/docker-compose.yml logs -f"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// server/index.js — generic KV document store over HTTP + WebSocket.
|
// server/index.js — generic KV document store over HTTP + WebSocket.
|
||||||
// firebase mirror: doc-tree model. Thin REST, path-based WS push.
|
// firebase mirror: doc-tree model. Thin REST, path-based WS push.
|
||||||
// Adapter (src/storage/server.js) = passthrough, no shape translation.
|
// Adapter (src/storage/server.js) = passthrough, no shape translation.
|
||||||
|
// TEST: cache layer rebuild check.
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+115
-2
@@ -207,6 +207,7 @@ function expandUndo(entry, currentEnc) {
|
|||||||
case 'death_save':
|
case 'death_save':
|
||||||
case 'stabilize':
|
case 'stabilize':
|
||||||
case 'revive':
|
case 'revive':
|
||||||
|
case 'mark_dead':
|
||||||
case 'deactivate_dead_monster': {
|
case 'deactivate_dead_monster': {
|
||||||
// Full death-state restore: currentHp, status, death-save counters,
|
// Full death-state restore: currentHp, status, death-save counters,
|
||||||
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
|
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
|
||||||
@@ -574,6 +575,7 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
|
|||||||
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
|
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
|
||||||
const ctx = maybeCtx || optionsOrCtx;
|
const ctx = maybeCtx || optionsOrCtx;
|
||||||
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
||||||
|
const ruleset = encounter.ruleset || '5e';
|
||||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
if (!participant) throw new Error('Participant not found.');
|
if (!participant) throw new Error('Participant not found.');
|
||||||
if (isNaN(amount) || amount === 0) return encounter;
|
if (isNaN(amount) || amount === 0) return encounter;
|
||||||
@@ -596,6 +598,12 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
let logDelta = { amount, from: participant.currentHp };
|
let logDelta = { amount, from: participant.currentHp };
|
||||||
const status = oldValues.status;
|
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 (changeType === 'damage') {
|
||||||
if (participant.currentHp === 0) {
|
if (participant.currentHp === 0) {
|
||||||
if (status === 'dead') {
|
if (status === 'dead') {
|
||||||
@@ -683,7 +691,76 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
return commit(encounter, patch, log, ctx);
|
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) {
|
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);
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
if (!participant) throw new Error('Participant not found.');
|
if (!participant) throw new Error('Participant not found.');
|
||||||
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||||
@@ -764,6 +841,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function stabilizeParticipant(encounter, participantId, 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);
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
if (!participant) throw new Error('Participant not found.');
|
if (!participant) throw new Error('Participant not found.');
|
||||||
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||||
@@ -806,12 +886,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reviveParticipant(encounter, participantId, ctx) {
|
async function reviveParticipant(encounter, participantId, ctx) {
|
||||||
|
const ruleset = encounter.ruleset || '5e';
|
||||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
if (!participant) throw new Error('Participant not found.');
|
if (!participant) throw new Error('Participant not found.');
|
||||||
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||||
if (status !== 'dead') return encounter;
|
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 =>
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||||
);
|
);
|
||||||
@@ -845,6 +930,34 @@ async function reviveParticipant(encounter, participantId, ctx) {
|
|||||||
return commit(encounter, patch, log, 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) {
|
async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
||||||
const participants = [...(encounter.participants || [])];
|
const participants = [...(encounter.participants || [])];
|
||||||
const dragged = participants.find(p => p.id === draggedId);
|
const dragged = participants.find(p => p.id === draggedId);
|
||||||
@@ -919,7 +1032,7 @@ module.exports = {
|
|||||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||||
startEncounter, nextTurn, togglePause,
|
startEncounter, nextTurn, togglePause,
|
||||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
|
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
||||||
reorderParticipants, endEncounter,
|
reorderParticipants, endEncounter,
|
||||||
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
||||||
};
|
};
|
||||||
|
|||||||
+337
-42
@@ -5,7 +5,7 @@ import {
|
|||||||
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
||||||
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
||||||
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
|
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
|
||||||
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
|
StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText,
|
||||||
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
|
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
|
||||||
Undo2, Redo2, Crosshair
|
Undo2, Redo2, Crosshair
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -125,6 +125,7 @@ const {
|
|||||||
deathSave: combatDeathSave,
|
deathSave: combatDeathSave,
|
||||||
stabilizeParticipant,
|
stabilizeParticipant,
|
||||||
reviveParticipant,
|
reviveParticipant,
|
||||||
|
markDead,
|
||||||
toggleCondition: combatToggleCondition,
|
toggleCondition: combatToggleCondition,
|
||||||
reorderParticipants,
|
reorderParticipants,
|
||||||
} = shared;
|
} = shared;
|
||||||
@@ -448,11 +449,12 @@ function ErrorDisplay({ message, critical = false }) {
|
|||||||
function CreateCampaignForm({ onCreate, onCancel }) {
|
function CreateCampaignForm({ onCreate, onCancel }) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [backgroundUrl, setBackgroundUrl] = useState('');
|
const [backgroundUrl, setBackgroundUrl] = useState('');
|
||||||
|
const [ruleset, setRuleset] = useState('5e');
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (name.trim()) {
|
if (name.trim()) {
|
||||||
onCreate(name, backgroundUrl);
|
onCreate(name, backgroundUrl, ruleset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -484,6 +486,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"
|
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>
|
||||||
|
<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">
|
<div className="flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -503,13 +518,14 @@ function CreateCampaignForm({ onCreate, onCancel }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateEncounterForm({ onCreate, onCancel }) {
|
function CreateEncounterForm({ onCreate, onCancel, defaultRuleset = '5e' }) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [ruleset, setRuleset] = useState(defaultRuleset);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (name.trim()) {
|
if (name.trim()) {
|
||||||
onCreate(name);
|
onCreate(name, ruleset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -528,6 +544,19 @@ function CreateEncounterForm({ onCreate, onCancel }) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -653,7 +682,30 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
const [editingCharacter, setEditingCharacter] = useState(null);
|
const [editingCharacter, setEditingCharacter] = useState(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
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 () => {
|
const handleAddCharacter = async () => {
|
||||||
if (!db || !characterName.trim() || !campaignId) return;
|
if (!db || !characterName.trim() || !campaignId) return;
|
||||||
@@ -740,15 +792,14 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
<>
|
<>
|
||||||
<div className="p-4 bg-stone-900 rounded-lg shadow">
|
<div className="p-4 bg-stone-900 rounded-lg shadow">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<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
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="p-1 text-stone-400 hover:text-stone-200"
|
className="flex items-center text-xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
|
||||||
aria-label={isOpen ? "Collapse" : "Expand"}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -806,7 +857,17 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
|
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{campaignCharacters.map(character => (
|
{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 ? (
|
{editingCharacter && editingCharacter.id === character.id ? (
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -882,6 +943,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1153,6 +1215,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) => {
|
const handleCritDamage = async (participantId) => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
try {
|
try {
|
||||||
@@ -1414,14 +1485,18 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
||||||
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
|
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
|
||||||
const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character');
|
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]';
|
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
|
||||||
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
|
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
|
||||||
|
|
||||||
const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious');
|
const participantStatus = p.status || (isGeneric
|
||||||
const isZeroHp = p.currentHp === 0;
|
? (p.currentHp <= 0 ? 'down' : 'conscious')
|
||||||
|
: (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious'));
|
||||||
|
const isZeroHp = p.currentHp <= 0;
|
||||||
const isDead = participantStatus === 'dead';
|
const isDead = participantStatus === 'dead';
|
||||||
|
const isDown = participantStatus === 'down';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
@@ -1452,6 +1527,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 === '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>}
|
{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>}
|
{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>
|
</p>
|
||||||
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
|
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
|
||||||
@@ -1477,11 +1553,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{participantStatus === 'dead' && (
|
{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
|
<HeartPulse size={14} /> Revive
|
||||||
</button>
|
</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' && (
|
{hasDeathSaves && participantStatus === 'stable' && (
|
||||||
<div className="mt-2 text-xs text-emerald-300/80 italic">Stable — regains 1 HP after 1d4 hours</div>
|
<div className="mt-2 text-xs text-emerald-300/80 italic">Stable — regains 1 HP after 1d4 hours</div>
|
||||||
)}
|
)}
|
||||||
@@ -1944,18 +2026,20 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
// ENCOUNTER MANAGER COMPONENT
|
// ENCOUNTER MANAGER COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
|
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) {
|
||||||
const { showToast } = useUIFeedback();
|
const { showToast } = useUIFeedback();
|
||||||
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
||||||
campaignId ? getPath.encounters(campaignId) : null
|
campaignId ? getPath.encounters(campaignId) : null
|
||||||
);
|
);
|
||||||
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
|
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
|
||||||
|
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
|
||||||
|
|
||||||
const [encounters, setEncounters] = useState([]);
|
const [encounters, setEncounters] = useState([]);
|
||||||
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
|
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
|
const [draggedEncounterId, setDraggedEncounterId] = useState(null);
|
||||||
|
|
||||||
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
||||||
|
|
||||||
@@ -1963,6 +2047,45 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
if (encountersData) setEncounters(encountersData);
|
if (encountersData) setEncounters(encountersData);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
selectedEncounterIdRef.current = selectedEncounterId;
|
selectedEncounterIdRef.current = selectedEncounterId;
|
||||||
}, [selectedEncounterId]);
|
}, [selectedEncounterId]);
|
||||||
@@ -1992,7 +2115,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
}
|
}
|
||||||
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
|
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
|
||||||
|
|
||||||
const handleCreateEncounter = async (name) => {
|
const handleCreateEncounter = async (name, ruleset = '5e') => {
|
||||||
if (!db || !name.trim() || !campaignId) return;
|
if (!db || !name.trim() || !campaignId) return;
|
||||||
|
|
||||||
const newEncounterId = generateId();
|
const newEncounterId = generateId();
|
||||||
@@ -2005,7 +2128,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
round: 0,
|
round: 0,
|
||||||
currentTurnParticipantId: null,
|
currentTurnParticipantId: null,
|
||||||
isStarted: false,
|
isStarted: false,
|
||||||
isPaused: false
|
isPaused: false,
|
||||||
|
ruleset: ruleset || '5e',
|
||||||
|
order: (encounters || []).reduce((max, e) => Math.max(max, e.order ?? -1), -1) + 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
@@ -2079,6 +2204,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
|
|
||||||
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
|
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) {
|
if (isLoadingEncounters && campaignId) {
|
||||||
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
|
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
|
||||||
}
|
}
|
||||||
@@ -2103,7 +2237,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{encounters?.map(encounter => {
|
{sortedEncounters.map(encounter => {
|
||||||
const isLive = activeDisplayInfo &&
|
const isLive = activeDisplayInfo &&
|
||||||
activeDisplayInfo.activeCampaignId === campaignId &&
|
activeDisplayInfo.activeCampaignId === campaignId &&
|
||||||
activeDisplayInfo.activeEncounterId === encounter.id;
|
activeDisplayInfo.activeEncounterId === encounter.id;
|
||||||
@@ -2111,13 +2245,18 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={encounter.id}
|
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 className="flex justify-between items-center">
|
||||||
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
|
<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">
|
<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>
|
</p>
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<span className="text-xs text-green-400 font-semibold block mt-1">
|
<span className="text-xs text-green-400 font-semibold block mt-1">
|
||||||
@@ -2126,6 +2265,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<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
|
<button
|
||||||
onClick={() => handleTogglePlayerDisplay(encounter.id)}
|
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'}`}
|
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 +2297,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<Modal onClose={() => setShowCreateModal(false)} title="Create New Encounter">
|
<Modal onClose={() => setShowCreateModal(false)} title="Create New Encounter">
|
||||||
<CreateEncounterForm
|
<CreateEncounterForm
|
||||||
|
key={campaignId}
|
||||||
onCreate={handleCreateEncounter}
|
onCreate={handleCreateEncounter}
|
||||||
onCancel={() => setShowCreateModal(false)}
|
onCancel={() => setShowCreateModal(false)}
|
||||||
|
defaultRuleset={campaignDoc?.ruleset || '5e'}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
@@ -2211,7 +2357,21 @@ function AdminView({ userId }) {
|
|||||||
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
|
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
|
||||||
|
|
||||||
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
||||||
|
const [draggedCampaignId, setDraggedCampaignId] = useState(null);
|
||||||
const [selectedCampaignId, setSelectedCampaignId] = 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 [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
@@ -2244,8 +2404,12 @@ function AdminView({ userId }) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// newest first by createdAt (fallback to name for stable order).
|
// sort by order field (fallback createdAt)
|
||||||
detailedCampaigns.sort((a, b) => {
|
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 at = a.createdAt || 0;
|
||||||
const bt = b.createdAt || 0;
|
const bt = b.createdAt || 0;
|
||||||
if (at === bt) return (a.name || '').localeCompare(b.name || '');
|
if (at === bt) return (a.name || '').localeCompare(b.name || '');
|
||||||
@@ -2258,6 +2422,10 @@ function AdminView({ userId }) {
|
|||||||
fetchDetails();
|
fetchDetails();
|
||||||
} else if (campaignsData) {
|
} else if (campaignsData) {
|
||||||
const sorted = [...campaignsData].sort((a, b) => {
|
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 at = a.createdAt || 0;
|
||||||
const bt = b.createdAt || 0;
|
const bt = b.createdAt || 0;
|
||||||
if (at === bt) return (a.name || '').localeCompare(b.name || '');
|
if (at === bt) return (a.name || '').localeCompare(b.name || '');
|
||||||
@@ -2271,10 +2439,15 @@ function AdminView({ userId }) {
|
|||||||
}, [campaignsData]);
|
}, [campaignsData]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
if (
|
||||||
initialActiveInfo &&
|
initialActiveInfo &&
|
||||||
initialActiveInfo.activeCampaignId &&
|
initialActiveInfo.activeCampaignId &&
|
||||||
campaignsWithDetails.length > 0
|
campaignsWithDetails.length > 0 &&
|
||||||
|
!encounterStartedRef.current &&
|
||||||
|
!encounterActiveRef.current
|
||||||
) {
|
) {
|
||||||
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
|
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
|
||||||
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
|
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
|
||||||
@@ -2283,7 +2456,7 @@ function AdminView({ userId }) {
|
|||||||
}
|
}
|
||||||
}, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]);
|
}, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]);
|
||||||
|
|
||||||
const handleCreateCampaign = async (name, backgroundUrl) => {
|
const handleCreateCampaign = async (name, backgroundUrl, ruleset = '5e') => {
|
||||||
if (!db || !name.trim()) return;
|
if (!db || !name.trim()) return;
|
||||||
|
|
||||||
const newCampaignId = generateId();
|
const newCampaignId = generateId();
|
||||||
@@ -2295,6 +2468,8 @@ function AdminView({ userId }) {
|
|||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
players: [],
|
players: [],
|
||||||
|
ruleset: ruleset || '5e',
|
||||||
|
order: (campaignsWithDetails || []).reduce((max, c) => Math.max(max, c.order ?? -1), -1) + 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
@@ -2441,12 +2616,36 @@ function AdminView({ userId }) {
|
|||||||
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` }
|
? { 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={campaign.id}
|
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}
|
className={cardClasses}
|
||||||
style={cardStyle}
|
style={cardStyle}
|
||||||
>
|
>
|
||||||
@@ -2454,7 +2653,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'}`}
|
className={`relative z-10 flex flex-col justify-between h-full ${campaign.playerDisplayBackgroundUrl ? 'bg-black bg-opacity-60 p-3' : 'p-4'}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
|
<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">
|
<div className="text-xs text-stone-100 mt-1 space-x-3">
|
||||||
<span className="inline-flex items-center">
|
<span className="inline-flex items-center">
|
||||||
<Users size={12} className="mr-1" /> {campaign.characters?.length || 0} Characters
|
<Users size={12} className="mr-1" /> {campaign.characters?.length || 0} Characters
|
||||||
@@ -2478,6 +2680,12 @@ function AdminView({ userId }) {
|
|||||||
>
|
>
|
||||||
<Trash2 size={14} className="mr-1" /> Delete
|
<Trash2 size={14} className="mr-1" /> Delete
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2514,6 +2722,8 @@ function AdminView({ userId }) {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
campaignCharacters={selectedCampaign.characters || []}
|
campaignCharacters={selectedCampaign.characters || []}
|
||||||
|
encounterStartedRef={encounterStartedRef}
|
||||||
|
encounterActiveRef={encounterActiveRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2554,6 +2764,61 @@ function AdminView({ userId }) {
|
|||||||
// DISPLAY VIEW COMPONENT (Player View)
|
// DISPLAY VIEW COMPONENT (Player View)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// Player participant card: animate state transitions.
|
||||||
|
// mount/activate : animate in (opacity+scale+slide)
|
||||||
|
// deactivate : animate out then signal exit
|
||||||
|
// alive→dead : transition to death cue (dim+desaturate+skull pulse+red rim)
|
||||||
|
// conscious/dying/stable: full visible, no fade
|
||||||
|
function PlayerParticipantCard({ id, isActive, status, onExit, className, children, divRef }) {
|
||||||
|
// phase: 'hidden' (opacity-0, start state) -> 'visible' (opacity-100)
|
||||||
|
// disable: 'visible' -> 'exiting' (opacity-0, transition plays) -> onExit
|
||||||
|
const [phase, setPhase] = useState('hidden');
|
||||||
|
const isDead = status === 'dead';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
// ensure current 'visible' frame painted, THEN flip to exiting so
|
||||||
|
// browser sees opacity change and plays the fade-out.
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
setPhase('exiting');
|
||||||
|
});
|
||||||
|
const t = setTimeout(() => onExit(id), 1000);
|
||||||
|
return () => { cancelAnimationFrame(raf); clearTimeout(t); };
|
||||||
|
}
|
||||||
|
// enable/reactivate: paint hidden frame first, THEN flip to visible
|
||||||
|
// so browser sees opacity change and plays the transition.
|
||||||
|
setPhase('hidden');
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => setPhase('visible'));
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [isActive, id, onExit]);
|
||||||
|
|
||||||
|
// animate: opacity + scale + slide. Visible motion, not just fade.
|
||||||
|
const fadeClass = phase === 'visible'
|
||||||
|
? 'opacity-100 scale-100 translate-y-0'
|
||||||
|
: 'opacity-0 scale-75 translate-y-4';
|
||||||
|
|
||||||
|
// death transition: fires once when alive→dead. dim + desaturate + red rim.
|
||||||
|
const deathClass = isDead
|
||||||
|
? 'brightness-50 saturate-0 border-2 border-red-900/70 shadow-red-900/50 shadow-2xl'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={divRef}
|
||||||
|
className={`[transition:opacity_1s_ease-in-out,transform_1s_ease-in-out,filter_1s_ease-in-out,border-color_1s_ease-in-out,box-shadow_300ms_ease-in-out] ${fadeClass} ${deathClass} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{isDead && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-6xl opacity-80 animate-pulse">☠️</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DisplayView() {
|
function DisplayView() {
|
||||||
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(
|
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(
|
||||||
getPath.activeDisplay()
|
getPath.activeDisplay()
|
||||||
@@ -2566,9 +2831,37 @@ function DisplayView() {
|
|||||||
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
|
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [wakeLockEnabled, setWakeLockEnabled] = useState(false);
|
const [wakeLockEnabled, setWakeLockEnabled] = useState(false);
|
||||||
|
const [displayParticipants, setDisplayParticipants] = useState([]);
|
||||||
const wakeLockRef = useRef(null);
|
const wakeLockRef = useRef(null);
|
||||||
const currentParticipantRef = useRef(null);
|
const currentParticipantRef = useRef(null);
|
||||||
|
|
||||||
|
// Player display transition state. Active participants render normally.
|
||||||
|
// Active→inactive: keep prior card, mark __displayActive=false, animate out,
|
||||||
|
// then PlayerParticipantCard calls handleExit to remove from display list.
|
||||||
|
// Inactive→active: card reappears with __displayActive=true and fades in.
|
||||||
|
useEffect(() => {
|
||||||
|
const source = (activeEncounterData && activeEncounterData.participants) || [];
|
||||||
|
setDisplayParticipants(prev => {
|
||||||
|
const prevById = new Map(prev.map(p => [p.id, p]));
|
||||||
|
const out = [];
|
||||||
|
for (const p of source) {
|
||||||
|
const prevP = prevById.get(p.id);
|
||||||
|
if (p.isActive !== false) {
|
||||||
|
out.push({ ...p, __displayActive: true });
|
||||||
|
} else if (prevP && prevP.__displayActive !== false) {
|
||||||
|
out.push({ ...p, __displayActive: false });
|
||||||
|
} else if (prevP && prevP.__displayActive === false) {
|
||||||
|
out.push(prevP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}, [activeEncounterData]);
|
||||||
|
|
||||||
|
const handleExit = useCallback((id) => {
|
||||||
|
setDisplayParticipants(prev => prev.filter(p => p.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
@@ -2706,13 +2999,9 @@ function DisplayView() {
|
|||||||
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
|
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
|
||||||
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
|
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
|
||||||
|
|
||||||
let participantsToRender = [];
|
// 1-list model: displayParticipants IS the display order (participants[] order
|
||||||
if (participants) {
|
// plus temporary exiting cards). Do NOT re-sort by initiative.
|
||||||
// 1-list model: participants[] IS the display order (DM drag = source of
|
const participantsToRender = displayParticipants;
|
||||||
// truth). Do NOT re-sort by initiative — that diverges from AdminView /
|
|
||||||
// turnOrderIds after any cross-init drag (BUG-15).
|
|
||||||
participantsToRender = participants.filter(p => p.isActive !== false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayStyles = campaignBackgroundUrl
|
const displayStyles = campaignBackgroundUrl
|
||||||
? {
|
? {
|
||||||
@@ -2768,8 +3057,9 @@ function DisplayView() {
|
|||||||
|
|
||||||
<div className="space-y-4 max-w-3xl mx-auto">
|
<div className="space-y-4 max-w-3xl mx-auto">
|
||||||
{participantsToRender.map(p => {
|
{participantsToRender.map(p => {
|
||||||
const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious');
|
const isGeneric = (activeEncounterData.ruleset || '5e') === 'generic';
|
||||||
const isZeroHp = p.currentHp === 0;
|
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'
|
let participantBgColor = p.type === 'monster' || p.type === 'npc'
|
||||||
? 'bg-[#8e351c]'
|
? 'bg-[#8e351c]'
|
||||||
: 'bg-indigo-950';
|
: 'bg-indigo-950';
|
||||||
@@ -2783,10 +3073,14 @@ function DisplayView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<PlayerParticipantCard
|
||||||
key={p.id}
|
key={p.id}
|
||||||
ref={isCurrentTurn ? currentParticipantRef : null}
|
id={p.id}
|
||||||
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${status === 'dead' ? 'opacity-40 grayscale' : (!p.isActive ? 'opacity-40 grayscale' : '')}`}
|
isActive={p.isActive !== false}
|
||||||
|
status={status}
|
||||||
|
onExit={handleExit}
|
||||||
|
divRef={isCurrentTurn ? currentParticipantRef : null}
|
||||||
|
className={`relative p-4 md:p-6 rounded-lg shadow-lg ${participantBgColor}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<h3
|
<h3
|
||||||
@@ -2799,6 +3093,7 @@ function DisplayView() {
|
|||||||
)}
|
)}
|
||||||
{status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>}
|
{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 === '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>}
|
{status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<span
|
||||||
@@ -2850,7 +3145,7 @@ function DisplayView() {
|
|||||||
{!p.isActive && !isZeroHp && (
|
{!p.isActive && !isZeroHp && (
|
||||||
<p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p>
|
<p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PlayerParticipantCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -105,9 +105,9 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
|
|||||||
return unsub;
|
return unsub;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply Firestore-style query constraints (orderBy desc/asc, limit) to mock docs.
|
// Apply Firestore-style query constraints (orderBy desc/asc, limit, where)
|
||||||
// Mirrors real SDK semantics enough for contract tests. Only orderBy + limit
|
// to mock docs. Mirrors real SDK semantics for contract tests. Offset handled
|
||||||
// supported (App's LOG_QUERY uses exactly these).
|
// by adapter (firebase.js slices) — mock never sees it.
|
||||||
function applyConstraints(docs, constraints) {
|
function applyConstraints(docs, constraints) {
|
||||||
let out = [...docs];
|
let out = [...docs];
|
||||||
for (const c of constraints) {
|
for (const c of constraints) {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('DisplayView characterization', () => {
|
|||||||
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DisplayView hides inactive participants for all types', async () => {
|
test('DisplayView hides inactive participants for all types after fade-out', async () => {
|
||||||
seedActiveDisplay([
|
seedActiveDisplay([
|
||||||
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
|
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
|
||||||
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
|
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
|
||||||
@@ -103,7 +103,8 @@ describe('DisplayView characterization', () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
|
||||||
expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument();
|
// inactive held during exit animation, removed after transition
|
||||||
|
await waitFor(() => expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(), { timeout: 1500 });
|
||||||
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
|
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user