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

@@ -2098,26 +2098,66 @@ def test_rollback_round_trip_restores_state(session):
assert crud.get_game_deck(game) == saved_deck
assert session.exec(select(Challenge).where(Challenge.game_id == game.id)).all() == []
def test_rollback_truncates_future_and_bumps_version(session):
game, deep, (p2,) = make_scene_game(session)
def _scene_with_two_checkpoints(session, num_pirats=1):
"""A scene with sealed history and two action checkpoints (cp1 'First', cp2 'Second')."""
game, deep, pirats = make_scene_game(session, num_pirats=num_pirats)
crud.seal_and_purge(session, game)
crud.add_game_event(session, game.id, "First", kind="info")
cp1 = crud.capture_checkpoint(session, game)
v0 = game.rollback_timeline_version
crud.add_game_event(session, game.id, "Second", kind="info")
cp2 = crud.capture_checkpoint(session, game)
assert {c.id for c in _checkpoints(session, game)} == {cp1.id, cp2.id}
return game, deep, pirats, cp1, cp2
def test_rollback_keeps_future_and_sets_head(session):
game, deep, _, cp1, cp2 = _scene_with_two_checkpoints(session)
v0 = game.rollback_timeline_version
ok, msg = crud.rollback_to_checkpoint(session, game, deep, cp1.id)
assert ok, msg
session.refresh(game)
# The future checkpoint and its event are gone; cp1 and its event survive.
assert [c.id for c in _checkpoints(session, game)] == [cp1.id]
msgs = {e.message for e in _events(session, game)}
assert "First" in msgs and "Second" not in msgs
# Non-destructive: the later checkpoint and its event are kept (the frontend greys
# them); head points at cp1 and the version is unchanged.
assert {c.id for c in _checkpoints(session, game)} == {cp1.id, cp2.id}
assert {e.message for e in _events(session, game)} >= {"First", "Second"}
assert game.rollback_head_checkpoint_id == cp1.id
assert game.rollback_timeline_version == v0
def test_redo_after_rollback_clears_head(session):
game, deep, _, cp1, cp2 = _scene_with_two_checkpoints(session)
crud.rollback_to_checkpoint(session, game, deep, cp1.id)
session.refresh(game)
assert game.rollback_head_checkpoint_id == cp1.id
# Rolling forward to the latest checkpoint redoes the rollback and returns to live play.
ok, msg = crud.rollback_to_checkpoint(session, game, deep, cp2.id)
assert ok, msg
session.refresh(game)
assert game.rollback_head_checkpoint_id is None
def test_action_after_rollback_truncates_and_bumps(session):
game, deep, _, cp1, cp2 = _scene_with_two_checkpoints(session)
v0 = game.rollback_timeline_version
crud.rollback_to_checkpoint(session, game, deep, cp1.id)
# A new action from the rolled-back state commits the new timeline: maintain_rollback_history
# discards the abandoned future, clears the head, bumps the version, and captures anew.
crud.add_game_event(session, game.id, "New branch", kind="info")
crud.maintain_rollback_history(session, game)
session.refresh(game)
events = _events(session, game)
msgs = {e.message for e in events}
assert "Second" not in msgs # abandoned future discarded
assert {"First", "New branch"} <= msgs
# cp1 + the freshly captured checkpoint (note: SQLite may reuse cp2's freed id, which
# is safe — every event that referenced it was deleted in the same step).
assert len(_checkpoints(session, game)) == 2
new_event = next(e for e in events if e.message == "New branch")
assert new_event.checkpoint_id and new_event.checkpoint_id > 0
assert game.rollback_head_checkpoint_id is None
assert game.rollback_timeline_version == v0 + 1
def test_rollback_permissions(session):
@@ -2151,16 +2191,23 @@ def test_scene_end_seals_and_purges(session):
crud.seal_and_purge(session, game)
crud.add_game_event(session, game.id, "Mid-scene", kind="info")
cp = crud.capture_checkpoint(session, game)
assert len(_checkpoints(session, game)) == 1
crud.add_game_event(session, game.id, "Later", kind="info")
crud.capture_checkpoint(session, game) # a later checkpoint so the rollback sets a head
crud.rollback_to_checkpoint(session, game, deep, cp.id) # leave with a head set
session.refresh(game)
assert game.rollback_head_checkpoint_id == cp.id
assert len(_checkpoints(session, game)) == 2
# Leaving the scene runs the non-scene branch: history is purged and sealed.
game.phase = "between_scenes"
session.add(game)
session.commit()
crud.maintain_rollback_history(session, game)
session.refresh(game)
assert _checkpoints(session, game) == []
assert all(e.checkpoint_id == 0 for e in _events(session, game))
assert game.rollback_head_checkpoint_id is None
# The now-stale checkpoint id is rejected (back in a scene, even).
game.phase = "scene"
session.add(game)
@@ -2211,13 +2258,30 @@ def test_rollback_middleware_capture_and_route_end_to_end():
assert session.get(Obstacle, obs.id).current_value == 10
assert len(_checkpoints(session, game)) == 2 # floor + the one the middleware took
# Roll back to the floor over HTTP.
# Roll back to the floor over HTTP: state reverts, but the later checkpoint is
# kept (greyed/undoable) and the head points at the floor.
r = client.post(f"/api/game/{game.id}/player/{deep.id}/rollback",
data={"checkpoint_id": floor.id})
assert r.status_code == 200
session.expire_all()
assert session.get(Obstacle, obs.id).current_value == 5
assert [c.id for c in _checkpoints(session, game)] == [floor.id]
assert len(_checkpoints(session, game)) == 2
session.refresh(game)
assert game.rollback_head_checkpoint_id == floor.id
v_before = game.rollback_timeline_version
# A new action from the rolled-back state (the floor restored the open challenge
# and the played card to p2's hand): the middleware truncates the abandoned future,
# clears the head, and bumps the version.
r = client.post(f"/api/game/{game.id}/player/{p2.id}/play-card",
data={"obstacle_id": obs.id, "card_code": "10D"})
assert r.status_code == 200
session.expire_all()
session.refresh(game)
assert session.get(Obstacle, obs.id).current_value == 10
assert game.rollback_head_checkpoint_id is None
assert game.rollback_timeline_version == v_before + 1
assert len(_checkpoints(session, game)) == 2 # floor + the new branch's checkpoint
finally:
main_module.engine = saved_engine
app.dependency_overrides.clear()