Builds replayable combat logs with first-class undo/redo, unified verification tooling, fast indexed log queries, and stricter CI.
feat(combat): add first-class undo and redo controls Add undo and redo controls to the combat UI so the DM can recover from recent actions without leaving the encounter view. Undo and redo operate on the current encounter's combat history. Empty stacks produce clear feedback instead of failing silently. Redo order follows normal stack behavior after multiple undos. This makes combat history actionable during play, not just visible in the log. feat(logs): make combat logs replayable Replace plain combat log messages with structured combat events that can be used by the UI, exported as JSON, replayed, and verified. Each new log entry records the action type, encounter identity, participant identity, a small action delta, undo intent, and a turn snapshot. Download and copy now export the event stream as JSON so a saved combat log is useful for offline analysis and debugging. Legacy logs remain viewable, but new logs use the structured event format. feat(logs): make undo and redo transactional Apply undo and redo as single storage operations so the encounter state and log state cannot drift apart. Server storage applies the encounter update and the log undone flag inside one SQLite transaction. Firebase storage uses a batch write for the same behavior. The storage contract now includes undo/redo semantics. This replaces fragile multi-write undo behavior where a failure could update the encounter without marking the log, or mark the log without updating the encounter. feat(combat): add unified replay and verification tool Add one combat CLI for replaying live combat and verifying combat logs. Replay drives the live backend through the same shared combat logic used by the app, writes a JSON event log to an explicit output path, and automatically verifies the result. Verification checks for DM-visible combat problems such as skipped turns, double actions, bad round changes, and unexpected turn order changes. The tool uses the same JSON event stream produced by log downloads, supports verbose turn output, and handles Ctrl-C by ending the encounter, writing the partial log, and verifying what was captured. fix(perf): keep long combat logging fast Remove the combat-time log query bottleneck that made long replays slow as log volume grew. Combat controls no longer subscribe to the log collection just to keep undo and redo state warm. Undo and redo now query the latest matching encounter log only when clicked. Server collection queries support exact filters, ordering, limits, and offsets, and SQLite indexes keep latest-log and per-encounter log lookups fast. Also fix duplicate WebSocket handler registration so realtime updates do not double-fire under write load. fix(turns): make toggle active a status change Make toggle active a roster/status edit instead of a turn advance. Deactivating the current participant no longer passes the turn or increments the round. The current turn stays where it is until the DM explicitly clicks Next Turn, and Next Turn skips inactive participants during normal rotation. This matches the initiative design: slot order is stable, toggle active does not move participants, and round changes only come from explicit turn advance. chore(ci): make warnings and hangs fail fast Tighten test and build checks so failures are visible instead of noisy or silent. Builds run with CI enabled so warnings fail production builds. The full test command runs app, shared, and server suites with hard timeouts so hangs fail quickly. Static eslint coverage fails on warnings as well as errors. Tests were updated around the new async combat logging flow, structured log events, transactional undo, replay verification, and toggle-active semantics.
This commit is contained in:
+413
-176
@@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react';
|
||||
import * as shared from '@ttrpg/shared';
|
||||
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, orderBy, limit, getStorage, getStorageMode } from './storage';
|
||||
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage';
|
||||
import {
|
||||
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
||||
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
||||
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
|
||||
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
|
||||
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, CheckCircle2, X,
|
||||
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
|
||||
Undo2, Redo2
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -118,8 +118,6 @@ const APP_VERSION = 'v0.4';
|
||||
const {
|
||||
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
|
||||
generateId, rollD20, formatInitMod,
|
||||
sortParticipantsByInitiative, syncTurnOrder,
|
||||
computeTurnOrderAfterRemoval,
|
||||
startEncounter, nextTurn, togglePause, endEncounter,
|
||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive: combatToggleActive,
|
||||
@@ -127,7 +125,6 @@ const {
|
||||
deathSave: combatDeathSave,
|
||||
toggleCondition: combatToggleCondition,
|
||||
reorderParticipants,
|
||||
activateDisplay, clearDisplay, toggleHidePlayerHp: combatToggleHidePlayerHp,
|
||||
} = shared;
|
||||
const ROLL_DISPLAY_DURATION = 5000;
|
||||
|
||||
@@ -235,18 +232,28 @@ const getPath = {
|
||||
// generateId, rollD20, formatInitMod, sortParticipantsByInitiative,
|
||||
// computeTurnOrderAfterRemoval/Addition: imported from @ttrpg/shared (1-list model).
|
||||
|
||||
const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)];
|
||||
// Display limit: recent logs only. Download/copy fetch full set on demand.
|
||||
// Keep small — 500 = ~900KB per snapshot = slow load.
|
||||
const LOG_PAGE_SIZE = 100;
|
||||
const LOG_ORDER = orderBy('ts', 'desc');
|
||||
|
||||
const logAction = async (message, context = {}, undoData = null) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
const entry = { timestamp: Date.now(), message, ...context };
|
||||
if (undoData) entry.undo = undoData;
|
||||
await storage.addDoc(getPath.logs(), entry);
|
||||
} catch (err) {
|
||||
console.error('Error writing log:', err);
|
||||
}
|
||||
};
|
||||
// Combat actions now live in shared/turn.js and WRITE THEIR OWN LOGS.
|
||||
// Every mutating func is async, takes ctx {storage, encPath, logPath},
|
||||
// persists encounter patch + log entry itself. No separate logEvent call.
|
||||
//
|
||||
// buildCtx: standard context for combat action calls. Lazy getStorage()
|
||||
// so handlers work even if module-level `storage` assigned late (test/auth).
|
||||
const buildCtx = (encounterPath) => ({
|
||||
storage: getStorage(),
|
||||
encPath: encounterPath,
|
||||
logPath: getPath.logs(),
|
||||
});
|
||||
|
||||
// Read undo payload: new lean `undo` (id-based) or legacy (`undo_payload`/`undo`).
|
||||
// Lean events need expandUndo() at click time (needs current enc doc).
|
||||
// expandUndo lives in shared/turn.js (re-exported via @ttrpg/shared).
|
||||
const { expandUndo } = shared;
|
||||
const getUndo = (entry) => entry.undo_payload || entry.undo || null;
|
||||
|
||||
|
||||
// ============================================================================
|
||||
@@ -307,12 +314,14 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) {
|
||||
setData(items);
|
||||
setIsLoading(false);
|
||||
}, queryConstraints, (err) => {
|
||||
console.error(`Error fetching collection ${collectionPath}:`, err);
|
||||
console.error(`[useFirestoreCollection] ERR ${collectionPath}:`, err);
|
||||
setError(err.message || "Failed to fetch collection.");
|
||||
setIsLoading(false);
|
||||
setData([]);
|
||||
});
|
||||
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') unsubscribe();
|
||||
};
|
||||
// queryString, not array ref
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [collectionPath, queryString]);
|
||||
@@ -349,8 +358,35 @@ function Modal({ onClose, title, children }) {
|
||||
}
|
||||
|
||||
function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Reset transient state each time modal opens.
|
||||
useEffect(() => {
|
||||
if (isOpen) { setPending(false); setError(null); }
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (pending) return; // double-click guard
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('ConfirmationModal action failed:', err);
|
||||
setError(err?.message || 'Action failed. Try again.');
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (pending) return; // block close mid-action
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
|
||||
@@ -359,18 +395,23 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
|
||||
<h2 className="text-xl font-semibold text-yellow-300">{title || "Confirm Action"}</h2>
|
||||
</div>
|
||||
<p className="text-stone-300 mb-6">{message || "Are you sure you want to proceed?"}</p>
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm mb-4">{error}</p>
|
||||
)}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors"
|
||||
onClick={close}
|
||||
disabled={pending}
|
||||
className="px-4 py-2 text-sm font-medium text-stone-300 bg-stone-700 hover:bg-stone-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
|
||||
onClick={handleConfirm}
|
||||
disabled={pending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Confirm
|
||||
{pending ? "Working..." : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -955,13 +996,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
};
|
||||
|
||||
try {
|
||||
const { patch, log } = addParticipant(encounter, newParticipant);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
await addParticipant(encounter, newParticipant, buildCtx(encounterPath));
|
||||
|
||||
setLastRollDetails({
|
||||
name: nameToAdd,
|
||||
@@ -1019,15 +1054,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
}
|
||||
|
||||
try {
|
||||
const { patch, log } = addParticipants(encounter, newParticipants);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
if (log) {
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
}
|
||||
await addParticipants(encounter, newParticipants, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
showToast("Failed to add all characters. Please try again."); }
|
||||
};
|
||||
@@ -1035,15 +1062,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const handleUpdateParticipant = async (updatedData) => {
|
||||
if (!db || !editingParticipant) return;
|
||||
try {
|
||||
const { patch, log } = updateParticipant(encounter, editingParticipant.id, updatedData);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
if (log) {
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
}
|
||||
await updateParticipant(encounter, editingParticipant.id, updatedData, buildCtx(encounterPath));
|
||||
setEditingParticipant(null);
|
||||
} catch (err) {
|
||||
showToast("Failed to update participant. Please try again."); }
|
||||
@@ -1058,15 +1077,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const n = parseInt(value, 10);
|
||||
if (isNaN(n)) return;
|
||||
try {
|
||||
const { patch, log } = updateParticipant(encounter, participantId, { initiative: n });
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
if (log) {
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
}
|
||||
await updateParticipant(encounter, participantId, { initiative: n }, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
// fall through silently
|
||||
}
|
||||
@@ -1080,13 +1091,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const confirmDeleteParticipant = async () => {
|
||||
if (!db || !itemToDelete) return;
|
||||
try {
|
||||
const { patch, log } = removeParticipant(encounter, itemToDelete.id);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
await removeParticipant(encounter, itemToDelete.id, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
showToast("Failed to delete participant. Please try again."); }
|
||||
setShowDeleteConfirm(false);
|
||||
@@ -1096,13 +1101,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const toggleParticipantActive = async (participantId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
const { patch, log } = combatToggleActive(encounter, participantId);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
await combatToggleActive(encounter, participantId, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
// fall through silently
|
||||
}
|
||||
@@ -1118,17 +1117,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { patch, log } = combatApplyHpChange(encounter, participantId, changeType, amount);
|
||||
if (patch) {
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
if (log) {
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
}
|
||||
}
|
||||
await combatApplyHpChange(encounter, participantId, changeType, amount, buildCtx(encounterPath));
|
||||
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
|
||||
} catch (err) {
|
||||
// fall through silently
|
||||
@@ -1138,8 +1127,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const handleDeathSaveChange = async (participantId, type, saveNumber) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
const { patch, isDying } = combatDeathSave(encounter, participantId, type, saveNumber);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
const { isDying } = await combatDeathSave(encounter, participantId, type, saveNumber, buildCtx(encounterPath));
|
||||
if (isDying) {
|
||||
setTimeout(async () => {
|
||||
const finalParticipants = encounter.participants.filter(p => p.id !== participantId);
|
||||
@@ -1154,15 +1142,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const toggleCondition = async (participantId, conditionId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
const { patch, log } = combatToggleCondition(encounter, participantId, conditionId);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
const cond = allConditions.find(c => c.id === conditionId);
|
||||
const condLabel = cond ? `${cond.label} ${cond.emoji || ''}`.trim() : conditionId;
|
||||
logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
await combatToggleCondition(encounter, participantId, conditionId, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
// fall through silently
|
||||
}
|
||||
@@ -1183,12 +1163,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const cur = (encounter.participants.find(p => p.id === participantId)?.conditions) || [];
|
||||
if (!cur.includes(trimmed)) {
|
||||
try {
|
||||
const { patch, log } = combatToggleCondition(encounter, participantId, trimmed);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
logAction(log.message.replace(trimmed, trimmed), { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath, updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
await combatToggleCondition(encounter, participantId, trimmed, buildCtx(encounterPath));
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
@@ -1211,15 +1186,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { patch, log } = reorderParticipants(encounter, draggedItemId, targetId);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
if (log) {
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
}
|
||||
await reorderParticipants(encounter, draggedItemId, targetId, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
// drag invalid (id not found) — ignore
|
||||
}
|
||||
@@ -1685,23 +1652,38 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
const { showToast, showInfo } = useUIFeedback();
|
||||
const [showEndConfirm, setShowEndConfirm] = useState(false);
|
||||
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
|
||||
const { data: logsData } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
|
||||
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
|
||||
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
|
||||
|
||||
// Undo/redo stacks for THIS encounter.
|
||||
const encLogs = (logsData || []).filter(l => l.encounterPath === encounterPath && l.undo);
|
||||
const undoTarget = encLogs.find(l => !l.undone) || null;
|
||||
// redo = latest undone entry that is NEWER than the latest undone entry's
|
||||
// predecessor — simplest correct heuristic: first entry (desc order) with undone=true
|
||||
// whose immediate newer entry is undone or absent.
|
||||
const redoTarget = encLogs.find(l => l.undone) || null;
|
||||
// Undo/redo queried on click. No live logs subscription during combat.
|
||||
const latestLogQuery = [where('encounterPath', '==', encounterPath), LOG_ORDER, limit(25)];
|
||||
const findUndoTarget = async (redo = false) => {
|
||||
const rows = (await storage.getCollection(getPath.logs(), latestLogQuery) || [])
|
||||
.filter(l => l.undo || l.undo_payload);
|
||||
if (!redo) return rows.find(l => !l.undone) || null;
|
||||
|
||||
// Redo stack = contiguous undone entries at top of log (newest first).
|
||||
// If A3 then A2 undone, redo must replay A2 first, then A3.
|
||||
const undoneBlock = [];
|
||||
for (const row of rows) {
|
||||
if (!row.undone) break;
|
||||
undoneBlock.push(row);
|
||||
}
|
||||
return undoneBlock.length ? undoneBlock[undoneBlock.length - 1] : null;
|
||||
};
|
||||
|
||||
const handleUndo = async () => {
|
||||
if (!db || !undoTarget) return;
|
||||
if (!db) return;
|
||||
try {
|
||||
await storage.updateDoc(undoTarget.undo.encounterPath, undoTarget.undo.updates);
|
||||
await storage.updateDoc(`${getPath.logs()}/${undoTarget.id}`, { undone: true });
|
||||
if (storage.undo) {
|
||||
const undoTarget = await findUndoTarget(false);
|
||||
if (!undoTarget) { showToast('No action to undo.'); return; }
|
||||
const expanded = undoTarget.undo
|
||||
? expandUndo(undoTarget, encounter) // lean: build patch from current enc
|
||||
: getUndo(undoTarget); // legacy
|
||||
if (!expanded) return;
|
||||
await storage.undo({ logPath: `${getPath.logs()}/${undoTarget.id}`, undo: expanded });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error undoing action:', err);
|
||||
showToast('Failed to undo. The encounter may have changed or no longer exists.');
|
||||
@@ -1709,10 +1691,17 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
};
|
||||
|
||||
const handleRedo = async () => {
|
||||
if (!db || !redoTarget) return;
|
||||
if (!db) return;
|
||||
try {
|
||||
await storage.updateDoc(redoTarget.undo.encounterPath, redoTarget.undo.redo);
|
||||
await storage.updateDoc(`${getPath.logs()}/${redoTarget.id}`, { undone: false });
|
||||
if (storage.undo) {
|
||||
const redoTarget = await findUndoTarget(true);
|
||||
if (!redoTarget) { showToast('No action to redo.'); return; }
|
||||
const expanded = redoTarget.undo
|
||||
? expandUndo(redoTarget, encounter)
|
||||
: getUndo(redoTarget);
|
||||
if (!expanded) return;
|
||||
await storage.undo({ logPath: `${getPath.logs()}/${redoTarget.id}`, undo: expanded, redo: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error redoing action:', err);
|
||||
showToast('Failed to redo.');
|
||||
@@ -1722,7 +1711,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
const handleToggleHidePlayerHp = async () => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await storage.setDoc(getPath.activeDisplay(), combatToggleHidePlayerHp(hidePlayerHp).patch, { merge: true });
|
||||
await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true });
|
||||
} catch (err) {
|
||||
console.error("Error toggling hidePlayerHp:", err);
|
||||
}
|
||||
@@ -1743,14 +1732,8 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { patch, log } = startEncounter(encounter);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
await storage.setDoc(getPath.activeDisplay(), activateDisplay({ campaignId, encounterId: encounter.id }).patch, { merge: true });
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
await startEncounter(encounter, buildCtx(encounterPath));
|
||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
|
||||
} catch (err) {
|
||||
showToast(err.message || "Failed to start encounter. Please try again."); }
|
||||
};
|
||||
@@ -1758,13 +1741,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
const handleTogglePause = async () => {
|
||||
if (!db || !encounter || !encounter.isStarted) return;
|
||||
try {
|
||||
const { patch, log } = togglePause(encounter);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
await togglePause(encounter, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
// fall through silently
|
||||
}
|
||||
@@ -1775,13 +1752,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const { patch, log } = nextTurn(encounter);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
await nextTurn(encounter, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
// nextTurn throws if no active participants — auto-end combat
|
||||
if (err.message === 'Encounter not running.' || err.message === 'No active turn.') return;
|
||||
@@ -1797,14 +1768,8 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
const confirmEndEncounter = async () => {
|
||||
if (!db) return;
|
||||
try {
|
||||
const { patch, log } = endEncounter(encounter);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true });
|
||||
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
redo: patch,
|
||||
});
|
||||
await endEncounter(encounter, buildCtx(encounterPath));
|
||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
||||
} catch (err) {
|
||||
showToast("Failed to end encounter. Please try again."); }
|
||||
|
||||
@@ -1863,21 +1828,19 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Undo / Redo — always available when encounter open */}
|
||||
{/* Undo / Redo — queried on click (no live logs subscription during combat) */}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
disabled={!undoTarget}
|
||||
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-stone-700 hover:bg-stone-600 rounded-md transition-colors flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={undoTarget ? `Undo: ${undoTarget.message}` : 'Nothing to undo'}
|
||||
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-amber-700 hover:bg-amber-600 rounded-md transition-colors flex items-center justify-center"
|
||||
title="Undo latest action for this encounter"
|
||||
>
|
||||
<Undo2 size={14} className="mr-1" /> Undo
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRedo}
|
||||
disabled={!redoTarget}
|
||||
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-stone-700 hover:bg-stone-600 rounded-md transition-colors flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={redoTarget ? `Redo: ${redoTarget.message}` : 'Nothing to redo'}
|
||||
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-sky-700 hover:bg-sky-600 rounded-md transition-colors flex items-center justify-center"
|
||||
title="Redo latest undone action for this encounter"
|
||||
>
|
||||
<Redo2 size={14} className="mr-1" /> Redo
|
||||
</button>
|
||||
@@ -1927,7 +1890,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
// ============================================================================
|
||||
|
||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
|
||||
const { showToast, showInfo } = useUIFeedback();
|
||||
const { showToast } = useUIFeedback();
|
||||
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
||||
campaignId ? getPath.encounters(campaignId) : null
|
||||
);
|
||||
@@ -2008,14 +1971,28 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
const encounterId = itemToDelete.id;
|
||||
|
||||
try {
|
||||
await storage.deleteDoc(getPath.encounter(campaignId, encounterId));
|
||||
// cascade: delete logs for this encounter
|
||||
const encPath = getPath.encounter(campaignId, encounterId);
|
||||
const allLogs = await storage.getCollection(getPath.logs());
|
||||
const encLogs = allLogs.filter(l =>
|
||||
(l.encounterId && l.encounterId === encounterId) ||
|
||||
(l.encounterPath && l.encounterPath.includes(encPath))
|
||||
);
|
||||
if (encLogs.length > 0) {
|
||||
await storage.batchWrite(encLogs.map(l => ({
|
||||
type: 'delete',
|
||||
path: `${getPath.logs()}/${l.id || l.path?.split('/').pop()}`,
|
||||
})));
|
||||
}
|
||||
|
||||
await storage.deleteDoc(encPath);
|
||||
|
||||
if (selectedEncounterId === encounterId) {
|
||||
setSelectedEncounterId(null);
|
||||
}
|
||||
|
||||
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
|
||||
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
|
||||
await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error deleting encounter:", err);
|
||||
@@ -2033,7 +2010,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
|
||||
|
||||
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
|
||||
await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true });
|
||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
||||
} else {
|
||||
await storage.setDoc(getPath.activeDisplay(), {
|
||||
activeCampaignId: campaignId,
|
||||
@@ -2172,7 +2149,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
// ============================================================================
|
||||
|
||||
function AdminView({ userId }) {
|
||||
const { showToast, showInfo } = useUIFeedback();
|
||||
const { showToast } = useUIFeedback();
|
||||
const { data: campaignsData, isLoading: isLoadingCampaigns, error: campaignsError } = useFirestoreCollection(
|
||||
getPath.campaigns()
|
||||
);
|
||||
@@ -2204,15 +2181,30 @@ function AdminView({ userId }) {
|
||||
})
|
||||
);
|
||||
|
||||
// newest first by createdAt (fallback to name for stable order).
|
||||
detailedCampaigns.sort((a, b) => {
|
||||
const at = a.createdAt || 0;
|
||||
const bt = b.createdAt || 0;
|
||||
if (at === bt) return (a.name || '').localeCompare(b.name || '');
|
||||
return bt - at;
|
||||
});
|
||||
|
||||
setCampaignsWithDetails(detailedCampaigns);
|
||||
};
|
||||
|
||||
fetchDetails();
|
||||
} else if (campaignsData) {
|
||||
const sorted = [...campaignsData].sort((a, b) => {
|
||||
const at = a.createdAt || 0;
|
||||
const bt = b.createdAt || 0;
|
||||
if (at === bt) return (a.name || '').localeCompare(b.name || '');
|
||||
return bt - at;
|
||||
});
|
||||
setCampaignsWithDetails(
|
||||
campaignsData.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 }))
|
||||
sorted.map(c => ({ ...c, characters: c.players || [], encounterCount: 0 }))
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [campaignsData]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2266,7 +2258,24 @@ function AdminView({ userId }) {
|
||||
const id = e.id || e.path?.split('/').pop();
|
||||
return { type: 'delete', path: `${encountersPath}/${id}` };
|
||||
});
|
||||
if (deleteOps.length > 0) await storage.batchWrite(deleteOps);
|
||||
|
||||
// cascade: delete logs for every encounter in this campaign
|
||||
const allLogs = await storage.getCollection(getPath.logs());
|
||||
const encPaths = new Set(encounters.map(e => `${encountersPath}/${e.id || e.path?.split('/').pop()}`));
|
||||
const encIds = new Set(encounters.map(e => e.id || e.path?.split('/').pop()));
|
||||
const logOps = allLogs
|
||||
.filter(l =>
|
||||
(l.encounterId && encIds.has(l.encounterId)) ||
|
||||
(l.encounterPath && [...encPaths].some(p => l.encounterPath.includes(p)))
|
||||
)
|
||||
.map(l => ({
|
||||
type: 'delete',
|
||||
path: `${getPath.logs()}/${l.id || l.path?.split('/').pop()}`,
|
||||
}));
|
||||
|
||||
if (deleteOps.length > 0 || logOps.length > 0) {
|
||||
await storage.batchWrite([...deleteOps, ...logOps]);
|
||||
}
|
||||
|
||||
await storage.deleteDoc(getPath.campaign(campaignId));
|
||||
|
||||
@@ -2277,7 +2286,7 @@ function AdminView({ userId }) {
|
||||
const activeDisplay = await storage.getDoc(getPath.activeDisplay());
|
||||
|
||||
if (activeDisplay && activeDisplay.activeCampaignId === campaignId) {
|
||||
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
|
||||
await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error deleting campaign:", err);
|
||||
@@ -2740,9 +2749,143 @@ function DisplayView() {
|
||||
|
||||
function LogsView() {
|
||||
const { showToast } = useUIFeedback();
|
||||
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
|
||||
const [page, setPage] = useState(0);
|
||||
const pageQuery = useMemo(() => [LOG_ORDER, limit(LOG_PAGE_SIZE), offset(page * LOG_PAGE_SIZE)], [page]);
|
||||
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), pageQuery);
|
||||
const { data: campaignsData } = useFirestoreCollection(getPath.campaigns());
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [undoingId, setUndoingId] = useState(null);
|
||||
const [dlMenu, setDlMenu] = useState(false);
|
||||
const [cpMenu, setCpMenu] = useState(false);
|
||||
|
||||
// Full typed-log set for download/copy dropdown lists. Page query (LOG_QUERY,
|
||||
// 100/page) only sees current page, so the encounter list was truncated.
|
||||
// Lazy-fetch on first menu open; reused after. Server getCollection with no
|
||||
// constraint returns all docs, so this is the full set (not page-limited).
|
||||
// Ref guard (not state): StrictMode double-mount sets state then unmounts
|
||||
// before fetch resolves; state-based loading flag dead-locks the remount.
|
||||
const [allLogs, setAllLogs] = useState(null);
|
||||
const allLogsFetched = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!dlMenu && !cpMenu) return;
|
||||
if (allLogs || allLogsFetched.current) return;
|
||||
allLogsFetched.current = true;
|
||||
let cancelled = false;
|
||||
getStorage().getCollection(getPath.logs())
|
||||
.then(docs => { if (!cancelled) setAllLogs(docs.filter(l => l.type)); })
|
||||
.catch(err => { console.error('Error loading all logs:', err); allLogsFetched.current = false; })
|
||||
.finally(() => { if (cancelled) allLogsFetched.current = false; });
|
||||
return () => { cancelled = true; };
|
||||
}, [dlMenu, cpMenu, allLogs]);
|
||||
|
||||
// Total log count (separate cheap server count query, not full fetch).
|
||||
const [totalCount, setTotalCount] = useState(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStorage().countCollection(getPath.logs())
|
||||
.then(n => { if (!cancelled) setTotalCount(n); })
|
||||
.catch(() => { if (!cancelled) setTotalCount(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [logs]);
|
||||
|
||||
const shown = (logs || []).length;
|
||||
const rangeStart = totalCount ? page * LOG_PAGE_SIZE + 1 : 0;
|
||||
const rangeEnd = totalCount ? Math.min((page + 1) * LOG_PAGE_SIZE, totalCount) : 0;
|
||||
const hasPrev = page > 0;
|
||||
const hasNext = totalCount === null ? shown === LOG_PAGE_SIZE : rangeEnd < totalCount;
|
||||
|
||||
// Parse campaign id out of encounterPath (.../campaigns/{cid}/encounters/{eid}).
|
||||
const cidFromPath = (p) => {
|
||||
if (!p) return null;
|
||||
const m = String(p).match(/campaigns\/([^/]+)\/encounters/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
const campaignNameById = new Map(
|
||||
(campaignsData || []).map(c => [c.id, c.name || '(unnamed campaign)'])
|
||||
);
|
||||
|
||||
// Group typed logs by encounter. Each item shows campaign · encounter ·
|
||||
// last-entry date · entry count. Sorted newest first by last-entry ts.
|
||||
// Legacy logs (no type) excluded — kept for human scroll only.
|
||||
const encounterGroups = (() => {
|
||||
const src = allLogs ?? (logs || []);
|
||||
const map = new Map(); // key: encounterId || encounterPath || 'none'
|
||||
for (const l of src) {
|
||||
if (!l.type) continue;
|
||||
const key = l.encounterId || l.encounterPath || 'none';
|
||||
const cid = cidFromPath(l.encounterPath);
|
||||
const campName = cid ? (campaignNameById.get(cid) || '(unknown campaign)') : '(unknown campaign)';
|
||||
const g = map.get(key) || {
|
||||
key,
|
||||
encounter: l.encounterName || '(unnamed encounter)',
|
||||
campaign: campName,
|
||||
count: 0,
|
||||
latest: 0,
|
||||
};
|
||||
g.count++;
|
||||
g.latest = Math.max(g.latest, l.ts || l.timestamp || 0);
|
||||
map.set(key, g);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.latest - a.latest);
|
||||
})();
|
||||
|
||||
const typedCount = encounterGroups.reduce((n, g) => n + g.count, 0);
|
||||
const fmtDate = (ts) => ts ? new Date(ts).toLocaleString() : '—';
|
||||
// Filename-safe human date: 2026-07-04_15-30
|
||||
const fileDate = (ts) => ts
|
||||
? new Date(ts).toISOString().slice(0,16).replace('T', '_').replace(':', '-')
|
||||
: new Date().toISOString().slice(0,16).replace('T', '_').replace(':', '-');
|
||||
const dlFileName = (g) => g
|
||||
? `ttrpg-logs-${(g.campaign + '_' + g.encounter).replace(/[^a-z0-9]+/gi, '_')}-${fileDate(g.latest)}.json`
|
||||
: `ttrpg-logs-all-${fileDate(Date.now())}.json`;
|
||||
|
||||
const handleDownloadFor = async (encKey) => {
|
||||
setDlMenu(false);
|
||||
try {
|
||||
const all = await storage.getCollection(getPath.logs());
|
||||
const filtered = encKey
|
||||
? all.filter(l => (l.encounterId || l.encounterPath || 'none') === encKey)
|
||||
: all;
|
||||
const json = shared.logEvent.serializeEvents(filtered);
|
||||
if (json === '[]') {
|
||||
showToast('No typed log entries for that selection.');
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const grp = encKey ? encounterGroups.find(g => g.key === encKey) : null;
|
||||
a.download = dlFileName(grp);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Error downloading logs:', err);
|
||||
showToast('Failed to download logs.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyFor = async (encKey) => {
|
||||
setCpMenu(false);
|
||||
try {
|
||||
const all = await storage.getCollection(getPath.logs());
|
||||
const filtered = encKey
|
||||
? all.filter(l => (l.encounterId || l.encounterPath || 'none') === encKey)
|
||||
: all;
|
||||
const json = shared.logEvent.serializeEvents(filtered);
|
||||
if (json === '[]') {
|
||||
showToast('No typed log entries for that selection.');
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(json);
|
||||
showToast('Logs copied to clipboard.');
|
||||
} catch (err) {
|
||||
console.error('Error copying logs:', err);
|
||||
showToast('Failed to copy logs.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
try {
|
||||
@@ -2761,11 +2904,16 @@ function LogsView() {
|
||||
};
|
||||
|
||||
const handleUndo = async (entry) => {
|
||||
if (!db || !entry.undo) return;
|
||||
if (!db) return;
|
||||
setUndoingId(entry.id);
|
||||
try {
|
||||
await storage.updateDoc(entry.undo.encounterPath, entry.undo.updates);
|
||||
await storage.updateDoc(`${getPath.logs()}/${entry.id}`, { undone: true });
|
||||
if (storage.undo) {
|
||||
const expanded = entry.undo
|
||||
? expandUndo(entry, await storage.getDoc(entry.encounterPath))
|
||||
: getUndo(entry);
|
||||
if (!expanded) { setUndoingId(null); return; }
|
||||
await storage.undo({ logPath: `${getPath.logs()}/${entry.id}`, undo: expanded });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error undoing action:', err);
|
||||
showToast("Failed to roll back. The encounter may have changed or no longer exists."); }
|
||||
@@ -2784,9 +2932,75 @@ function LogsView() {
|
||||
>
|
||||
← Back to Tracker
|
||||
</a>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { setDlMenu(v => !v); setCpMenu(false); }}
|
||||
disabled={isLoading || typedCount === 0}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Download ▾
|
||||
</button>
|
||||
{dlMenu && (
|
||||
<div className="absolute right-0 mt-1 w-80 bg-stone-800 border border-stone-700 rounded-md shadow-xl z-20 py-1 max-h-96 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => handleDownloadFor(null)}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-stone-100 hover:bg-stone-700"
|
||||
>
|
||||
All encounters ({typedCount})
|
||||
</button>
|
||||
{encounterGroups.length > 0 && (
|
||||
<div className="border-t border-stone-700 my-1" />
|
||||
)}
|
||||
{encounterGroups.map(g => (
|
||||
<button
|
||||
key={g.key}
|
||||
onClick={() => handleDownloadFor(g.key)}
|
||||
className="block w-full text-left px-3 py-2 text-sm text-stone-100 hover:bg-stone-700"
|
||||
>
|
||||
<div className="font-medium truncate">{g.encounter}</div>
|
||||
<div className="text-xs text-stone-400 truncate">{g.campaign}</div>
|
||||
<div className="text-xs text-stone-500">{fmtDate(g.latest)} · {g.count} entries</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { setCpMenu(v => !v); setDlMenu(false); }}
|
||||
disabled={isLoading || typedCount === 0}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Copy ▾
|
||||
</button>
|
||||
{cpMenu && (
|
||||
<div className="absolute right-0 mt-1 w-80 bg-stone-800 border border-stone-700 rounded-md shadow-xl z-20 py-1 max-h-96 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => handleCopyFor(null)}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-stone-100 hover:bg-stone-700"
|
||||
>
|
||||
All encounters ({typedCount})
|
||||
</button>
|
||||
{encounterGroups.length > 0 && (
|
||||
<div className="border-t border-stone-700 my-1" />
|
||||
)}
|
||||
{encounterGroups.map(g => (
|
||||
<button
|
||||
key={g.key}
|
||||
onClick={() => handleCopyFor(g.key)}
|
||||
className="block w-full text-left px-3 py-2 text-sm text-stone-100 hover:bg-stone-700"
|
||||
>
|
||||
<div className="font-medium truncate">{g.encounter}</div>
|
||||
<div className="text-xs text-stone-400 truncate">{g.campaign}</div>
|
||||
<div className="text-xs text-stone-500">{fmtDate(g.latest)} · {g.count} entries</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
disabled={isLoading || logs.length === 0}
|
||||
disabled={isLoading || (logs || []).length === 0}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-red-800 hover:bg-red-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Clear Log
|
||||
@@ -2802,7 +3016,30 @@ function LogsView() {
|
||||
<p className="text-stone-400 text-center mt-12 text-lg">No log entries yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-stone-500 text-sm mb-4">{logs.length} entries — newest first</p>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-stone-500 text-sm">
|
||||
{totalCount === null
|
||||
? `${shown} entries — newest first`
|
||||
: `Showing ${rangeStart}–${rangeEnd} of ${totalCount}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={!hasPrev}
|
||||
className="px-3 py-1 rounded text-sm bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-stone-500 text-sm">Page {page + 1}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={!hasNext}
|
||||
className="px-3 py-1 rounded text-sm bg-stone-700 hover:bg-stone-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 max-w-5xl">
|
||||
{logs.map(entry => (
|
||||
<div
|
||||
@@ -2814,7 +3051,7 @@ function LogsView() {
|
||||
}`}
|
||||
>
|
||||
<span className="text-stone-500 whitespace-nowrap font-mono shrink-0">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
{fmtDate(entry.ts || entry.timestamp)}
|
||||
</span>
|
||||
{entry.encounterName && (
|
||||
<span className="text-amber-600 whitespace-nowrap shrink-0">[{entry.encounterName}]</span>
|
||||
@@ -2824,7 +3061,7 @@ function LogsView() {
|
||||
</span>
|
||||
{entry.undone ? (
|
||||
<span className="shrink-0 text-xs text-stone-600 italic">rolled back</span>
|
||||
) : entry.undo ? (
|
||||
) : getUndo(entry) ? (
|
||||
<button
|
||||
onClick={() => handleUndo(entry)}
|
||||
disabled={undoingId === entry.id}
|
||||
|
||||
@@ -71,6 +71,12 @@ export async function getDocs(collRefOrQuery) {
|
||||
return { docs: docs.map(d => ({ id: d.id, data: () => d.data, ref: { path: `${collPath}/${d.id}` } })) };
|
||||
}
|
||||
|
||||
export async function getCountFromServer(collRefOrQuery) {
|
||||
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
|
||||
const docs = MOCK_DB.collection(collPath);
|
||||
return { data: () => ({ count: docs.length }) };
|
||||
}
|
||||
|
||||
// realtime — emit from mock DB, capture unsub
|
||||
export function onSnapshot(refOrQuery, onSuccess, onError) {
|
||||
const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path);
|
||||
|
||||
+30
-2
@@ -6,8 +6,6 @@
|
||||
// const { runStorageContract } = require('./contract.test');
|
||||
// runStorageContract('memory', () => createMemoryStorage());
|
||||
|
||||
'use strict';
|
||||
|
||||
// Each impl factory returns a fresh storage instance (async-creatable is fine).
|
||||
// Interface every impl MUST provide:
|
||||
// getDoc(path) -> Promise<obj|null>
|
||||
@@ -17,6 +15,7 @@
|
||||
// addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id)
|
||||
// getCollection(path) -> Promise<arr> (immediate child docs)
|
||||
// batchWrite(ops) -> Promise<void> ops: [{type, path, data?}]
|
||||
// undo({logPath, undo, redo}) -> Promise<void> tx: apply updates + flip undone
|
||||
// subscribeDoc(path, cb) -> unsubscribe fn cb(doc|null)
|
||||
// subscribeCollection(path, cb) -> unsubscribe fn cb(arr)
|
||||
|
||||
@@ -181,6 +180,35 @@ function runStorageContract(name, factory) {
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo', () => {
|
||||
test('undo applies updates + flips undone true', async () => {
|
||||
await storage.setDoc('encounters/e1', { round: 2, name: 'Enc' });
|
||||
await storage.setDoc('logs/l1', { message: 'x', undone: false });
|
||||
await storage.undo({
|
||||
logPath: 'logs/l1',
|
||||
undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
|
||||
});
|
||||
const enc = await storage.getDoc('encounters/e1');
|
||||
const log = await storage.getDoc('logs/l1');
|
||||
expect(enc.round).toBe(1);
|
||||
expect(log.undone).toBe(true);
|
||||
});
|
||||
|
||||
test('redo applies redo patch + flips undone false', async () => {
|
||||
await storage.setDoc('encounters/e1', { round: 1 });
|
||||
await storage.setDoc('logs/l1', { message: 'x', undone: true });
|
||||
await storage.undo({
|
||||
logPath: 'logs/l1',
|
||||
undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
|
||||
redo: true,
|
||||
});
|
||||
const enc = await storage.getDoc('encounters/e1');
|
||||
const log = await storage.getDoc('logs/l1');
|
||||
expect(enc.round).toBe(2);
|
||||
expect(log.undone).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeDoc', () => {
|
||||
test('fires cb immediately with current value', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||
|
||||
+30
-6
@@ -4,13 +4,11 @@
|
||||
// App.js imports SDK via this module (doc, setDoc, etc re-exported) for both
|
||||
// the adapter (createFirebaseStorage) and direct SDK calls. ONE import path.
|
||||
|
||||
'use strict';
|
||||
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import {
|
||||
getFirestore, doc, setDoc, getDoc as getDocReal, getDocs as getDocsReal, addDoc, collection,
|
||||
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, serverTimestamp,
|
||||
onSnapshot, updateDoc, deleteDoc, query, where, orderBy, limit, writeBatch, getCountFromServer,
|
||||
} from 'firebase/firestore';
|
||||
|
||||
// Adapter call recorder (instrumentation, no behavior change).
|
||||
@@ -102,11 +100,26 @@ export function createFirebaseStorage() {
|
||||
return { id: ref.id, path: `${collectionPath}/${ref.id}` };
|
||||
},
|
||||
|
||||
async getCollection(collectionPath) {
|
||||
const snapshot = await getDocsReal(collection(db, collectionPath));
|
||||
async getCollection(collectionPath, queryConstraints = []) {
|
||||
// Translate neutral {__type} constraints to firebase SDK builders.
|
||||
const fbConstraints = [];
|
||||
for (const c of queryConstraints || []) {
|
||||
if (!c || !c.__type) continue;
|
||||
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
|
||||
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
|
||||
else if (c.__type === 'limit') fbConstraints.push(limit(c.n));
|
||||
// firebase SDK has no offset; skip (UI uses server adapter in dev).
|
||||
}
|
||||
const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath);
|
||||
const snapshot = await getDocsReal(q);
|
||||
return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
|
||||
},
|
||||
|
||||
async countCollection(collectionPath) {
|
||||
const snapshot = await getCountFromServer(collection(db, collectionPath));
|
||||
return snapshot.data().count;
|
||||
},
|
||||
|
||||
async batchWrite(ops) {
|
||||
const batch = writeBatch(db);
|
||||
for (const op of ops) {
|
||||
@@ -117,6 +130,16 @@ export function createFirebaseStorage() {
|
||||
await batch.commit();
|
||||
},
|
||||
|
||||
// Transactional undo via batch (atomic in firestore). Apply updates +
|
||||
// flip undone flag in single commit.
|
||||
async undo({ logPath, undo, redo = false }) {
|
||||
const batch = writeBatch(db);
|
||||
const updates = redo ? (undo.redo || undo.updates) : undo.updates;
|
||||
batch.update(doc(db, undo.encounterPath), updates);
|
||||
batch.update(doc(db, logPath), { undone: !redo });
|
||||
await batch.commit();
|
||||
},
|
||||
|
||||
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
|
||||
subscribeDoc(path, cb, errCb) {
|
||||
recordAdapterCall({ fn: 'subscribeDoc', path });
|
||||
@@ -133,6 +156,7 @@ export function createFirebaseStorage() {
|
||||
// queryConstraints = neutral {__type} builders (from index.js).
|
||||
// Translate to SDK orderBy/limit.
|
||||
const sdkConstraints = queryConstraints.map(c => {
|
||||
if (c.__type === 'where') return where(c.field, c.op, c.value);
|
||||
if (c.__type === 'orderBy') return orderBy(c.field, c.dir);
|
||||
if (c.__type === 'limit') return limit(c.n);
|
||||
return c; // pass-through (forward compat)
|
||||
|
||||
@@ -43,8 +43,10 @@ export function getStorageMode() {
|
||||
// raw SDK), so both adapters read the same shape. firebase adapter translates
|
||||
// neutral -> SDK; server adapter applies client-side. Combat log uses these:
|
||||
// [orderBy('timestamp','desc'), limit(500)]
|
||||
export function where(field, op, value) { return { __type: 'where', field, op, value }; }
|
||||
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
|
||||
export function limit(n) { return { __type: 'limit', n }; }
|
||||
export function offset(n) { return { __type: 'offset', offset: n }; }
|
||||
|
||||
export {
|
||||
initializeApp,
|
||||
|
||||
+68
-43
@@ -2,8 +2,6 @@
|
||||
// Passthrough: no shape translation. Backend = firebase mirror.
|
||||
// Implements same interface as memory.js. Tested by storage contract vs running server.
|
||||
|
||||
'use strict';
|
||||
|
||||
// Native browser WebSocket if present, else ws pkg (Node/jest).
|
||||
// Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require().
|
||||
let WebSocketImpl;
|
||||
@@ -42,30 +40,13 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
// Apply neutral {__type} query constraints client-side. Backend returns all
|
||||
// docs; adapter sorts/limits. Mirrors firebase mock applyConstraints.
|
||||
function applyConstraints(docs, constraints) {
|
||||
let out = [...docs];
|
||||
for (const c of constraints || []) {
|
||||
if (!c || !c.__type) continue;
|
||||
if (c.__type === 'orderBy') {
|
||||
out.sort((a, b) => {
|
||||
const av = a[c.field];
|
||||
const bv = b[c.field];
|
||||
if (av === bv) return 0;
|
||||
const cmp = av > bv ? 1 : -1;
|
||||
return c.dir === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
} else if (c.__type === 'limit') {
|
||||
out = out.slice(0, c.n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const docSubs = new Map(); // path -> Set<cb>
|
||||
const collSubs = new Map(); // collPath -> Set<cb>
|
||||
const collConstraints = new Map(); // collPath -> constraints[] (per collection)
|
||||
// Last data emitted per path. Dedupe identical re-emits so WS-open
|
||||
// re-fetch (authoritative catch-up) doesn't double-fire when REST already
|
||||
// delivered the same data. Race: REST empty → WS-open populated fires.
|
||||
const lastEmitted = new Map(); // path -> serialized data (or null for empty)
|
||||
let ws = null;
|
||||
let wsReady = null;
|
||||
|
||||
@@ -74,6 +55,16 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
let everConnected = false;
|
||||
const RECONNECT_DELAY = 500;
|
||||
|
||||
// Emit helper: dedupe identical re-emits per path. WS-open re-fetch
|
||||
// (authoritative catch-up) + initial REST both may fire; skip dup.
|
||||
function emit(kind, path, data, cbs) {
|
||||
const key = `${kind}:${path}`;
|
||||
const ser = data === undefined ? 'undef' : JSON.stringify(data);
|
||||
if (lastEmitted.get(key) === ser) return; // identical, skip
|
||||
lastEmitted.set(key, ser);
|
||||
cbs.forEach(cb => cb(data));
|
||||
}
|
||||
|
||||
function ensureWs() {
|
||||
if (wsReady) return wsReady;
|
||||
wsReady = new Promise((resolve, reject) => {
|
||||
@@ -96,7 +87,7 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
for (const p of collSubs.keys()) {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
||||
}
|
||||
// On RECONNECT only: re-fetch current values — catches writes that
|
||||
// Reconnect only: re-fetch current values — catches writes that
|
||||
// happened while disconnected (broadcast missed). Skip on first connect
|
||||
// (initial REST fetch in subscribeDoc/subscribeCollection already did).
|
||||
if (isReconnect) {
|
||||
@@ -104,7 +95,8 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
storage.getDoc(p).then(doc => { cbs.forEach(cb => cb(doc)); }).catch(() => {});
|
||||
}
|
||||
for (const [p, cbs] of collSubs) {
|
||||
storage.getCollection(p).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
|
||||
const cstr = collConstraints.get(p) || [];
|
||||
storage.getCollection(p, cstr).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
|
||||
}
|
||||
}
|
||||
resolve(ws);
|
||||
@@ -127,16 +119,14 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
|
||||
handleMessage(msg);
|
||||
};
|
||||
// Bind ONCE. Browser WS + ws pkg are EventTargets: assigning onX AND
|
||||
// addEventListener('x') registers the handler twice -> every event
|
||||
// fires 2x (double onOpen -> spurious reconnect re-fetch, double
|
||||
// onMessage -> emit dedupe races under write load -> UI looks stale).
|
||||
ws.onopen = onOpen;
|
||||
ws.onerror = onError;
|
||||
ws.onclose = onClose;
|
||||
ws.onmessage = onMessage;
|
||||
if (typeof ws.addEventListener === 'function') {
|
||||
ws.addEventListener('open', onOpen);
|
||||
ws.addEventListener('error', onError);
|
||||
ws.addEventListener('close', onClose);
|
||||
ws.addEventListener('message', onMessage);
|
||||
}
|
||||
})();
|
||||
});
|
||||
return wsReady;
|
||||
@@ -144,7 +134,9 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
|
||||
// Backend pushes change notices keyed by path. Re-fetch affected subscribers.
|
||||
async function handleMessage(msg) {
|
||||
if (msg.type !== 'change' || !msg.change) return;
|
||||
if (msg.type !== 'change' || !msg.change) {
|
||||
return;
|
||||
}
|
||||
const c = msg.change;
|
||||
// doc subscriber at exact changed path
|
||||
const docCbs = docSubs.get(c.path);
|
||||
@@ -157,7 +149,8 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
const collCbs = collSubs.get(c.parent);
|
||||
if (collCbs) {
|
||||
const constraints = collConstraints.get(c.parent) || [];
|
||||
const docs = applyConstraints(await storage.getCollection(c.parent), constraints);
|
||||
// Server honors orderBy + limit in SQL now.
|
||||
const docs = await storage.getCollection(c.parent, constraints);
|
||||
collCbs.forEach(cb => cb(docs));
|
||||
}
|
||||
}
|
||||
@@ -217,11 +210,27 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
return { id: res.id, path: res.path };
|
||||
},
|
||||
|
||||
async getCollection(rawCollPath) {
|
||||
async getCollection(rawCollPath, queryConstraints = []) {
|
||||
const p = norm(rawCollPath);
|
||||
const docs = await api('GET', '/api/collection', { path: p });
|
||||
// Backend returns array of { id, data } OR bare data[]; normalize to
|
||||
// { id, ...data } (firebase truth).
|
||||
const query = { path: p };
|
||||
// Push where + orderBy + limit + offset to server SQL when present.
|
||||
// Avoids client-side full-scan + re-sort on every WS change notification.
|
||||
for (const c of queryConstraints || []) {
|
||||
if (!c || !c.__type) continue;
|
||||
if (c.__type === 'where') {
|
||||
query.whereField = c.field;
|
||||
query.whereOp = c.op;
|
||||
query.whereValue = c.value;
|
||||
} else if (c.__type === 'orderBy') {
|
||||
query.orderBy = c.field;
|
||||
query.dir = c.dir || 'asc';
|
||||
} else if (c.__type === 'limit') {
|
||||
query.limit = c.n;
|
||||
} else if (c.__type === 'offset') {
|
||||
query.offset = c.offset;
|
||||
}
|
||||
}
|
||||
const docs = await api('GET', '/api/collection', query);
|
||||
if (!Array.isArray(docs)) return [];
|
||||
return docs.map(d => {
|
||||
if (d && typeof d === 'object' && 'id' in d && 'data' in d) {
|
||||
@@ -231,15 +240,31 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
});
|
||||
},
|
||||
|
||||
async countCollection(rawCollPath) {
|
||||
const p = norm(rawCollPath);
|
||||
const res = await api('GET', '/api/collection/count', { path: p });
|
||||
return (res && typeof res.count === 'number') ? res.count : 0;
|
||||
},
|
||||
|
||||
async batchWrite(ops) {
|
||||
const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
|
||||
await api('POST', '/api/batch', null, { ops: normOps });
|
||||
},
|
||||
|
||||
// Transactional undo (server mode only). Firebase falls back to 2-write
|
||||
// in App.js (via store.txUndo check). undo = { encounterPath, updates, redo }.
|
||||
// redo=true applies redo patch + flips undone:false.
|
||||
async undo({ logPath, undo, redo = false }) {
|
||||
const normUndo = { ...undo, encounterPath: norm(undo.encounterPath) };
|
||||
const normLog = norm(logPath);
|
||||
await api('POST', '/api/undo', null, { logPath: normLog, undo: normUndo, redo });
|
||||
},
|
||||
|
||||
subscribeDoc(rawPath, cb, errCb) {
|
||||
const p = norm(rawPath);
|
||||
lastEmitted.delete('doc:'+p); // fresh sub → allow first emit
|
||||
// Initial value via REST (independent of WS connect). getDoc injects id.
|
||||
storage.getDoc(p).then(cb).catch(() => {});
|
||||
storage.getDoc(p).then(doc => emit('doc', p, doc, docSubs.get(p) || new Set([cb]))).catch(() => {});
|
||||
// WS only for subsequent change notifications.
|
||||
ensureWs().then(() => {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
|
||||
@@ -252,11 +277,11 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) {
|
||||
const p = norm(rawCollPath);
|
||||
collConstraints.set(p, queryConstraints || []);
|
||||
// Initial value via REST (independent of WS connect). Apply constraints
|
||||
// client-side — backend returns all docs, adapter sorts/limits.
|
||||
storage.getCollection(p)
|
||||
.then(docs => cb(applyConstraints(docs, queryConstraints || [])))
|
||||
.catch(() => {});
|
||||
lastEmitted.delete('coll:'+p); // fresh sub → allow first emit
|
||||
// Server honors orderBy + limit in SQL now. No client re-sort.
|
||||
storage.getCollection(p, queryConstraints || [])
|
||||
.then(docs => emit('coll', p, docs, collSubs.get(p) || new Set([cb])))
|
||||
.catch(err => { if (errCb) errCb(err); });
|
||||
// WS only for subsequent change notifications.
|
||||
ensureWs().then(() => {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
||||
|
||||
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
import { renderApp, createCampaignViaUI, selectCampaignByName } from './testHelpers';
|
||||
import { renderApp, createCampaignViaUI, selectCampaignByName, setupReady, startCombatViaUI, addMonsterViaUI } from './testHelpers';
|
||||
|
||||
function findCall(fn, pathSub) {
|
||||
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||
@@ -139,4 +139,53 @@ describe('Campaign -> Firebase', () => {
|
||||
const delCall = findCall('deleteDoc', `/campaigns/${cid}`);
|
||||
expect(delCall).toBeDefined();
|
||||
});
|
||||
|
||||
test('deleteCampaign: cascade-deletes logs for campaign encounters (BUG)', async () => {
|
||||
const { setupReady, startCombatViaUI } = require('./testHelpers');
|
||||
await setupReady('Doomed', 'Enc');
|
||||
await addMonsterViaUI('Gob', 5, 2);
|
||||
await startCombatViaUI();
|
||||
// log written on start
|
||||
await waitFor(() => {
|
||||
const logWrites = findCalls('addDoc').filter(c => c.path.includes('/logs'));
|
||||
expect(logWrites.length).toBeGreaterThan(0);
|
||||
});
|
||||
const cid = Object.keys(MOCK_DB.collection('campaigns').reduce((m,c)=>(m[c.id]=c,m),{}))[0];
|
||||
|
||||
// delete campaign
|
||||
const allDeletes = screen.getAllByText(/Delete/i);
|
||||
fireEvent.click(allDeletes[allDeletes.length - 1]);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
await waitFor(() => findCall('deleteDoc', `/campaigns/${cid}`));
|
||||
|
||||
// logs MUST be deleted via batch or deleteDoc on each log path
|
||||
const logDeletes = getCalls().filter(c =>
|
||||
(c.fn === 'deleteDoc' && c.path.includes('/logs/')) ||
|
||||
(c.fn === 'batch.delete' && c.path.includes('/logs/'))
|
||||
);
|
||||
expect(logDeletes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('deleteEncounter: cascade-deletes logs for encounter (BUG)', async () => {
|
||||
const { setupReady, startCombatViaUI } = require('./testHelpers');
|
||||
await setupReady('Camp', 'DoomedEnc');
|
||||
await addMonsterViaUI('Gob', 5, 2);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => {
|
||||
expect(findCalls('addDoc').filter(c => c.path.includes('/logs')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// delete encounter
|
||||
const allDeletes = screen.getAllByText(/Delete/i);
|
||||
fireEvent.click(allDeletes[0]);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const logDeletes = getCalls().filter(c =>
|
||||
(c.fn === 'deleteDoc' && c.path.includes('/logs/')) ||
|
||||
(c.fn === 'batch.delete' && c.path.includes('/logs/'))
|
||||
);
|
||||
expect(logDeletes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+119
-165
@@ -3,55 +3,41 @@
|
||||
//
|
||||
// Does 100 ROUNDS (not turns). A round = one full pass through initiative
|
||||
// (every active participant acts once); round counter increments at wrap.
|
||||
// See docs/GLOSSARY.md. Previous version lied: called nextTurn 100× (~100
|
||||
// turns, ~12 rounds). Now loops by actual round-wrap count.
|
||||
//
|
||||
// Exercises ALL combat paths deterministically (less random than replay):
|
||||
// Exercises ALL combat paths deterministically:
|
||||
// startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
|
||||
// updateParticipant, removeParticipant, toggleParticipantActive,
|
||||
// applyHpChange (damage/heal), deathSave (incl. 3-success → isDying),
|
||||
// toggleCondition, reorderParticipants (drag same-init tie), endEncounter,
|
||||
// activateDisplay, clearDisplay, toggleHidePlayerHp.
|
||||
// applyHpChange (damage/heal), deathSave, toggleCondition,
|
||||
// reorderParticipants, endEncounter.
|
||||
//
|
||||
// New API: funcs async, take ctx {storage, encPath, logPath, displayPath},
|
||||
// write encounter + log internally, return newEnc. Mock storage captures writes.
|
||||
// Display lifecycle now async too — but test keeps inline display writes (mirrors App.js).
|
||||
//
|
||||
// Failing assertions do NOT abort the run: each phase wrapped in try/catch,
|
||||
// failures collected, final expect reports all.
|
||||
|
||||
const path = require('path');
|
||||
const { createMockStorage, mockCtx, readyEnc } = require('../../shared/tests/_helpers');
|
||||
const {
|
||||
makeParticipant,
|
||||
buildCharacterParticipant,
|
||||
buildMonsterParticipant,
|
||||
startEncounter,
|
||||
nextTurn,
|
||||
togglePause,
|
||||
addParticipant,
|
||||
addParticipants,
|
||||
updateParticipant,
|
||||
removeParticipant,
|
||||
toggleParticipantActive,
|
||||
applyHpChange,
|
||||
deathSave,
|
||||
toggleCondition,
|
||||
reorderParticipants,
|
||||
endEncounter,
|
||||
activateDisplay,
|
||||
clearDisplay,
|
||||
toggleHidePlayerHp,
|
||||
} = require('../../shared');
|
||||
|
||||
// aliases for combat funcs that clash with local helper names
|
||||
const {
|
||||
applyHpChange: combatApplyHpChange,
|
||||
startEncounter, nextTurn, togglePause, endEncounter,
|
||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive: combatToggleActive,
|
||||
toggleCondition: combatToggleCondition,
|
||||
applyHpChange: combatApplyHpChange,
|
||||
deathSave: combatDeathSave,
|
||||
toggleCondition: combatToggleCondition,
|
||||
reorderParticipants,
|
||||
} = require('../../shared');
|
||||
|
||||
// ---------- scenario state (mirrors two firestore docs) ----------
|
||||
// ---------- scenario state ----------
|
||||
|
||||
const ROUNDS = 100;
|
||||
const ENCOUNTER_ID = 'enc-1';
|
||||
|
||||
// encounter doc
|
||||
let enc = {
|
||||
id: ENCOUNTER_ID,
|
||||
name: 'BigBoss',
|
||||
participants: [],
|
||||
isStarted: false,
|
||||
@@ -60,7 +46,7 @@ let enc = {
|
||||
currentTurnParticipantId: null,
|
||||
turnOrderIds: [],
|
||||
};
|
||||
// activeDisplay/status doc
|
||||
// display tracked via mock storage displayPath doc; simple local mirror.
|
||||
let display = {
|
||||
activeCampaignId: null,
|
||||
activeEncounterId: null,
|
||||
@@ -68,222 +54,200 @@ let display = {
|
||||
hideNpcHp: false,
|
||||
};
|
||||
const CAMPAIGN_ID = 'camp-1';
|
||||
const ENCOUNTER_ID = 'enc-1';
|
||||
const roster = [];
|
||||
|
||||
function applyEnc(patch) {
|
||||
if (!patch) return;
|
||||
for (const [k, v] of Object.entries(patch)) enc[k] = v;
|
||||
}
|
||||
function applyDisplay(patch) {
|
||||
if (!patch) return;
|
||||
for (const [k, v] of Object.entries(patch)) display[k] = v;
|
||||
}
|
||||
let storage, ctx;
|
||||
const DISPLAY_PATH = 'activeDisplay/status';
|
||||
|
||||
const RESULTS = [];
|
||||
function record(phase, fn) {
|
||||
try { fn(); RESULTS.push({ phase, ok: true }); }
|
||||
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
||||
}
|
||||
async function recordAsync(phase, fn) {
|
||||
async function record(phase, fn) {
|
||||
try { await fn(); RESULTS.push({ phase, ok: true }); }
|
||||
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
||||
}
|
||||
|
||||
// ---------- helpers (shared, no React) ----------
|
||||
// ---------- helpers ----------
|
||||
|
||||
const alive = (name) => enc.participants.find(p => p.name === name && p.currentHp > 0);
|
||||
const any = (name) => enc.participants.find(p => p.name === name);
|
||||
const idOf = (name) => { const p = any(name); return p ? p.id : null; };
|
||||
const anyById = (id) => enc.participants.find(p => p.id === id);
|
||||
|
||||
function addCharacterViaUI(name, maxHp, initMod) {
|
||||
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
|
||||
}
|
||||
function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
||||
|
||||
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
||||
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
|
||||
applyEnc(addParticipant(enc, participant).patch);
|
||||
enc = await addParticipant(enc, participant, ctx);
|
||||
}
|
||||
function addCharacterParticipant(charName) {
|
||||
async function addCharacterParticipant(charName) {
|
||||
const character = roster.find(c => c.name === charName);
|
||||
if (!character) throw new Error(`char not found: ${charName}`);
|
||||
if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) {
|
||||
throw new Error(`${charName} already in encounter`);
|
||||
}
|
||||
const { participant } = buildCharacterParticipant(character);
|
||||
applyEnc(addParticipant(enc, participant).patch);
|
||||
enc = await addParticipant(enc, participant, ctx);
|
||||
}
|
||||
function addAllCharacters() {
|
||||
async function addAllCharacters() {
|
||||
const toAdd = roster
|
||||
.filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id))
|
||||
.map(c => buildCharacterParticipant(c).participant);
|
||||
applyEnc(addParticipants(enc, toAdd).patch);
|
||||
enc = await addParticipants(enc, toAdd, ctx);
|
||||
}
|
||||
function startCombat() {
|
||||
applyEnc(startEncounter(enc).patch);
|
||||
applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch);
|
||||
async function startCombat() {
|
||||
enc = await startEncounter(enc, ctx);
|
||||
display.activeCampaignId = CAMPAIGN_ID;
|
||||
display.activeEncounterId = ENCOUNTER_ID;
|
||||
await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: CAMPAIGN_ID, activeEncounterId: ENCOUNTER_ID });
|
||||
}
|
||||
function endCombat() {
|
||||
applyEnc(endEncounter(enc).patch);
|
||||
applyDisplay(clearDisplay().patch);
|
||||
async function endCombat() {
|
||||
enc = await endEncounter(enc, ctx);
|
||||
display.activeCampaignId = null;
|
||||
display.activeEncounterId = null;
|
||||
await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: null, activeEncounterId: null });
|
||||
}
|
||||
function nextTurnAction() { applyEnc(nextTurn(enc).patch); }
|
||||
function pauseCombat() { applyEnc(togglePause(enc).patch); if (!enc.isPaused) throw new Error('not paused'); }
|
||||
function resumeCombat() { applyEnc(togglePause(enc).patch); if (enc.isPaused) throw new Error('not resumed'); }
|
||||
async function nextTurnAction() { enc = await nextTurn(enc, ctx); }
|
||||
async function pauseCombat() { enc = await togglePause(enc, ctx); if (!enc.isPaused) throw new Error('not paused'); }
|
||||
async function resumeCombat() { enc = await togglePause(enc, ctx); if (enc.isPaused) throw new Error('not resumed'); }
|
||||
|
||||
function applyDamage(name, amount) {
|
||||
async function applyDamage(name, amount) {
|
||||
const p = any(name);
|
||||
if (!p || p.currentHp <= 0) return; // dead = skip (button hidden in UI)
|
||||
applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch);
|
||||
if (!p || p.currentHp <= 0) return;
|
||||
enc = await combatApplyHpChange(enc, p.id, 'damage', amount, ctx);
|
||||
}
|
||||
function applyHeal(name, amount) {
|
||||
async function applyHeal(name, amount) {
|
||||
const p = any(name);
|
||||
if (!p) return; // removed (death) = skip (no button in UI)
|
||||
applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch);
|
||||
if (!p) return;
|
||||
enc = await combatApplyHpChange(enc, p.id, 'heal', amount, ctx);
|
||||
}
|
||||
function toggleActive(name) {
|
||||
async function toggleActive(name) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no active target: ${name}`);
|
||||
applyEnc(combatToggleActive(enc, p.id).patch);
|
||||
enc = await combatToggleActive(enc, p.id, ctx);
|
||||
}
|
||||
function toggleConditionAction(name, label) {
|
||||
async function toggleConditionAction(name, label) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no condition target: ${name}`);
|
||||
applyEnc(combatToggleCondition(enc, p.id, label).patch);
|
||||
enc = await combatToggleCondition(enc, p.id, label, ctx);
|
||||
}
|
||||
function editParticipant(name, patch) {
|
||||
async function editParticipant(name, patch) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no edit target: ${name}`);
|
||||
applyEnc(updateParticipant(enc, p.id, patch).patch);
|
||||
enc = await updateParticipant(enc, p.id, patch, ctx);
|
||||
}
|
||||
function removeParticipantByName(name) {
|
||||
async function removeParticipantByName(name) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no remove target: ${name}`);
|
||||
applyEnc(removeParticipant(enc, p.id).patch);
|
||||
enc = await removeParticipant(enc, p.id, ctx);
|
||||
}
|
||||
function deathSaveAction(name, type, saveNum) {
|
||||
async function deathSaveAction(name, type, saveNum) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no deathsave target: ${name}`);
|
||||
applyEnc(combatDeathSave(enc, p.id, type, saveNum).patch);
|
||||
const r = await combatDeathSave(enc, p.id, type, saveNum, ctx);
|
||||
enc = r.enc;
|
||||
}
|
||||
function dragTie(draggedName, targetName) {
|
||||
async function dragTie(draggedName, targetName) {
|
||||
const d = any(draggedName), t = any(targetName);
|
||||
if (!d || !t) throw new Error('drag target missing');
|
||||
applyEnc(reorderParticipants(enc, d.id, t.id).patch);
|
||||
enc = await reorderParticipants(enc, d.id, t.id, ctx);
|
||||
}
|
||||
function toggleHidePlayerHpAction() {
|
||||
applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch);
|
||||
async function toggleHidePlayerHpAction() {
|
||||
display.hidePlayerHp = !display.hidePlayerHp;
|
||||
await storage.updateDoc(DISPLAY_PATH, { hidePlayerHp: display.hidePlayerHp });
|
||||
}
|
||||
|
||||
// ---------- scenario ----------
|
||||
|
||||
beforeEach(() => {
|
||||
({ storage, ctx } = mockCtx());
|
||||
enc = {
|
||||
id: ENCOUNTER_ID, name: 'BigBoss', participants: [],
|
||||
isStarted: false, isPaused: false, round: 0,
|
||||
currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
display = { activeCampaignId: null, activeEncounterId: null, hidePlayerHp: true, hideNpcHp: false };
|
||||
roster.length = 0;
|
||||
RESULTS.length = 0;
|
||||
});
|
||||
|
||||
test('full 100-round combat scenario (shared, no React)', async () => {
|
||||
// roster
|
||||
await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
||||
await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
||||
await recordAsync('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3));
|
||||
await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
||||
await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
||||
await record('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3));
|
||||
|
||||
// monsters + npcs
|
||||
await recordAsync('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
|
||||
await recordAsync('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
|
||||
await recordAsync('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
|
||||
await recordAsync('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
|
||||
await recordAsync('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
|
||||
await record('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
|
||||
await record('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
|
||||
await record('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
|
||||
await record('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
|
||||
await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
|
||||
|
||||
// add chars into encounter
|
||||
await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
|
||||
await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
|
||||
await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
|
||||
await recordAsync('addAllChars', () => addAllCharacters()); // bulk add path
|
||||
await record('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
|
||||
await record('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
|
||||
await record('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
|
||||
await record('addAllChars', () => addAllCharacters());
|
||||
|
||||
// hidden hp toggle x2 (back to default)
|
||||
record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
|
||||
record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
|
||||
await record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
|
||||
await record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
|
||||
|
||||
await recordAsync('startCombat', () => startCombat());
|
||||
await record('startCombat', () => startCombat());
|
||||
|
||||
// ---------- 100 ROUNDS (loop by actual round-wrap count) ----------
|
||||
// ---------- 100 ROUNDS ----------
|
||||
let roundsDone = 0;
|
||||
let prevRound = enc.round;
|
||||
let turnInRound = 0;
|
||||
let turnTotal = 0;
|
||||
|
||||
while (roundsDone < ROUNDS) {
|
||||
turnTotal++;
|
||||
const actor = anyById(enc.currentTurnParticipantId);
|
||||
const r = enc.round;
|
||||
|
||||
// per-turn deterministic actions keyed by round + actor type
|
||||
// damage OrcBoss every even round
|
||||
if (r % 2 === 0 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
||||
}
|
||||
// heal Cleric every 3 rounds
|
||||
if (r % 3 === 0 && turnInRound === 1) {
|
||||
record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
||||
}
|
||||
// condition on Fighter every 5 rounds
|
||||
if (r % 5 === 0 && turnInRound === 2) {
|
||||
record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
|
||||
}
|
||||
// toggleActive Goblin2 every 7 rounds (toggle off, next 7 toggle on)
|
||||
if (r % 7 === 0 && turnInRound === 3) {
|
||||
record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
||||
}
|
||||
// edit Wolf initiative every 13 rounds
|
||||
if (r % 13 === 0 && turnInRound === 4) {
|
||||
record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
||||
}
|
||||
if (r % 2 === 0 && turnInRound === 0) await record(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
||||
if (r % 3 === 0 && turnInRound === 1) await record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
||||
if (r % 5 === 0 && turnInRound === 2) await record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
|
||||
if (r % 7 === 0 && turnInRound === 3) await record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
||||
if (r % 13 === 0 && turnInRound === 4) await record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
||||
|
||||
// pause/resume + add reinforcement + edit every 10 rounds
|
||||
if (r % 10 === 0 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} pause`, () => pauseCombat());
|
||||
await recordAsync(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
||||
await recordAsync(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
|
||||
await recordAsync(`r${r} resume`, () => resumeCombat());
|
||||
await record(`r${r} pause`, () => pauseCombat());
|
||||
await record(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
||||
await record(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
|
||||
await record(`r${r} resume`, () => resumeCombat());
|
||||
}
|
||||
|
||||
// create a same-init tie + drag every 15 rounds (exercises reorderParticipants)
|
||||
if (r % 15 === 0 && turnInRound === 5) {
|
||||
const g1 = any('Goblin1');
|
||||
if (g1 && alive('Goblin2')) {
|
||||
record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
|
||||
record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
|
||||
await record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
|
||||
await record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
|
||||
}
|
||||
}
|
||||
|
||||
// death scenarios: drop a PC to 0, death saves, revive
|
||||
if (r === 25 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
|
||||
record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail', 1));
|
||||
record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||||
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
|
||||
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail', 1));
|
||||
await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||||
}
|
||||
// round 50: full 3-success death-save path (isDying) on Cleric, then remove
|
||||
if (r === 50 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
||||
await recordAsync(`r${r} deathSave Cleric x3 (isDying)`, async () => {
|
||||
deathSaveAction('Cleric', 'fail', 1);
|
||||
deathSaveAction('Cleric', 'fail', 2);
|
||||
deathSaveAction('Cleric', 'fail', 3); // → dead
|
||||
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
||||
await record(`r${r} deathSave Cleric x3 (isDying)`, async () => {
|
||||
await deathSaveAction('Cleric', 'fail', 1);
|
||||
await deathSaveAction('Cleric', 'fail', 2);
|
||||
await deathSaveAction('Cleric', 'fail', 3);
|
||||
});
|
||||
const cl = any('Cleric');
|
||||
if (cl && cl.isDying) record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
|
||||
if (cl && cl.isDying) await record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
|
||||
}
|
||||
|
||||
// remove a reinforcement every 30 rounds
|
||||
if (r % 30 === 0 && turnInRound === 1) {
|
||||
const rein = any(`Reinforce${r - 10}`);
|
||||
if (rein) record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
|
||||
if (rein) await record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
|
||||
}
|
||||
|
||||
// toggle hide hp every 20 rounds
|
||||
if (r % 20 === 0 && turnInRound === 0) {
|
||||
record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
|
||||
record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
|
||||
await record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
|
||||
await record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
|
||||
}
|
||||
|
||||
// rotation integrity check every 10 rounds at round start
|
||||
if (turnInRound === 0 && r % 10 === 0) {
|
||||
record(`r${r} rotation-check`, () => {
|
||||
await record(`r${r} rotation-check`, () => {
|
||||
const order = enc.turnOrderIds || [];
|
||||
const uniq = new Set(order);
|
||||
if (uniq.size !== order.length) throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
|
||||
@@ -297,11 +261,9 @@ test('full 100-round combat scenario (shared, no React)', async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// advance turn
|
||||
await recordAsync(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
|
||||
await record(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
|
||||
turnInRound++;
|
||||
|
||||
// round wrap detected
|
||||
if (enc.round > prevRound) {
|
||||
roundsDone++;
|
||||
prevRound = enc.round;
|
||||
@@ -309,9 +271,8 @@ test('full 100-round combat scenario (shared, no React)', async () => {
|
||||
}
|
||||
}
|
||||
|
||||
await recordAsync('endCombat', () => endCombat());
|
||||
await record('endCombat', () => endCombat());
|
||||
|
||||
// ---------- report ----------
|
||||
const failed = RESULTS.filter(x => !x.ok);
|
||||
if (failed.length > 0) {
|
||||
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
|
||||
@@ -321,14 +282,7 @@ test('full 100-round combat scenario (shared, no React)', async () => {
|
||||
expect(failed).toEqual([]);
|
||||
expect(roundsDone).toBe(ROUNDS);
|
||||
|
||||
// lifecycle assertions (activeDisplay mirrors combat state)
|
||||
expect(enc.isStarted).toBe(false);
|
||||
expect(enc.round).toBe(0);
|
||||
expect(enc.currentTurnParticipantId).toBeNull();
|
||||
expect(display.activeCampaignId).toBeNull();
|
||||
expect(display.activeEncounterId).toBeNull();
|
||||
});
|
||||
|
||||
function anyById(id) {
|
||||
return enc.participants.find(p => p.id === id);
|
||||
}
|
||||
|
||||
@@ -34,18 +34,22 @@ describe('Logs -> Firebase', () => {
|
||||
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
|
||||
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
|
||||
expect(logCall.data).toHaveProperty('message');
|
||||
expect(logCall.data).toHaveProperty('timestamp');
|
||||
expect(logCall.data).toHaveProperty('ts');
|
||||
expect(logCall.data.message).toMatch(/Combat started/);
|
||||
});
|
||||
|
||||
test('logAction: includes undo payload', async () => {
|
||||
test('logAction: includes lean undo data', async () => {
|
||||
await setupReady('UndoCamp', 'UndoEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
|
||||
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
|
||||
expect(logCall.data.undo).toBeTruthy();
|
||||
expect(logCall.data.undo).toHaveProperty('updates');
|
||||
expect(logCall.data.undo).toHaveProperty('isStarted', false);
|
||||
expect(logCall.data.undo).toHaveProperty('round', 0);
|
||||
expect(logCall.data.type).toBe('start_encounter');
|
||||
expect(logCall.data).toHaveProperty('snapshot');
|
||||
expect(logCall.data.undone).toBe(false);
|
||||
});
|
||||
|
||||
test('clearLogs: writeBatch deletes all log docs', async () => {
|
||||
@@ -69,26 +73,25 @@ describe('Logs -> Firebase', () => {
|
||||
expect(batchDeletes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('undo: updateDoc on encounter path + marks log undone', async () => {
|
||||
test('undo: tx batch marks log undone + updates encounter', async () => {
|
||||
// seed log via combat start
|
||||
await setupReady('UndoFlowCamp', 'UndoFlowEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => findLogCalls().length > 0);
|
||||
const logId = findLogCalls()[0].path.split('/').pop();
|
||||
|
||||
await goToLogs();
|
||||
const undoBtns = await screen.findAllByRole('button', { name: /Undo/i });
|
||||
fireEvent.click(undoBtns[0]);
|
||||
|
||||
// tx undo = batch.update on log (undone:true) + encounter
|
||||
await waitFor(() => {
|
||||
const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
const und = getCalls().find(c => c.fn === 'batch.update' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
return und;
|
||||
});
|
||||
const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
const markUndone = getCalls().find(c => c.fn === 'batch.update' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
expect(markUndone.data.undone).toBe(true);
|
||||
// encounter path updated with undo payload (any encounter update after undo click)
|
||||
const encUndo = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const encUndo = getCalls().filter(c => c.fn === 'batch.update' && c.path.includes('/encounters/'));
|
||||
expect(encUndo.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user