From fd03ae48c864632f7dbf7275ff17fb7bc8b4c675 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:33:21 -0400 Subject: [PATCH] Character writeback on encounter end Campaign-level toggle (CharacterManager, live flip). When ON, ending an encounter writes each character participant's current HP, Max HP, and AC back to the campaign roster so they carry into the next encounter. - shared: buildCharacterParticipant uses defaultCurrentHp (fallback maxHp) - endEncounter: syncs maxHp/ac/currentHp to campaign players if syncCharacters, snapshots old values into undo payload for restore. Works retroactive (ctx.campaignId fallback for encounters lacking campaignId field). - New encounters store campaignId field. - Undo end-encounter restores character values client-side. - CharacterManager: styled sync toggle (checkbox badge), stat badges in list (HP/Current HP/AC/Init), edit box supports Current HP field. Tests: 5 writeback cases (off/on/skip monsters/undo snapshot/missing char). --- TODO.md | 7 +- shared/tests/turn.writeback.test.js | 157 ++++++++++++++++++++++++++++ shared/turn.js | 35 ++++++- src/App.js | 80 +++++++++++--- 4 files changed, 263 insertions(+), 16 deletions(-) create mode 100644 shared/tests/turn.writeback.test.js diff --git a/TODO.md b/TODO.md index 5fa5bae..60252ca 100644 --- a/TODO.md +++ b/TODO.md @@ -20,7 +20,12 @@ maybe campaign toggle in charc section (choosing each end is DM overload will be hp wont go over max and no temp hp support -refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes? +x refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes? + + +I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived + npcs..... + diff --git a/shared/tests/turn.writeback.test.js b/shared/tests/turn.writeback.test.js new file mode 100644 index 0000000..8fd099f --- /dev/null +++ b/shared/tests/turn.writeback.test.js @@ -0,0 +1,157 @@ +// Character writeback on endEncounter: sync maxHp/ac to campaign players. +const shared = require('@ttrpg/shared'); +const { endEncounter } = shared; + +function makeMockStorage() { + const docs = new Map(); + const writes = []; + return { + getDoc: jest.fn(async (p) => docs.get(p) || null), + setDoc: jest.fn(async (p, d) => { docs.set(p, d); }), + updateDoc: jest.fn(async (p, patch) => { + writes.push({ path: p, patch }); + const cur = docs.get(p) || {}; + docs.set(p, { ...cur, ...patch }); + }), + addDoc: jest.fn(async (p, data) => { + writes.push({ path: p, patch: data }); + docs.set(p, data); + }), + deleteDoc: jest.fn(async () => {}), + getCollection: jest.fn(async () => []), + _docs: docs, + _writes: writes, + }; +} + +function makeEncounter(overrides = {}) { + return { + id: 'enc1', + name: 'Test', + campaignId: 'camp1', + isStarted: true, + isPaused: false, + currentTurnParticipantId: 'p1', + round: 3, + turnOrderIds: ['p1', 'p2'], + participants: [ + { id: 'p1', name: 'Fighter', type: 'character', originalCharacterId: 'char1', initiative: 15, maxHp: 28, currentHp: 20, ac: 18 }, + { id: 'p2', name: 'Goblin', type: 'monster', originalCharacterId: null, initiative: 12, maxHp: 7, currentHp: 7, ac: 13 }, + { id: 'p3', name: 'Cleric', type: 'character', originalCharacterId: 'char2', initiative: 10, maxHp: 22, currentHp: 22, ac: 16 }, + ], + ...overrides, + }; +} + +describe('character writeback on endEncounter', () => { + test('no writeback when syncCharacters false/missing', async () => { + const storage = makeMockStorage(); + storage._docs.set('campaigns/camp1', { + id: 'camp1', name: 'Camp', players: [ + { id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 }, + ], + }); + const enc = makeEncounter(); + const ctx = { + storage, + encounterPath: 'campaigns/camp1/encounters/enc1', + logPath: 'logs/log1', + logCollection: 'logs', + displayPath: 'activeDisplay/status', + }; + await endEncounter(enc, ctx); + // no campaign write + expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined(); + }); + + test('writeback updates char maxHp + ac + currentHp when syncCharacters true', async () => { + const storage = makeMockStorage(); + const players = [ + { id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 }, + { id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 }, + ]; + storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true }); + const enc = makeEncounter(); + const ctx = { + storage, + encounterPath: 'campaigns/camp1/encounters/enc1', + logPath: 'logs/log1', + logCollection: 'logs', + displayPath: 'activeDisplay/status', + }; + await endEncounter(enc, ctx); + const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1'); + expect(campWrite).toBeDefined(); + const updated = campWrite.patch.players; + const fighter = updated.find(p => p.id === 'char1'); + expect(fighter.defaultMaxHp).toBe(28); + expect(fighter.defaultAc).toBe(18); + expect(fighter.defaultCurrentHp).toBe(20); + const cleric = updated.find(p => p.id === 'char2'); + expect(cleric.defaultMaxHp).toBe(22); + expect(cleric.defaultAc).toBe(16); + expect(cleric.defaultCurrentHp).toBe(22); + }); + + test('writeback skips monsters (no originalCharacterId)', async () => { + const storage = makeMockStorage(); + const players = [{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 }]; + storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true }); + const enc = makeEncounter(); + const ctx = { + storage, + encounterPath: 'campaigns/camp1/encounters/enc1', + logPath: 'logs/log1', + logCollection: 'logs', + displayPath: 'activeDisplay/status', + }; + await endEncounter(enc, ctx); + const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1'); + // players array still 1 entry, goblin not added + expect(campWrite.patch.players.length).toBe(1); + }); + + test('undo payload includes old char values for restore', async () => { + const storage = makeMockStorage(); + const players = [ + { id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 }, + { id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 }, + ]; + storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true }); + storage._docs.set('logs/log1', null); + const enc = makeEncounter(); + const ctx = { + storage, + encounterPath: 'campaigns/camp1/encounters/enc1', + logPath: 'logs/log1', + logCollection: 'logs', + displayPath: 'activeDisplay/status', + }; + await endEncounter(enc, ctx); + const logWrite = storage._writes.find(w => w.path === 'logs/log1'); + expect(logWrite).toBeDefined(); + const log = logWrite.patch; + expect(log.undo.characterWriteback).toBeDefined(); + expect(log.undo.characterWriteback).toHaveLength(2); + const fighterOld = log.undo.characterWriteback.find(c => c.id === 'char1'); + expect(fighterOld.defaultMaxHp).toBe(30); + expect(fighterOld.defaultAc).toBe(17); + expect(fighterOld.defaultCurrentHp).toBe(30); + }); + + test('writeback no-op if character missing from campaign', async () => { + const storage = makeMockStorage(); + storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players: [], syncCharacters: true }); + const enc = makeEncounter(); + const ctx = { + storage, + encounterPath: 'campaigns/camp1/encounters/enc1', + logPath: 'logs/log1', + logCollection: 'logs', + displayPath: 'activeDisplay/status', + }; + await endEncounter(enc, ctx); + // no writeback (no matching chars = no change) + expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined(); + }); +}); diff --git a/shared/turn.js b/shared/turn.js index d2a1672..4a53e30 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -370,6 +370,7 @@ function buildCharacterParticipant(character) { const modifier = character.defaultInitMod || 0; const finalInitiative = initiativeRoll + modifier; const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP; + const currentHp = character.defaultCurrentHp != null ? character.defaultCurrentHp : maxHp; return { participant: makeParticipant({ name: character.name, @@ -377,7 +378,7 @@ function buildCharacterParticipant(character) { originalCharacterId: character.id, initiative: finalInitiative, maxHp, - currentHp: maxHp, + currentHp, ac: character.defaultAc !== undefined ? character.defaultAc : null, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, @@ -1054,6 +1055,38 @@ async function endEncounter(encounter, ctx) { delta: {}, undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], endedAt: encounter.endedAt ?? null }, }; + + // Character writeback: sync maxHp/ac/currentHp to campaign players if enabled. + const wbCampaignId = encounter.campaignId || ctx.campaignId; + if ((wbCampaignId || ctx.campaign) && ctx.storage) { + let campaign; + try { + campaign = ctx.campaign || await ctx.storage.getDoc(`campaigns/${wbCampaignId}`); + } catch { campaign = null; } + if (campaign && campaign.syncCharacters && Array.isArray(campaign.players)) { + const charParticipants = (encounter.participants || []).filter(p => p.originalCharacterId); + const oldValues = []; + let changed = false; + const updatedPlayers = campaign.players.map(player => { + const cp = charParticipants.find(p => p.originalCharacterId === player.id); + if (!cp) return player; + const oldMaxHp = player.defaultMaxHp; + const oldAc = player.defaultAc; + const oldCurrentHp = player.defaultCurrentHp; + oldValues.push({ id: player.id, defaultMaxHp: oldMaxHp, defaultAc: oldAc, defaultCurrentHp: oldCurrentHp }); + const newPlayer = { ...player }; + if (cp.maxHp != null && cp.maxHp !== oldMaxHp) { newPlayer.defaultMaxHp = cp.maxHp; changed = true; } + if (cp.ac != null && cp.ac !== oldAc) { newPlayer.defaultAc = cp.ac; changed = true; } + if (cp.currentHp != null && cp.currentHp !== oldCurrentHp) { newPlayer.defaultCurrentHp = cp.currentHp; changed = true; } + return newPlayer; + }); + if (changed) { + await ctx.storage.updateDoc(`campaigns/${wbCampaignId}`, { players: updatedPlayers }); + log.undo.characterWriteback = oldValues; + } + } + } + return commit(encounter, patch, log, ctx); } diff --git a/src/App.js b/src/App.js index bc08cc8..9fbb1fc 100644 --- a/src/App.js +++ b/src/App.js @@ -1,4 +1,3 @@ -// import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react'; import * as shared from '@ttrpg/shared'; import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage'; @@ -501,6 +500,7 @@ function CreateCampaignForm({ onCreate, onCancel }) { +