Add undoable rollback/redo (Phase 2)
Rolling back is now non-destructive: instead of truncating the future, it restores the target checkpoint's snapshot and points Game.rollback_head_checkpoint_id at it. Later checkpoints/events are kept and rendered greyed; rolling forward to one redoes the rollback (same endpoint). The abandoned future is discarded only when a new action is taken from a rolled-back state -- maintain_rollback_history truncates it, clears the head, and bumps rollback_timeline_version, now the sole trigger for the frontend log reset. - Game.rollback_head_checkpoint_id (nullable; in SNAPSHOT_EXCLUDE) + Alembic migration. - crud_rollback: rollback_to_checkpoint sets the head (None at latest); _discard_future + head-clear + version-bump moved into the next scene action's capture. - EventLog.svelte: greyed future, redo (forward) affordance, a "rolled back" banner; dropped the now-misleading destructive confirm. - Tests rewritten for head-pointer semantics (+ redo, action-after- rollback truncation, scene-end head clear); HTTP integration test extended to drive the full cycle over the wire. Verified end-to-end in a browser: rollback greys the future and reverts live state (captaincy/ranks), redo restores it, no errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,9 @@
|
||||
// Rollback buttons appear during a scene, for Admins and Deep players (server-enforced too).
|
||||
$: canRollback = state.game?.phase === 'scene'
|
||||
&& (state.player?.is_admin || state.player?.role === 'deep');
|
||||
// When set, the game is at a rolled-back state; events past this checkpoint are the
|
||||
// greyed, still-undoable future. Rolling forward to one of them redoes the rollback.
|
||||
$: head = state.game?.rollback_head_checkpoint_id ?? null;
|
||||
|
||||
$: mergePolled(state.events);
|
||||
|
||||
@@ -46,8 +49,9 @@
|
||||
// otherwise events the player paged back to would vanish as the window slides.
|
||||
async function mergePolled(polled) {
|
||||
if (!polled) return;
|
||||
// A rollback truncates the log server-side; the version bump tells us to drop
|
||||
// everything we'd accumulated (those future events no longer exist) and reseed.
|
||||
// Plain rollback/redo keeps every event (only `head` moves); but taking a new
|
||||
// action from a rolled-back state truncates the abandoned future and bumps the
|
||||
// version, which tells us to drop what we'd accumulated and reseed.
|
||||
const version = state.game?.rollback_timeline_version ?? 0;
|
||||
if (timelineVersion !== null && version !== timelineVersion) {
|
||||
events = [];
|
||||
@@ -122,8 +126,10 @@
|
||||
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Both rollback (to a past event) and redo (to a greyed future event) are the same
|
||||
// call — it just moves the head. It's reversible until someone takes a new action,
|
||||
// so no confirm; the greyed future makes the state obvious.
|
||||
async function rollback(event) {
|
||||
if (!confirm(`Roll the game back to just after "${event.message}"? Everything after it will be undone.`)) return;
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(
|
||||
@@ -147,6 +153,11 @@
|
||||
{#if error}
|
||||
<div class="log-error">{error}</div>
|
||||
{/if}
|
||||
{#if head !== null}
|
||||
<div class="log-rolledback">
|
||||
↩ Rolled back — greyed entries are undone. Click one to redo, or take an action to make it permanent.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
|
||||
{#if events.length}
|
||||
<div class="log-top">
|
||||
@@ -159,16 +170,17 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#each events as event (event.id)}
|
||||
<div class="log-entry log-kind-{event.kind || 'info'}">
|
||||
{@const isFuture = head !== null && event.checkpoint_id > head}
|
||||
<div class="log-entry log-kind-{event.kind || 'info'}" class:greyed={isFuture}>
|
||||
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
|
||||
<span class="log-message">{event.message}</span>
|
||||
<span class="log-time">{formatTime(event.timestamp)}</span>
|
||||
{#if canRollback && event.checkpoint_id > 0}
|
||||
<button
|
||||
class="rollback-btn"
|
||||
title="Roll the game back to just after this event"
|
||||
title={isFuture ? 'Redo forward to just after this event' : 'Roll the game back to just after this event'}
|
||||
on:click={() => rollback(event)}
|
||||
>↩</button>
|
||||
>{isFuture ? '↪' : '↩'}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -286,6 +298,20 @@
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.log-rolledback {
|
||||
margin: 6px 8px 0;
|
||||
padding: 6px 10px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.log-entry.greyed {
|
||||
opacity: 0.45;
|
||||
font-style: italic;
|
||||
}
|
||||
.log-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user