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