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:
david raistrick
2026-07-08 16:33:21 -04:00
parent 96770099f6
commit fd03ae48c8
4 changed files with 263 additions and 16 deletions
+66 -14
View File
@@ -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