Generic (non-5e) ruleset mode + UI polish + combat switch fix #7
@@ -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