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).
This commit is contained in:
@@ -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.....
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+34
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+66
-14
@@ -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 }) {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -727,7 +727,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
// CHARACTER MANAGER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
const { showToast, showInfo } = useUIFeedback();
|
||||
const [characterName, setCharacterName] = useState('');
|
||||
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
|
||||
@@ -795,7 +795,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
showToast("Failed to add character. Please try again."); }
|
||||
};
|
||||
|
||||
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc) => {
|
||||
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp) => {
|
||||
if (!db || !newName.trim() || !campaignId) return;
|
||||
|
||||
const hp = parseInt(newDefaultMaxHp, 10);
|
||||
@@ -812,7 +812,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
|
||||
const updatedCharacters = campaignCharacters.map(c =>
|
||||
c.id === characterId
|
||||
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null }
|
||||
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null }
|
||||
: c
|
||||
);
|
||||
|
||||
@@ -861,6 +861,16 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<label className={`inline-flex items-center gap-2 mb-3 px-3 py-1.5 rounded-lg cursor-pointer text-sm font-medium transition-colors ${syncCharacters ? 'bg-amber-900 border border-amber-500 text-amber-100' : 'bg-stone-800 border border-stone-700 text-stone-400 hover:text-stone-200'}`} title="When enabled, ending an encounter writes each character's current HP, Max HP, and AC back to the campaign roster so they carry into the next encounter."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncCharacters}
|
||||
onChange={(e) => storage.updateDoc(getPath.campaign(campaignId), { syncCharacters: e.target.checked })}
|
||||
className="h-4 w-4 text-amber-600 border-stone-400 rounded focus:ring-amber-500"
|
||||
/>
|
||||
Sync character changes on encounter end
|
||||
</label>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-2 sm:grid-cols-12 gap-2 mb-4 items-end">
|
||||
<div className="col-span-2 sm:col-span-6">
|
||||
<label htmlFor="characterName" className="block text-xs font-medium text-stone-400">
|
||||
@@ -945,7 +955,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
editingCharacter.name,
|
||||
editingCharacter.defaultMaxHp,
|
||||
editingCharacter.defaultInitMod,
|
||||
editingCharacter.defaultAc
|
||||
editingCharacter.defaultAc,
|
||||
editingCharacter.defaultCurrentHp
|
||||
);
|
||||
}}
|
||||
className="flex-grow flex flex-wrap gap-2 items-center"
|
||||
@@ -969,6 +980,16 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
title="Default Max HP"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<label className="text-[10px] text-stone-400 mb-0.5">Cur HP</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingCharacter.defaultCurrentHp ?? ''}
|
||||
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultCurrentHp: e.target.value })}
|
||||
className="w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
|
||||
title="Default Current HP"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<label className="text-[10px] text-stone-400 mb-0.5">Init</label>
|
||||
<input
|
||||
@@ -1002,20 +1023,28 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-stone-100">
|
||||
{character.name}{' '}
|
||||
<span className="text-xs text-stone-400">
|
||||
(HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)}{character.defaultAc != null ? `, AC: ${character.defaultAc}` : ''})
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex items-center mr-auto">
|
||||
<span className="text-stone-100 mr-2">{character.name}</span>
|
||||
<div className="flex gap-1">
|
||||
<span className="px-1.5 py-0.5 rounded bg-red-900/60 text-red-200 text-xs font-medium">HP {character.defaultMaxHp || '?'}</span>
|
||||
{character.defaultCurrentHp != null && character.defaultCurrentHp !== character.defaultMaxHp && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-red-950 text-red-300 text-xs font-medium" title="Current HP (carried from last encounter)">Current HP {character.defaultCurrentHp}</span>
|
||||
)}
|
||||
{character.defaultAc != null && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-sky-900/60 text-sky-200 text-xs font-medium">AC {character.defaultAc}</span>
|
||||
)}
|
||||
<span className="px-1.5 py-0.5 rounded bg-amber-900/40 text-amber-200 text-xs font-medium">Init {formatInitMod(character.defaultInitMod)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setEditingCharacter({
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP,
|
||||
defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD,
|
||||
defaultAc: character.defaultAc ?? ''
|
||||
defaultAc: character.defaultAc ?? '',
|
||||
defaultCurrentHp: character.defaultCurrentHp ?? ''
|
||||
})}
|
||||
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
|
||||
aria-label="Edit character"
|
||||
@@ -2047,6 +2076,21 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
: getUndo(undoTarget); // legacy
|
||||
if (!expanded) return;
|
||||
await storage.undo({ logPath: `${getPath.logs()}/${undoTarget.id}`, undo: expanded });
|
||||
// Restore character writeback (end_encounter sync)
|
||||
const cw = undoTarget.undo?.characterWriteback;
|
||||
const wbId = encounter?.campaignId || campaignId;
|
||||
if (cw && wbId) {
|
||||
try {
|
||||
const campDoc = await storage.getDoc(getPath.campaign(wbId));
|
||||
if (campDoc && Array.isArray(campDoc.players)) {
|
||||
const restored = campDoc.players.map(p => {
|
||||
const old = cw.find(c => c.id === p.id);
|
||||
return old ? { ...p, defaultMaxHp: old.defaultMaxHp, defaultAc: old.defaultAc, defaultCurrentHp: old.defaultCurrentHp } : p;
|
||||
});
|
||||
await storage.updateDoc(getPath.campaign(wbId), { players: restored });
|
||||
}
|
||||
} catch (e) { console.error('characterWriteback restore failed:', e); }
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error undoing action:', err);
|
||||
@@ -2132,7 +2176,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
const confirmEndEncounter = async () => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await endEncounter(encounter, buildCtx(encounterPath));
|
||||
const ctx = { ...buildCtx(encounterPath), campaignId };
|
||||
if (campaignId) {
|
||||
try {
|
||||
ctx.campaign = await storage.getDoc(getPath.campaign(campaignId));
|
||||
} catch {}
|
||||
}
|
||||
await endEncounter(encounter, ctx);
|
||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
||||
} catch (err) {
|
||||
showToast("Failed to end encounter. Please try again."); }
|
||||
@@ -2357,6 +2407,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
try {
|
||||
await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, {
|
||||
name: name.trim(),
|
||||
campaignId,
|
||||
createdAt: new Date().toISOString(),
|
||||
participants: [],
|
||||
round: 0,
|
||||
@@ -3068,6 +3119,7 @@ function AdminView({ userId }) {
|
||||
<CharacterManager
|
||||
campaignId={selectedCampaignId}
|
||||
campaignCharacters={selectedCampaign.characters || []}
|
||||
syncCharacters={!!selectedCampaign.syncCharacters}
|
||||
/>
|
||||
<hr className="my-6 border-stone-700" />
|
||||
<EncounterManager
|
||||
|
||||
Reference in New Issue
Block a user