Fix off-by-one in rollback button placement

A rollback button on event E restores the state just after E. So the
button on the latest event was a no-op (you're already there), while the
scene-start event -- a valid earliest target -- correctly has one. The
visible set was effectively shifted one event late.

Hide the button on whichever event sits at the current position (the
rollback head if set, else the latest checkpoint, now supplied by the
backend as state.latest_checkpoint_id since the frontend's own reduce
over the async-populated accumulator wasn't reliably reactive). In live
play the latest event loses its redundant button; when rolled back, the
"you are here" event has none while earlier events show rollback and
later ones show redo.

Verified in a browser across live, rolled-back, and redo states.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 09:03:52 -07:00
parent 6964aeabb6
commit 56cdebdeda
4 changed files with 25 additions and 2 deletions

View File

@@ -42,6 +42,10 @@
// When set, the game is at a rolled-back state; events past this checkpoint are the // 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. // greyed, still-undoable future. Rolling forward to one of them redoes the rollback.
$: head = state.game?.rollback_head_checkpoint_id ?? null; $: head = state.game?.rollback_head_checkpoint_id ?? null;
// The point in time the game is currently at: the rollback head if set, otherwise
// the newest checkpoint (supplied by the backend). A button targeting it would be a
// no-op, so the "you are here" event — and, in live play, the latest event — has none.
$: currentPos = head !== null ? head : (state.latest_checkpoint_id ?? 0);
$: mergePolled(state.events); $: mergePolled(state.events);
@@ -175,7 +179,7 @@
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span> <span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
<span class="log-message">{event.message}</span> <span class="log-message">{event.message}</span>
<span class="log-time">{formatTime(event.timestamp)}</span> <span class="log-time">{formatTime(event.timestamp)}</span>
{#if canRollback && event.checkpoint_id > 0} {#if canRollback && event.checkpoint_id > 0 && event.checkpoint_id !== currentPos}
<button <button
class="rollback-btn" class="rollback-btn"
title={isFuture ? 'Redo forward to just after this event' : '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'}

View File

@@ -25,7 +25,7 @@ what commits the new timeline.
""" """
import json import json
from typing import Tuple from typing import Optional, Tuple
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -148,6 +148,17 @@ def _discard_future(db: Session, game: Game, after_checkpoint_id: int) -> None:
db.delete(c) db.delete(c)
def latest_checkpoint_id(db: Session, game_id: str) -> Optional[int]:
"""The newest checkpoint id for a game (None if there are none). During live play
this is the point the game is at — the frontend hides the rollback button on the
event sitting at the current position, since rolling there would be a no-op."""
cp = db.exec(
select(Checkpoint).where(Checkpoint.game_id == game_id)
.order_by(Checkpoint.id.desc())
).first()
return cp.id if cp else None
def maintain_rollback_history(db: Session, game: Game) -> None: def maintain_rollback_history(db: Session, game: Game) -> None:
"""Called after every successful mutation: snapshot the state while a scene is in """Called after every successful mutation: snapshot the state while a scene is in
progress, otherwise clear the (now-irrelevant) rollback history. This is the only progress, otherwise clear the (now-irrelevant) rollback history. This is the only

View File

@@ -135,6 +135,8 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi
"votes": [v.model_dump() for v in game.votes], "votes": [v.model_dump() for v in game.votes],
"events": [e.model_dump() for e in events], "events": [e.model_dump() for e in events],
"events_have_more": events_have_more, "events_have_more": events_have_more,
# The newest checkpoint id, so the log can tell which event is "you are here".
"latest_checkpoint_id": crud.latest_checkpoint_id(db, game_id),
} }
@api.get("/game/{game_id}/events") @api.get("/game/{game_id}/events")

View File

@@ -2123,6 +2123,12 @@ def test_rollback_keeps_future_and_sets_head(session):
assert game.rollback_head_checkpoint_id == cp1.id assert game.rollback_head_checkpoint_id == cp1.id
assert game.rollback_timeline_version == v0 assert game.rollback_timeline_version == v0
def test_latest_checkpoint_id(session):
game, deep, _, cp1, cp2 = _scene_with_two_checkpoints(session)
# Drives the "you are here" button-hiding: during live play this is the current point.
assert crud.latest_checkpoint_id(session, game.id) == cp2.id
assert crud.latest_checkpoint_id(session, crud.create_game(session).id) is None
def test_redo_after_rollback_clears_head(session): def test_redo_after_rollback_clears_head(session):
game, deep, _, cp1, cp2 = _scene_with_two_checkpoints(session) game, deep, _, cp1, cp2 = _scene_with_two_checkpoints(session)