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:
2026-06-13 08:25:06 -07:00
parent 0bce291ab9
commit 6964aeabb6
6 changed files with 204 additions and 44 deletions

View File

@@ -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;
}