Add game-state rollback (Phase 1)

Full-state JSON snapshots let a scene be rolled back to an earlier
action. All game state lives in game_id-scoped tables, so a checkpoint
is just a serialized dump of the gameplay rows and rollback restores
it -- sidestepping deterministic replay (the deck shuffle is
materialized into state and captured verbatim).

- Checkpoint table + GameEvent.checkpoint_id (tri-state) +
  Game.rollback_timeline_version (models.py, Alembic migration).
- crud_rollback.py: serialize/apply/capture/seal-purge/rollback.
- Capture is driven by the broadcast middleware: snapshot per action
  while in the `scene` phase, seal+purge otherwise. Confined to the
  current scene; older scenes' checkpoints are purged at scene end.
- POST /game/{gid}/player/{pid}/rollback (routes_rollback.py),
  server-enforced for Admins and Deep players.
- EventLog.svelte: per-event rollback buttons + timeline reconciliation.
- Remove the orphaned /scene/rollback per-card-play undo (dead code
  since d7f8483, never wired up; superseded by full-state rollback).

Phase 1 truncates the future immediately; the greyed/undoable redo is
deferred to Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 06:02:32 -07:00
parent e7db19f27e
commit a7f174b214
11 changed files with 595 additions and 67 deletions

View File

@@ -33,6 +33,12 @@
let haveMore = true;
let loadingOlder = false;
let atBottom = true;
let error = '';
let timelineVersion = null;
// 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');
$: mergePolled(state.events);
@@ -40,6 +46,14 @@
// 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.
const version = state.game?.rollback_timeline_version ?? 0;
if (timelineVersion !== null && version !== timelineVersion) {
events = [];
seenIds = new Set();
}
timelineVersion = version;
if (events.length === 0) {
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
}
@@ -107,6 +121,21 @@
function formatTime(ts) {
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
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(
`/game/${state.game.id}/player/${state.player.id}/rollback`,
'POST',
{ checkpoint_id: event.checkpoint_id },
);
// The state-changed ping drives Dashboard to refetch the restored state.
} catch (e) {
error = e.message;
}
}
</script>
<div class="floating-event-log {open ? 'open' : ''}">
@@ -115,6 +144,9 @@
</button>
{#if open}
{#if error}
<div class="log-error">{error}</div>
{/if}
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
{#if events.length}
<div class="log-top">
@@ -131,6 +163,13 @@
<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"
on:click={() => rollback(event)}
>↩</button>
{/if}
</div>
{/each}
{:else}
@@ -211,7 +250,7 @@
}
.log-entry {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-columns: auto 1fr auto auto;
gap: 8px;
align-items: baseline;
padding: 6px 8px;
@@ -222,6 +261,31 @@
line-height: 1.35;
color: color-mix(in srgb, var(--text) 85%, var(--text-muted));
}
.rollback-btn {
background: transparent;
border: 1px solid var(--edge);
color: var(--text-muted);
border-radius: 6px;
padding: 0 6px;
font-size: 0.8rem;
line-height: 1.4;
cursor: pointer;
align-self: center;
}
.rollback-btn:hover {
background: color-mix(in srgb, var(--danger) 18%, transparent);
border-color: var(--danger);
color: var(--text);
}
.log-error {
margin: 6px 8px 0;
padding: 6px 10px;
background: color-mix(in srgb, var(--danger) 14%, transparent);
border: 1px solid var(--danger);
border-radius: var(--radius-md);
color: var(--text);
font-size: 0.8rem;
}
.log-icon {
font-size: 0.9rem;
}