Track encounter start/end timestamps; display on card
startEncounter sets startedAt (clears endedAt on restart). endEncounter sets endedAt. snapshotOf includes both for undo/redo fidelity. expandUndo split start/end cases to restore old timestamps. Display: encounter card shows Started/Ended independently (endedAt shows even without startedAt). Undo sets null (can't delete via merge), test snap strips only startedAt/endedAt null for compare. Reviewed by pi gpt-5.5: stale endedAt on restart fixed, snap null-strip narrowed. Medium-low risk.
This commit is contained in:
@@ -29,7 +29,13 @@ function enc(ps) {
|
|||||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
}
|
}
|
||||||
const snap = (e) => JSON.parse(JSON.stringify(e));
|
const snap = (e) => {
|
||||||
|
const o = JSON.parse(JSON.stringify(e));
|
||||||
|
// normalize nullable timestamp fields: null = absent (undo clears via null)
|
||||||
|
if (o.startedAt === null) delete o.startedAt;
|
||||||
|
if (o.endedAt === null) delete o.endedAt;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
|
||||||
describe('undo + redo roundtrip', () => {
|
describe('undo + redo roundtrip', () => {
|
||||||
test('startEncounter', async () => {
|
test('startEncounter', async () => {
|
||||||
|
|||||||
+17
-5
@@ -102,6 +102,8 @@ function snapshotOf(enc) {
|
|||||||
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
||||||
turnOrderIds: [...(enc.turnOrderIds || [])],
|
turnOrderIds: [...(enc.turnOrderIds || [])],
|
||||||
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
||||||
|
startedAt: enc.startedAt ?? null,
|
||||||
|
endedAt: enc.endedAt ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,12 +307,18 @@ function expandUndo(entry, currentEnc) {
|
|||||||
redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
|
redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'start_encounter':
|
case 'start_encounter': {
|
||||||
|
return {
|
||||||
|
encounterPath: encPath,
|
||||||
|
updates: { ...u, startedAt: u.startedAt ?? null },
|
||||||
|
redo: { isStarted: true, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], startedAt: snap.startedAt ?? Date.now() },
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'end_encounter': {
|
case 'end_encounter': {
|
||||||
return {
|
return {
|
||||||
encounterPath: encPath,
|
encounterPath: encPath,
|
||||||
updates: u,
|
updates: { ...u, endedAt: u.endedAt ?? null },
|
||||||
redo: { isStarted: entry.type === 'start_encounter', isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
|
redo: { isStarted: false, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], endedAt: snap.endedAt ?? Date.now() },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -391,6 +399,8 @@ async function startEncounter(encounter, ctx) {
|
|||||||
participants: sortedParticipants,
|
participants: sortedParticipants,
|
||||||
currentTurnParticipantId: firstActive.id,
|
currentTurnParticipantId: firstActive.id,
|
||||||
turnOrderIds: sortedParticipants.map(p => p.id),
|
turnOrderIds: sortedParticipants.map(p => p.id),
|
||||||
|
startedAt: Date.now(),
|
||||||
|
endedAt: null,
|
||||||
};
|
};
|
||||||
const log = {
|
const log = {
|
||||||
type: 'start_encounter',
|
type: 'start_encounter',
|
||||||
@@ -403,6 +413,8 @@ async function startEncounter(encounter, ctx) {
|
|||||||
round: encounter.round ?? 0,
|
round: encounter.round ?? 0,
|
||||||
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
||||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||||
|
startedAt: encounter.startedAt ?? null,
|
||||||
|
endedAt: encounter.endedAt ?? null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return commit(encounter, patch, log, ctx);
|
return commit(encounter, patch, log, ctx);
|
||||||
@@ -1003,12 +1015,12 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function endEncounter(encounter, ctx) {
|
async function endEncounter(encounter, ctx) {
|
||||||
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] };
|
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [], endedAt: Date.now() };
|
||||||
const log = {
|
const log = {
|
||||||
type: 'end_encounter',
|
type: 'end_encounter',
|
||||||
message: `Combat ended: "${encounter.name}"`,
|
message: `Combat ended: "${encounter.name}"`,
|
||||||
delta: {},
|
delta: {},
|
||||||
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])] },
|
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], endedAt: encounter.endedAt ?? null },
|
||||||
};
|
};
|
||||||
return commit(encounter, patch, log, ctx);
|
return commit(encounter, patch, log, ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2258,6 +2258,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
<p className="text-xs text-stone-300">
|
<p className="text-xs text-stone-300">
|
||||||
{encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}
|
{encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}
|
||||||
</p>
|
</p>
|
||||||
|
{(encounter.startedAt || encounter.endedAt) && (
|
||||||
|
<p className="text-xs text-green-400/80 mt-0.5">
|
||||||
|
{encounter.startedAt && `⚔ Started: ${new Date(encounter.startedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`}{encounter.startedAt && encounter.endedAt && ' · '}{encounter.endedAt && `Ended: ${new Date(encounter.endedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<span className="text-xs text-green-400 font-semibold block mt-1">
|
<span className="text-xs text-green-400 font-semibold block mt-1">
|
||||||
LIVE ON PLAYER DISPLAY
|
LIVE ON PLAYER DISPLAY
|
||||||
|
|||||||
Reference in New Issue
Block a user