Fix undo/redo forward-state restore, harden test infra, skip-guard
Root cause: mutation writers stored oldValues only. Redo rebuilt from current state via derived logic — wrong order, wrong content, or no-op. Undo patched fields in place but ignored roster/array order changes. Writers now store forward arrays (shared/turn.js): - addParticipant: newTurnOrderIds, newCurrentTurnParticipantId - removeParticipant: newTurnOrderIds, newCurrentTurnParticipantId - updateParticipant: oldTurnOrderIds + newTurnOrderIds (initiative re-slot) - reorderParticipants: newParticipants + newTurnOrderIds - damage/heal/deathSave/stabilize/revive/deactivate_dead_monster: oldValues + newValues both capture full death state incl conditions expandUndo redo uses stored forward state instead of deriving: - add/remove: order via newTurnOrderIds - update: initiative change re-orders both directions - reorder: re-applies newParticipants - death ops: restore via newValues (was HP-only, dropped status/conditions) Missing expandUndo cases added: stabilize, revive, deactivate_dead_monster (were default:null -> undo no-op). Test infra (shared/tests/_helpers.js): - Mock storage undo() real now: applies patch + flips undone flag (was no-op) - addDoc persists log entries, getCollection returns them - undoLast/redoLast harness helpers exercise real mechanism - Undo tests assert against actual persisted doc, not expandUndo output turn.undo.test.js rewritten: every op tested as undo->deepEqual before, redo->deepEqual after-op (12 cases). Was undo-only, redo untested. turn.deathsave.undo.test.js added: 11 death-path roundtrips. Dead code removed: - round-trip.test.js: skipped suite testing deleted replay-from-logs.js (5 skipped tests rotting). Coverage gap acknowledged in TODO. Skip-guard added (scripts/run-tests.sh): pre-flight grep refuses to run if any .skip/xdescribe/xit found in test dirs. No CI; this is the gate.
This commit is contained in:
+144
-31
@@ -163,10 +163,21 @@ function expandUndo(entry, currentEnc) {
|
||||
const turnState = {};
|
||||
if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId;
|
||||
if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds;
|
||||
// redo: re-insert added into current parts, order by stored newTurnOrderIds.
|
||||
const redoOrder = u.newTurnOrderIds || null;
|
||||
let redoParts = null;
|
||||
if (redoOrder) {
|
||||
const pool = new Map([...parts, ...added].map(p => [p.id, p]));
|
||||
redoParts = redoOrder.map(id => pool.get(id)).filter(Boolean);
|
||||
}
|
||||
const redoTurnState = {};
|
||||
if (u.newCurrentTurnParticipantId !== undefined) redoTurnState.currentTurnParticipantId = u.newCurrentTurnParticipantId;
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: parts.filter(p => !ids.has(p.id)), ...turnState },
|
||||
redo: { participants: [...parts, ...added.filter(p => !parts.some(x => x.id === p.id))] },
|
||||
redo: redoParts
|
||||
? { participants: redoParts, turnOrderIds: redoOrder, ...redoTurnState }
|
||||
: { participants: [...parts, ...added.filter(p => !parts.some(x => x.id === p.id))] },
|
||||
};
|
||||
}
|
||||
case 'remove_participant': {
|
||||
@@ -179,23 +190,45 @@ function expandUndo(entry, currentEnc) {
|
||||
const idx = typeof u.slotIdx === 'number' ? Math.min(u.slotIdx, parts.length) : parts.length;
|
||||
restoredParts = [...parts.slice(0, idx), p, ...parts.slice(idx)];
|
||||
}
|
||||
// redo: re-remove + restore stored forward turn state.
|
||||
const redoTurnState = {};
|
||||
if (u.newCurrentTurnParticipantId !== undefined) redoTurnState.currentTurnParticipantId = u.newCurrentTurnParticipantId;
|
||||
if (u.newTurnOrderIds) redoTurnState.turnOrderIds = u.newTurnOrderIds;
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: restoredParts, ...turnState },
|
||||
redo: { participants: parts.filter(x => x.id !== (p && p.id)) },
|
||||
redo: u.newTurnOrderIds
|
||||
? { participants: parts.filter(x => x.id !== (p && p.id)), ...redoTurnState }
|
||||
: { participants: parts.filter(x => x.id !== (p && p.id)) },
|
||||
};
|
||||
}
|
||||
case 'damage':
|
||||
case 'heal': {
|
||||
const amt = u.amount || 0;
|
||||
case 'heal':
|
||||
case 'death_save':
|
||||
case 'stabilize':
|
||||
case 'revive':
|
||||
case 'deactivate_dead_monster': {
|
||||
// Full death-state restore: currentHp, status, death-save counters,
|
||||
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
|
||||
const pid = entry.participantId;
|
||||
const cur = parts.find(p => p.id === pid);
|
||||
if (!cur) return null;
|
||||
const newHp = Math.max(0, Math.min(cur.maxHp, cur.currentHp + amt));
|
||||
const oldVals = u.oldValues || {};
|
||||
const newVals = u.newValues || {};
|
||||
const restoreFields = (vals) => {
|
||||
const out = {};
|
||||
if (vals.currentHp !== undefined) out.currentHp = vals.currentHp;
|
||||
if (vals.status !== undefined) out.status = vals.status;
|
||||
if (vals.deathSaveSuccesses !== undefined) out.deathSaveSuccesses = vals.deathSaveSuccesses;
|
||||
if (vals.deathSaveFailures !== undefined) out.deathSaveFailures = vals.deathSaveFailures;
|
||||
if (vals.isActive !== undefined) out.isActive = vals.isActive;
|
||||
if (vals.conditions !== undefined) out.conditions = vals.conditions;
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: parts.map(p => p.id === pid ? { ...p, currentHp: newHp } : p) },
|
||||
redo: { participants: parts.map(p => p.id === pid ? { ...p, currentHp: cur.currentHp - amt } : p) },
|
||||
updates: { participants: parts.map(p => p.id === pid ? { ...p, ...restoreFields(oldVals) } : p) },
|
||||
redo: { participants: parts.map(p => p.id === pid ? { ...p, ...restoreFields(newVals) } : p) },
|
||||
};
|
||||
}
|
||||
case 'add_condition':
|
||||
@@ -226,30 +259,41 @@ function expandUndo(entry, currentEnc) {
|
||||
};
|
||||
}
|
||||
case 'reorder': {
|
||||
// undo: swap dragged/target back. Stored old order.
|
||||
// undo: restore old order. redo: re-apply stored forward order.
|
||||
const redoParts = u.newParticipants || null;
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: u.participants || parts, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}) },
|
||||
redo: { participants: parts, turnOrderIds: snap.turnOrderIds || currentEnc.turnOrderIds || [] },
|
||||
updates: { participants: u.participants || parts, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}), ...(u.currentTurnParticipantId !== undefined ? { currentTurnParticipantId: u.currentTurnParticipantId } : {}) },
|
||||
redo: redoParts
|
||||
? { participants: redoParts, turnOrderIds: u.newTurnOrderIds || redoParts.map(p => p.id) }
|
||||
: { participants: parts, turnOrderIds: snap.turnOrderIds || currentEnc.turnOrderIds || [] },
|
||||
};
|
||||
}
|
||||
case 'update_participant': {
|
||||
const pid = entry.participantId;
|
||||
const oldVals = u.oldValues || {};
|
||||
const newVals = u.newValues || {};
|
||||
// If initiative changed, restore array order too (undo by oldTurnOrderIds,
|
||||
// redo by newTurnOrderIds). Otherwise just patch fields in place.
|
||||
const applyFields = (vals) => parts.map(p => p.id === pid ? { ...p, ...vals } : p);
|
||||
const hasOrderChange = !!(u.oldTurnOrderIds && u.oldTurnOrderIds.length && u.newTurnOrderIds && u.newTurnOrderIds.length && JSON.stringify(u.oldTurnOrderIds) !== JSON.stringify(u.newTurnOrderIds));
|
||||
if (hasOrderChange && u.oldTurnOrderIds && u.newTurnOrderIds) {
|
||||
const undoApplied = applyFields(oldVals);
|
||||
const undoPool = new Map(undoApplied.map(p => [p.id, p]));
|
||||
const undoOrdered = u.oldTurnOrderIds.map(id => undoPool.get(id)).filter(Boolean);
|
||||
const redoApplied = applyFields(newVals);
|
||||
const redoPool = new Map(redoApplied.map(p => [p.id, p]));
|
||||
const redoOrdered = u.newTurnOrderIds.map(id => redoPool.get(id)).filter(Boolean);
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: undoOrdered, turnOrderIds: u.oldTurnOrderIds },
|
||||
redo: { participants: redoOrdered, turnOrderIds: u.newTurnOrderIds },
|
||||
};
|
||||
}
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: parts.map(p => p.id === pid ? { ...p, ...oldVals } : p) },
|
||||
redo: { participants: parts.map(p => p.id === pid ? { ...p, ...newVals } : p) },
|
||||
};
|
||||
}
|
||||
case 'death_save': {
|
||||
const pid = entry.participantId;
|
||||
const oldVals = u.oldValues || {};
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: parts.map(p => p.id === pid ? { ...p, ...oldVals } : p) },
|
||||
redo: { participants: parts },
|
||||
updates: { participants: applyFields(oldVals) },
|
||||
redo: { participants: applyFields(newVals), ...(u.newTurnOrderIds ? { turnOrderIds: u.newTurnOrderIds } : {}) },
|
||||
};
|
||||
}
|
||||
case 'next_turn':
|
||||
@@ -434,7 +478,13 @@ async function addParticipant(encounter, participant, ctx) {
|
||||
participantId: participant.id, participantName: participant.name,
|
||||
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
|
||||
delta: { init: participant.initiative, maxHp: participant.maxHp, ptype: participant.type },
|
||||
undo: { participant, currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])] },
|
||||
undo: {
|
||||
participant,
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
newTurnOrderIds: patch.turnOrderIds,
|
||||
newCurrentTurnParticipantId: patch.currentTurnParticipantId,
|
||||
},
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -469,7 +519,7 @@ async function updateParticipant(encounter, participantId, updatedData, ctx) {
|
||||
participantId, participantName: target.name,
|
||||
message: `${target.name} updated (${fields})`,
|
||||
delta: { changedFields: Object.keys(updatedData) },
|
||||
undo: { oldValues, newValues: { ...updatedData } },
|
||||
undo: { oldValues, newValues: { ...updatedData }, oldTurnOrderIds: encounter.turnOrderIds || [], newTurnOrderIds: patch.turnOrderIds },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
} else {
|
||||
@@ -482,7 +532,7 @@ async function updateParticipant(encounter, participantId, updatedData, ctx) {
|
||||
participantId, participantName: target.name,
|
||||
message: `${target.name} updated (initiative: ${merged.initiative})`,
|
||||
delta: { changedFields: Object.keys(updatedData), initiative: merged.initiative },
|
||||
undo: { oldValues, newValues: { ...updatedData } },
|
||||
undo: { oldValues, newValues: { ...updatedData }, oldTurnOrderIds: encounter.turnOrderIds || [], newTurnOrderIds: patch.turnOrderIds },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -500,7 +550,13 @@ async function removeParticipant(encounter, participantId, ctx) {
|
||||
participantId, participantName: participant ? participant.name : null,
|
||||
message: `${participant ? participant.name : 'Participant'} removed from encounter`,
|
||||
delta: { dead: true },
|
||||
undo: { participant, slotIdx, currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])] },
|
||||
undo: {
|
||||
participant, slotIdx,
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
newCurrentTurnParticipantId: patch.currentTurnParticipantId,
|
||||
newTurnOrderIds: patch.turnOrderIds,
|
||||
},
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -544,6 +600,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
|
||||
let updates = {};
|
||||
@@ -559,13 +616,22 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
p.id === participantId ? { ...p, isActive: false } : p
|
||||
);
|
||||
const patch = { participants: updatedParticipants };
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp,
|
||||
status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive,
|
||||
conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const log = {
|
||||
type: 'deactivate_dead_monster',
|
||||
participantId,
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} marked inactive (dead monster)`,
|
||||
delta: { status: 'dead', isActive: false },
|
||||
undo: { oldValues },
|
||||
undo: { oldValues, newValues },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -610,12 +676,21 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
);
|
||||
const patch = { participants: updatedParticipants };
|
||||
const undoAmount = changeType === 'damage' ? amount : -amount;
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp,
|
||||
status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive,
|
||||
conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const log = {
|
||||
type: changeType,
|
||||
participantId, participantName: participant.name,
|
||||
message,
|
||||
delta: logDelta,
|
||||
undo: { amount: undoAmount, oldValues },
|
||||
undo: { amount: undoAmount, oldValues, newValues },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -634,6 +709,8 @@ async function deathSave(encounter, participantId, outcome, ctx) {
|
||||
status: statusBefore,
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
|
||||
let updates;
|
||||
@@ -657,12 +734,21 @@ async function deathSave(encounter, participantId, outcome, ctx) {
|
||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||
);
|
||||
const patch = { participants: updatedParticipants };
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp,
|
||||
status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive,
|
||||
conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const log = {
|
||||
type: 'death_save',
|
||||
participantId, participantName: name,
|
||||
message,
|
||||
delta: { outcome, status: updates.status, deathSaveSuccesses: updates.deathSaveSuccesses, deathSaveFailures: updates.deathSaveFailures },
|
||||
undo: { oldValues },
|
||||
undo: { oldValues, newValues },
|
||||
};
|
||||
const enc = await commit(encounter, patch, log, ctx);
|
||||
return { enc, status: updates.status };
|
||||
@@ -708,6 +794,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
status,
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp,
|
||||
status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive,
|
||||
conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const log = {
|
||||
type: 'stabilize',
|
||||
@@ -715,7 +812,7 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} stabilized at 0 HP`,
|
||||
delta: { status: 'stable' },
|
||||
undo: { oldValues },
|
||||
undo: { oldValues, newValues },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -738,6 +835,16 @@ async function reviveParticipant(encounter, participantId, ctx) {
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp,
|
||||
status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive,
|
||||
conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const log = {
|
||||
type: 'revive',
|
||||
@@ -745,7 +852,7 @@ async function reviveParticipant(encounter, participantId, ctx) {
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} revived to 0 HP (stable, unconscious)`,
|
||||
delta: { status: 'stable', currentHp: 0 },
|
||||
undo: { oldValues },
|
||||
undo: { oldValues, newValues },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -777,7 +884,13 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
||||
participantId: draggedId, participantName: removedItem.name,
|
||||
message: `${removedItem.name} reordered before ${(participants.find(p => p.id === targetId) || {}).name || targetId}`,
|
||||
delta: { draggedId, targetId },
|
||||
undo: { participants: encounter.participants || [], turnOrderIds: encounter.turnOrderIds || [], currentTurnParticipantId: encounter.currentTurnParticipantId ?? null },
|
||||
undo: {
|
||||
participants: encounter.participants || [],
|
||||
turnOrderIds: encounter.turnOrderIds || [],
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
||||
newParticipants: participants,
|
||||
newTurnOrderIds: patch.turnOrderIds,
|
||||
},
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user