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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user