Single source of truth: combat logic, storage parity, slot ordering #3
@@ -166,23 +166,38 @@ describe('Logging undo payloads', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Logging: gaps (currently unlogged mutations)', () => {
|
describe('Logging: addParticipants + updateParticipant', () => {
|
||||||
// Document current behavior. addParticipants + updateParticipant return
|
|
||||||
// null log → invisible in combat log. May be intended (bulk/no-op) or bug.
|
|
||||||
let e;
|
let e;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
e = enc([p('a', 10), p('b', 7)]);
|
e = enc([p('a', 10), p('b', 7)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('addParticipants returns null log (bulk gap?)', () => {
|
test('addParticipants logs', () => {
|
||||||
const r = addParticipants(e, [p('c', 3)]);
|
const r = addParticipants(e, [p('c', 3)]);
|
||||||
// Currently null. Document so decision is explicit.
|
expectLogged(r);
|
||||||
expect(r.log).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateParticipant returns null log (edit gap?)', () => {
|
test('updateParticipant (same slot) logs', () => {
|
||||||
const r = updateParticipant(e, 'b', { name: 'B' });
|
const r = updateParticipant(e, 'b', { name: 'B' });
|
||||||
// Currently null. Document so decision is explicit.
|
expectLogged(r);
|
||||||
expect(r.log).toBeNull();
|
});
|
||||||
|
|
||||||
|
test('updateParticipant (init change) logs', () => {
|
||||||
|
const r = updateParticipant(e, 'b', { initiative: 5 });
|
||||||
|
expectLogged(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipants undo restores prior list', () => {
|
||||||
|
const orig = e.participants.map(p => p.id);
|
||||||
|
const r = addParticipants(e, [p('c', 3)]);
|
||||||
|
const restored = { ...e, ...r.log.undo };
|
||||||
|
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateParticipant undo restores prior participant', () => {
|
||||||
|
const orig = e.participants.map(p => p.id);
|
||||||
|
const r = updateParticipant(e, 'b', { name: 'B' });
|
||||||
|
const restored = { ...e, ...r.log.undo };
|
||||||
|
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-3
@@ -333,8 +333,16 @@ function addParticipant(encounter, participant) {
|
|||||||
|
|
||||||
// ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters").
|
// ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters").
|
||||||
function addParticipants(encounter, newParticipants) {
|
function addParticipants(encounter, newParticipants) {
|
||||||
|
const undo = { participants: encounter.participants || [] };
|
||||||
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
|
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
|
||||||
return { patch: { participants: updatedParticipants }, log: null };
|
const names = newParticipants.map(p => p.name).join(', ');
|
||||||
|
return {
|
||||||
|
patch: { participants: updatedParticipants },
|
||||||
|
log: {
|
||||||
|
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
|
||||||
|
undo,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc).
|
// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc).
|
||||||
@@ -346,14 +354,28 @@ function updateParticipant(encounter, participantId, updatedData) {
|
|||||||
// SLOT only if initiative changed. Other edits (name/hp/notes/isNpc) = no
|
// SLOT only if initiative changed. Other edits (name/hp/notes/isNpc) = no
|
||||||
// position change (design doc). Re-slots on unrelated edits shuffle the
|
// position change (design doc). Re-slots on unrelated edits shuffle the
|
||||||
// list mid-round → rotation dupes.
|
// list mid-round → rotation dupes.
|
||||||
|
const undo = { participants: encounter.participants || [] };
|
||||||
if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) {
|
if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) {
|
||||||
const sameSlot = existing.map(p => p.id === participantId ? merged : p);
|
const sameSlot = existing.map(p => p.id === participantId ? merged : p);
|
||||||
return { patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) }, log: null };
|
const fields = Object.keys(updatedData).join(', ');
|
||||||
|
return {
|
||||||
|
patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) },
|
||||||
|
log: {
|
||||||
|
message: `${target.name} updated (${fields})`,
|
||||||
|
undo,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const without = existing.filter(p => p.id !== participantId);
|
const without = existing.filter(p => p.id !== participantId);
|
||||||
const idx = slotIndexForInit(without, merged.initiative);
|
const idx = slotIndexForInit(without, merged.initiative);
|
||||||
without.splice(idx, 0, merged);
|
without.splice(idx, 0, merged);
|
||||||
return { patch: { participants: without, ...syncTurnOrder(without) }, log: null };
|
return {
|
||||||
|
patch: { participants: without, ...syncTurnOrder(without) },
|
||||||
|
log: {
|
||||||
|
message: `${target.name} updated (initiative: ${merged.initiative})`,
|
||||||
|
undo,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
|
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
|
||||||
|
|||||||
+21
-3
@@ -940,8 +940,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { patch } = addParticipants(encounter, newParticipants);
|
const { patch, log } = addParticipants(encounter, newParticipants);
|
||||||
await storage.updateDoc(encounterPath, patch);
|
await storage.updateDoc(encounterPath, patch);
|
||||||
|
if (log) {
|
||||||
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
|
encounterPath,
|
||||||
|
updates: log.undo,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Failed to add all characters. Please try again.");
|
alert("Failed to add all characters. Please try again.");
|
||||||
}
|
}
|
||||||
@@ -950,8 +956,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
const handleUpdateParticipant = async (updatedData) => {
|
const handleUpdateParticipant = async (updatedData) => {
|
||||||
if (!db || !editingParticipant) return;
|
if (!db || !editingParticipant) return;
|
||||||
try {
|
try {
|
||||||
const { patch } = updateParticipant(encounter, editingParticipant.id, updatedData);
|
const { patch, log } = updateParticipant(encounter, editingParticipant.id, updatedData);
|
||||||
await storage.updateDoc(encounterPath, patch);
|
await storage.updateDoc(encounterPath, patch);
|
||||||
|
if (log) {
|
||||||
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
|
encounterPath,
|
||||||
|
updates: log.undo,
|
||||||
|
});
|
||||||
|
}
|
||||||
setEditingParticipant(null);
|
setEditingParticipant(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Failed to update participant. Please try again.");
|
alert("Failed to update participant. Please try again.");
|
||||||
@@ -967,8 +979,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
const n = parseInt(value, 10);
|
const n = parseInt(value, 10);
|
||||||
if (isNaN(n)) return;
|
if (isNaN(n)) return;
|
||||||
try {
|
try {
|
||||||
const { patch } = updateParticipant(encounter, participantId, { initiative: n });
|
const { patch, log } = updateParticipant(encounter, participantId, { initiative: n });
|
||||||
await storage.updateDoc(encounterPath, patch);
|
await storage.updateDoc(encounterPath, patch);
|
||||||
|
if (log) {
|
||||||
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
|
encounterPath,
|
||||||
|
updates: log.undo,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// fall through silently
|
// fall through silently
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user