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