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:
2026-06-13 06:02:32 -07:00
parent e7db19f27e
commit a7f174b214
11 changed files with 595 additions and 67 deletions

View File

@@ -1,18 +1,22 @@
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session
import logging
import re
import uvicorn
import os
from pathlib import Path
from . import crud
from .database import run_migrations, get_session
from .database import run_migrations, get_session, engine
from .ws import manager
logger = logging.getLogger(__name__)
EVENT_PAGE_SIZE = 50
BASE_DIR = Path(__file__).parent
@@ -28,6 +32,19 @@ api = APIRouter()
# Every state mutation is a POST under /api/game/{game_id}/..., so one
# middleware can notify a game's websocket clients after any successful write.
GAME_PATH_RE = re.compile(r"^/api/game/([^/]+)/")
# The full-state rollback endpoint manages its own history, so it's the one mutation
# we don't checkpoint. Matched precisely (not a bare "/rollback" suffix) so it stays
# unambiguous if other "*/rollback" endpoints are ever added.
STATE_ROLLBACK_PATH_RE = re.compile(r"/player/[^/]+/rollback$")
def _checkpoint_after_mutation(game_id: str):
"""After a successful mutation, maintain rollback history: snapshot the state
while a scene is in progress, otherwise clear any scene's history. Runs in its own
session (the request's has already committed and closed by the time this fires)."""
with Session(engine) as db:
game = crud.get_game(db, game_id)
if game:
crud.maintain_rollback_history(db, game)
@app.middleware("http")
async def broadcast_state_changes(request, call_next):
@@ -35,7 +52,16 @@ async def broadcast_state_changes(request, call_next):
if request.method == "POST" and response.status_code < 400:
match = GAME_PATH_RE.match(request.url.path)
if match:
await manager.broadcast(match.group(1), {"type": "state_changed"})
game_id = match.group(1)
# The full-state rollback endpoint manages its own state; don't checkpoint it.
if not STATE_ROLLBACK_PATH_RE.search(request.url.path):
try:
await run_in_threadpool(_checkpoint_after_mutation, game_id)
except Exception:
# A checkpointing failure must not fail the user's action, which
# has already committed; the next mutation will checkpoint again.
logger.exception("Checkpoint capture failed for game %s", game_id)
await manager.broadcast(game_id, {"type": "state_changed"})
return response
@app.websocket("/api/game/{game_id}/ws")
@@ -137,6 +163,7 @@ from .routes_scene import router as scene_router
from .routes_challenge import router as challenge_router
from .routes_upkeep import router as upkeep_router
from .routes_admin import router as admin_router
from .routes_rollback import router as rollback_router
api.include_router(lobby_router)
api.include_router(character_router)
@@ -144,6 +171,7 @@ api.include_router(scene_router)
api.include_router(challenge_router)
api.include_router(upkeep_router)
api.include_router(admin_router)
api.include_router(rollback_router)
# Mount API at /api
app.include_router(api, prefix="/api")