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
+34 -1
View File
@@ -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);
}