8 Commits
Author SHA1 Message Date
david raistrick f3ed971592 Merge remote-tracking branch 'upstream/main' into feat/generic-ruleset 2026-07-07 14:53:42 -04:00
david raistrick 95db6fdfdc Character section rollup: clickable title, count, persisted collapse
Match campaigns rollup pattern. Title button toggles collapse (chevron
left, like campaigns). Character count in header. Collapse state persisted
to localStorage key ttrpg.charactersCollapsed.
2026-07-07 14:51:45 -04:00
david raistrick a5b6d61e83 Generic ruleset UI + campaign/encounter tags + switch-during-combat fix
Campaign + encounter cards: ruleset tag (5e/GEN), create date visible.
CreateEncounterForm keyed by campaignId so default ruleset syncs on campaign switch.

Campaign switch during active combat:
- encounterStartedRef (unpaused) blocks switch + toast
- encounterActiveRef (started paused-or-not) gates display-follow effect
- manualSelectRef tracks user clicks; external display change clears it
  (BUG-12 follow still works for replay/other-DM)
- Prevents revert race when EncounterManager unmounts and refs go false

UI:
- campaign card: ruleset tag bottom-right, opposite delete
- encounter card: tag inline title, date left of participants count
- EncounterManager fetches campaignDoc for default ruleset inheritance

Tests green: app 100, shared 186, server 40.
2026-07-07 14:28:32 -04:00
robertandClaude Opus 4.8 76f5dc09ab Trigger Portainer stack webhooks after image push
Adds a main-only Redeploy stage that POSTs to two Portainer stack
webhooks (re-pull + redeploy) after a successful push. URLs are read
from Secret-text credentials so they stay out of the repo and logs.
Gated by the REDEPLOY_PORTAINER parameter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:57:47 -04:00
david raistrick c2bb5cca29 Generic (non-5e) ruleset mode: negative HP, down status, mark dead/revive
New campaign/encounter ruleset toggle: 5e (default, unchanged) vs generic.

Generic mode:
- No death saves (deathSave/stabilize throw)
- Negative HP allowed (no clamp at 0)
- <=0 HP = status 'down' (no pips, no unconscious auto-condition)
- Monster death = dead + inactive (same as 5e)
- markDead button: DM sets dead manually (both modes)
- revive: dead->conscious (generic), dead->stable (5e)

5e mode: zero behavior change.

shared/turn.js:
- applyHpChangeGeneric: negative HP, down status, no death-save logic
- markDead: status dead, monster auto-inactive
- reviveParticipant: ruleset-aware (generic=conscious, 5e=stable)
- deathSave/stabilize: throw in generic
- expandUndo: mark_dead case added

UI (src/App.js):
- CreateCampaignForm + CreateEncounterForm: ruleset radio toggle
- handleCreateCampaign/handleCreateEncounter: store ruleset field
- EncounterManager: fetch campaignDoc for default ruleset inheritance
- DM participant: down/dead labels, Mark Dead button (generic), Revive both
- death-save pips/buttons gated 5e-only
- DisplayView: down label, generic status derivation

Tests: 15 generic cases (turn.generic.test.js). 186 shared total.
2026-07-07 13:36:16 -04:00
robertandClaude Opus 4.8 7955bed4a9 Add Jenkins CI and Portainer stack for image deployment
Jenkinsfile builds and pushes both the firebase (nginx/static) and
sqlite (caddy+node) images to thinkserver:5000, guarded to main only.
docker/portainer-stack.yml deploys the prebuilt sqlite image from the
registry behind the external TLS-terminating nginx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:43:46 -04:00
robert 66f3cc1380 Adding Jenkinsfile to simplify my deployment. 2026-07-07 12:13:16 -04:00
robert 19de9fcf75 Merge pull request 'Player display animations, death visual, upstream merge' (#5) from single-source into main
Reviewed-on: #5
2026-07-07 11:21:23 -04:00
6 changed files with 628 additions and 40 deletions
Vendored
+172
View File
@@ -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}"
}
}
}
-13
View File
@@ -50,19 +50,6 @@ lots of updates
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
### BUG: select campaign during active combat = screen flicker, no action
- In active encounter, click different campaign → flicker, no nav, no end.
- Either block (toast: end encounter first) or auto-end current + switch.
- Decide UX before fix.
### FEAT: generic/non-5e rules mode
- Campaign/encounter ruleset toggle: `5e` vs `generic`.
- Generic mode turns off death saves/state machine.
- Generic mode allows negative HP.
- 5e mode rejects negative damage/heal and clamps HP at 0.
### FEAT: clarify "Is NPC" in add-participant
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
+27
View File
@@ -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:
+185
View File
@@ -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
View File
@@ -207,6 +207,7 @@ function expandUndo(entry, currentEnc) {
case 'death_save':
case 'stabilize':
case 'revive':
case 'mark_dead':
case 'deactivate_dead_monster': {
// Full death-state restore: currentHp, status, death-save counters,
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
@@ -574,6 +575,7 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
const ctx = maybeCtx || optionsOrCtx;
const options = maybeCtx ? (optionsOrCtx || {}) : {};
const ruleset = encounter.ruleset || '5e';
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
if (isNaN(amount) || amount === 0) return encounter;
@@ -596,6 +598,12 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
let logDelta = { amount, from: participant.currentHp };
const status = oldValues.status;
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
if (ruleset === 'generic') {
return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx);
}
if (changeType === 'damage') {
if (participant.currentHp === 0) {
if (status === 'dead') {
@@ -683,7 +691,76 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
return commit(encounter, patch, log, ctx);
}
// GENERIC ruleset HP change: no death saves, negative HP, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
const participantId = participant.id;
let updates, message, logDelta = { amount, from: participant.currentHp };
if (changeType === 'damage') {
if (oldValues.status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, isActive: false } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
deathSaveSuccesses: 0, deathSaveFailures: 0,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
return commit(encounter, { participants: updatedParticipants }, {
type: 'deactivate_dead_monster', participantId, participantName: participant.name,
message: `${participant.name} marked inactive (dead monster)`,
delta: { status: 'dead', isActive: false },
undo: { oldValues, newValues },
}, ctx);
}
return encounter;
}
const newHp = participant.currentHp - amount;
const isMonster = participant.type === 'monster';
if (newHp <= 0 && isMonster) {
updates = { currentHp: newHp, status: 'dead', isActive: false };
} else if (newHp <= 0) {
updates = { currentHp: newHp, status: 'down' };
} else {
updates = { currentHp: newHp, status: 'conscious' };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
} else if (changeType === 'heal') {
if (oldValues.status === 'dead') return encounter;
const newHp = Math.min(participant.maxHp, participant.currentHp + amount);
updates = { currentHp: newHp, status: 'conscious' };
message = `${participant.name} healed for ${amount} (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: 'conscious' };
} else {
throw new Error(`Unknown HP change type: ${changeType}`);
}
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...updates } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
deathSaveFailures: newP.deathSaveFailures || 0,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
const undoAmount = changeType === 'damage' ? amount : -amount;
return commit(encounter, { participants: updatedParticipants }, {
type: changeType, participantId, participantName: participant.name,
message, delta: logDelta,
undo: { amount: undoAmount, oldValues, newValues },
}, ctx);
}
async function deathSave(encounter, participantId, outcome, ctx) {
if ((encounter.ruleset || '5e') === 'generic') {
throw new Error('Death saves not available in generic ruleset.');
}
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
@@ -764,6 +841,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
}
async function stabilizeParticipant(encounter, participantId, ctx) {
if ((encounter.ruleset || '5e') === 'generic') {
throw new Error('Stabilize not available in generic ruleset.');
}
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
@@ -806,12 +886,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
}
async function reviveParticipant(encounter, participantId, ctx) {
const ruleset = encounter.ruleset || '5e';
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
if (status !== 'dead') return encounter;
const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
// Generic: dead→conscious (no stable state). HP unchanged (may be negative).
// 5e: dead→stable at 0 HP, unconscious, clears death-save counters.
const updates = ruleset === 'generic'
? { status: 'conscious', ...(participant.type === 'monster' ? {} : {}), ...(participant.isActive === false ? { isActive: true } : {}) }
: { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
);
@@ -845,6 +930,34 @@ async function reviveParticipant(encounter, participantId, ctx) {
return commit(encounter, patch, log, ctx);
}
// markDead: DM manually sets status dead. Both rulesets. Monster = auto-inactive.
async function markDead(encounter, participantId, ctx) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
if (participant.status === 'dead') return encounter;
const oldValues = {
currentHp: participant.currentHp,
status: participant.status || 'conscious',
isActive: participant.isActive,
conditions: [...(participant.conditions || [])],
};
const updates = { status: 'dead', ...(participant.type === 'monster' ? { isActive: false } : {}) };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
return commit(encounter, { participants: updatedParticipants }, {
type: 'mark_dead', participantId, participantName: participant.name,
message: `${participant.name} marked dead`,
delta: { status: 'dead' },
undo: { oldValues, newValues },
}, ctx);
}
async function reorderParticipants(encounter, draggedId, targetId, ctx) {
const participants = [...(encounter.participants || [])];
const dragged = participants.find(p => p.id === draggedId);
@@ -919,7 +1032,7 @@ module.exports = {
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
reorderParticipants, endEncounter,
activateDisplay, clearDisplay, toggleHidePlayerHp,
};
+129 -25
View File
@@ -5,7 +5,7 @@ import {
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
Undo2, Redo2, Crosshair
} from 'lucide-react';
@@ -125,6 +125,7 @@ const {
deathSave: combatDeathSave,
stabilizeParticipant,
reviveParticipant,
markDead,
toggleCondition: combatToggleCondition,
reorderParticipants,
} = shared;
@@ -448,11 +449,12 @@ function ErrorDisplay({ message, critical = false }) {
function CreateCampaignForm({ onCreate, onCancel }) {
const [name, setName] = useState('');
const [backgroundUrl, setBackgroundUrl] = useState('');
const [ruleset, setRuleset] = useState('5e');
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate(name, backgroundUrl);
onCreate(name, backgroundUrl, ruleset);
}
};
@@ -484,6 +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"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
D&D 5e (death saves)
</label>
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
Generic (no death saves, negative HP)
</label>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
@@ -503,13 +518,14 @@ function CreateCampaignForm({ onCreate, onCancel }) {
);
}
function CreateEncounterForm({ onCreate, onCancel }) {
function CreateEncounterForm({ onCreate, onCancel, defaultRuleset = '5e' }) {
const [name, setName] = useState('');
const [ruleset, setRuleset] = useState(defaultRuleset);
const handleSubmit = (e) => {
e.preventDefault();
if (name.trim()) {
onCreate(name);
onCreate(name, ruleset);
}
};
@@ -528,6 +544,19 @@ function CreateEncounterForm({ onCreate, onCancel }) {
required
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
D&D 5e (death saves)
</label>
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
Generic (no death saves, negative HP)
</label>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
@@ -653,7 +682,14 @@ function CharacterManager({ campaignId, campaignCharacters }) {
const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [isOpen, setIsOpen] = useState(true);
const [isOpen, setIsOpen] = useState(() => {
try { return localStorage.getItem('ttrpg.charactersCollapsed') !== 'true'; }
catch { return true; }
});
useEffect(() => {
try { localStorage.setItem('ttrpg.charactersCollapsed', String(!isOpen)); }
catch {}
}, [isOpen]);
const handleAddCharacter = async () => {
if (!db || !characterName.trim() || !campaignId) return;
@@ -740,15 +776,14 @@ function CharacterManager({ campaignId, campaignCharacters }) {
<>
<div className="p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center">
<Users size={24} className="mr-2" /> Campaign Characters
</h3>
<button
onClick={() => setIsOpen(!isOpen)}
className="p-1 text-stone-400 hover:text-stone-200"
aria-label={isOpen ? "Collapse" : "Expand"}
className="flex items-center text-xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
aria-expanded={isOpen}
>
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
{isOpen ? <ChevronDown size={20} className="mr-1" /> : <ChevronRight size={20} className="mr-1" />}
<Users size={24} className="mr-2" /> Campaign Characters
<span className="text-sm font-normal text-stone-400 ml-1">({campaignCharacters.length})</span>
</button>
</div>
@@ -1153,6 +1188,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
}
};
const handleMarkDead = async (participantId) => {
if (!db) return;
try {
await markDead(encounter, participantId, buildCtx(encounterPath));
} catch (err) {
showToast(err.message);
}
};
const handleCritDamage = async (participantId) => {
if (!db) return;
try {
@@ -1414,14 +1458,18 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character');
const hasDeathSaves = p.type === 'character' || p.type === 'npc';
const isGeneric = (encounter.ruleset || '5e') === 'generic';
const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc');
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious');
const isZeroHp = p.currentHp === 0;
const participantStatus = p.status || (isGeneric
? (p.currentHp <= 0 ? 'down' : 'conscious')
: (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious'));
const isZeroHp = p.currentHp <= 0;
const isDead = participantStatus === 'dead';
const isDown = participantStatus === 'down';
return (
<li
@@ -1452,6 +1500,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
)}
{hasDeathSaves && participantStatus === 'dying' && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Dying)</span>}
{hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && <span className="ml-2 text-xs text-emerald-300 font-semibold">(Unconscious)</span>}
{isDown && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Down)</span>}
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
</p>
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
@@ -1477,11 +1526,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
</div>
{participantStatus === 'dead' && (
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive to 0 HP, stable and unconscious">
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive (clear dead status)">
<HeartPulse size={14} /> Revive
</button>
)}
{isGeneric && !isDead && (
<button onClick={() => handleMarkDead(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-red-900 hover:bg-red-800 text-white" title="Mark dead">
<HeartCrack size={14} /> Mark Dead
</button>
)}
{hasDeathSaves && participantStatus === 'stable' && (
<div className="mt-2 text-xs text-emerald-300/80 italic">Stable regains 1 HP after 1d4 hours</div>
)}
@@ -1944,12 +1999,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
// ENCOUNTER MANAGER COMPONENT
// ============================================================================
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) {
const { showToast } = useUIFeedback();
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null
);
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
const [encounters, setEncounters] = useState([]);
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
@@ -1992,7 +2048,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
}
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
const handleCreateEncounter = async (name) => {
const handleCreateEncounter = async (name, ruleset = '5e') => {
if (!db || !name.trim() || !campaignId) return;
const newEncounterId = generateId();
@@ -2005,7 +2061,8 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false
isPaused: false,
ruleset: ruleset || '5e',
});
setShowCreateModal(false);
@@ -2079,6 +2136,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
useEffect(() => {
if (encounterStartedRef) {
encounterStartedRef.current = !!(selectedEncounter && selectedEncounter.isStarted && !selectedEncounter.isPaused);
}
if (encounterActiveRef) {
encounterActiveRef.current = !!(selectedEncounter && selectedEncounter.isStarted);
}
}, [selectedEncounter, encounterStartedRef, encounterActiveRef]);
if (isLoadingEncounters && campaignId) {
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
}
@@ -2115,9 +2181,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
>
<div className="flex justify-between items-center">
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
<h4 className="font-medium text-white">{encounter.name}</h4>
<h4 className="font-medium text-white">{encounter.name} <span className={`ml-1 px-1.5 py-0.5 rounded text-xs font-bold tracking-wide ${encounter.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}>{encounter.ruleset === 'generic' ? 'GEN' : '5e'}</span></h4>
<p className="text-xs text-stone-300">
Participants: {encounter.participants?.length || 0}
{encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}
</p>
{isLive && (
<span className="text-xs text-green-400 font-semibold block mt-1">
@@ -2153,8 +2219,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{showCreateModal && (
<Modal onClose={() => setShowCreateModal(false)} title="Create New Encounter">
<CreateEncounterForm
key={campaignId}
onCreate={handleCreateEncounter}
onCancel={() => setShowCreateModal(false)}
defaultRuleset={campaignDoc?.ruleset || '5e'}
/>
</Modal>
)}
@@ -2212,6 +2280,19 @@ function AdminView({ userId }) {
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const encounterStartedRef = useRef(false);
const encounterActiveRef = useRef(false);
const manualSelectRef = useRef(false);
const prevDisplayCampaignRef = useRef(null);
// External display change (replay/other DM) = clear manual override, allow follow.
useEffect(() => {
const cur = initialActiveInfo?.activeCampaignId || null;
if (cur !== prevDisplayCampaignRef.current) {
manualSelectRef.current = false;
prevDisplayCampaignRef.current = cur;
}
}, [initialActiveInfo]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
@@ -2271,10 +2352,15 @@ function AdminView({ userId }) {
}, [campaignsData]);
useEffect(() => {
// Skip follow only if user manually selected AND display hasn't changed.
// (external display change clears manualSelectRef via prevDisplay effect)
if (manualSelectRef.current && selectedCampaignId !== initialActiveInfo?.activeCampaignId) return;
if (
initialActiveInfo &&
initialActiveInfo.activeCampaignId &&
campaignsWithDetails.length > 0
campaignsWithDetails.length > 0 &&
!encounterStartedRef.current &&
!encounterActiveRef.current
) {
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
@@ -2283,7 +2369,7 @@ function AdminView({ userId }) {
}
}, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]);
const handleCreateCampaign = async (name, backgroundUrl) => {
const handleCreateCampaign = async (name, backgroundUrl, ruleset = '5e') => {
if (!db || !name.trim()) return;
const newCampaignId = generateId();
@@ -2295,6 +2381,7 @@ function AdminView({ userId }) {
ownerId: userId,
createdAt: new Date().toISOString(),
players: [],
ruleset: ruleset || '5e',
});
setShowCreateModal(false);
@@ -2446,7 +2533,14 @@ function AdminView({ userId }) {
return (
<div
key={campaign.id}
onClick={() => setSelectedCampaignId(campaign.id)}
onClick={() => {
if (encounterStartedRef.current) {
showToast('End or pause active encounter before switching campaigns.');
return;
}
manualSelectRef.current = true;
setSelectedCampaignId(campaign.id);
}}
className={cardClasses}
style={cardStyle}
>
@@ -2478,6 +2572,12 @@ function AdminView({ userId }) {
>
<Trash2 size={14} className="mr-1" /> Delete
</button>
<span
className={`absolute bottom-2 right-2 z-20 px-2 py-0.5 rounded text-xs font-bold tracking-wide ${campaign.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}
title={campaign.ruleset === 'generic' ? 'Generic ruleset' : 'D&D 5e ruleset'}
>
{campaign.ruleset === 'generic' ? 'GEN' : '5e'}
</span>
</div>
</div>
);
@@ -2514,6 +2614,8 @@ function AdminView({ userId }) {
: null
}
campaignCharacters={selectedCampaign.characters || []}
encounterStartedRef={encounterStartedRef}
encounterActiveRef={encounterActiveRef}
/>
</div>
)}
@@ -2847,8 +2949,9 @@ function DisplayView() {
<div className="space-y-4 max-w-3xl mx-auto">
{participantsToRender.map(p => {
const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious');
const isZeroHp = p.currentHp === 0;
const isGeneric = (activeEncounterData.ruleset || '5e') === 'generic';
const status = p.status || (isGeneric ? (p.currentHp <= 0 ? 'down' : 'conscious') : (p.currentHp === 0 ? 'dying' : 'conscious'));
const isZeroHp = p.currentHp <= 0;
let participantBgColor = p.type === 'monster' || p.type === 'npc'
? 'bg-[#8e351c]'
: 'bg-indigo-950';
@@ -2882,6 +2985,7 @@ function DisplayView() {
)}
{status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>}
{status === 'stable' && (p.conditions || []).includes('unconscious') && <span className="text-emerald-300 text-lg ml-2">(Unconscious)</span>}
{status === 'down' && <span className="text-red-300 text-lg ml-2 animate-pulse">(Down)</span>}
{status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>}
</h3>
<span