diff --git a/TODO.md b/TODO.md index 53712b1..b910b0a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,5 @@ ## Major features -- [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin. +- [~] **Rollback of game state.** *Phase 1 implemented* (full-state JSON snapshots in a `Checkpoint` table, captured per action by the broadcast middleware, restored by `crud_rollback.rollback_to_checkpoint`; scene-confined; server-enforced for Admins and Deep players; event-log buttons in `EventLog.svelte`). **Phase 1 truncates the future immediately (no undo).** *Phase 2 (remaining):* the greyed-out / undoable redo UX — roll back without discarding the future, grey later entries, and only commit the truncation when a new action is posted. The schema is already forward-compatible for this (add `Game.rollback_head_checkpoint_id`). ## Words Words Words diff --git a/frontend/src/components/EventLog.svelte b/frontend/src/components/EventLog.svelte index e3ae454..6547f97 100644 --- a/frontend/src/components/EventLog.svelte +++ b/frontend/src/components/EventLog.svelte @@ -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; + } + }