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 }) { +