Compare commits

..

4 Commits

Author SHA1 Message Date
56cdebdeda 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>
2026-06-13 09:03:52 -07:00
6964aeabb6 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>
2026-06-13 08:25:06 -07:00
0bce291ab9 Ignore Emacs autosave files 2026-06-13 06:04:53 -07:00
a7f174b214 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>
2026-06-13 06:02:56 -07:00
13 changed files with 781 additions and 68 deletions

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ result
src/pirats/static/
# Machine-local Claude Code permission approvals
.claude/settings.local.json
# Emacs autosaves
\#*

View File

@@ -1,5 +1,5 @@
## Major features
- [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin.
- [x] **Rollback of game state.** Full-state JSON snapshots in a `Checkpoint` table, captured per action by the broadcast middleware, restored by `crud_rollback.rollback_to_checkpoint`; scene-confined; server-enforced for Admins and Deep players. Rolling back is non-destructive — it moves a head pointer (`Game.rollback_head_checkpoint_id`), so later log entries grey out and can be redone (roll forward); the abandoned future is only discarded when a new action is taken. UI is the per-event rollback/redo buttons + greyed entries + "rolled back" banner in `EventLog.svelte`.
## Words Words Words
@@ -10,3 +10,8 @@
## Security
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates.
## Cleanup
- [ ] **Look for dead code.** While implementing the rollback feature, we discovered /scene/rollback, an unused and buggy single-card-play rollback route that was added but never hooked up to the UI. Look for other code that matches that pattern -- orphan functions and routes that never get called.
- [ ] **Clean up ruff E701 warnings**

View File

@@ -33,6 +33,19 @@
let haveMore = true;
let loadingOlder = false;
let atBottom = true;
let error = '';
let timelineVersion = null;
// Rollback buttons appear during a scene, for Admins and Deep players (server-enforced too).
$: canRollback = state.game?.phase === 'scene'
&& (state.player?.is_admin || state.player?.role === 'deep');
// 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.
$: 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);
@@ -40,6 +53,15 @@
// otherwise events the player paged back to would vanish as the window slides.
async function mergePolled(polled) {
if (!polled) return;
// Plain rollback/redo keeps every event (only `head` moves); but taking a new
// action from a rolled-back state truncates the abandoned future and bumps the
// version, which tells us to drop what we'd accumulated and reseed.
const version = state.game?.rollback_timeline_version ?? 0;
if (timelineVersion !== null && version !== timelineVersion) {
events = [];
seenIds = new Set();
}
timelineVersion = version;
if (events.length === 0) {
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
}
@@ -107,6 +129,23 @@
function formatTime(ts) {
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Both rollback (to a past event) and redo (to a greyed future event) are the same
// call — it just moves the head. It's reversible until someone takes a new action,
// so no confirm; the greyed future makes the state obvious.
async function rollback(event) {
error = '';
try {
await apiRequest(
`/game/${state.game.id}/player/${state.player.id}/rollback`,
'POST',
{ checkpoint_id: event.checkpoint_id },
);
// The state-changed ping drives Dashboard to refetch the restored state.
} catch (e) {
error = e.message;
}
}
</script>
<div class="floating-event-log {open ? 'open' : ''}">
@@ -115,6 +154,14 @@
</button>
{#if open}
{#if error}
<div class="log-error">{error}</div>
{/if}
{#if head !== null}
<div class="log-rolledback">
↩ Rolled back — greyed entries are undone. Click one to redo, or take an action to make it permanent.
</div>
{/if}
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
{#if events.length}
<div class="log-top">
@@ -127,10 +174,18 @@
{/if}
</div>
{#each events as event (event.id)}
<div class="log-entry log-kind-{event.kind || 'info'}">
{@const isFuture = head !== null && event.checkpoint_id > head}
<div class="log-entry log-kind-{event.kind || 'info'}" class:greyed={isFuture}>
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
<span class="log-message">{event.message}</span>
<span class="log-time">{formatTime(event.timestamp)}</span>
{#if canRollback && event.checkpoint_id > 0 && event.checkpoint_id !== currentPos}
<button
class="rollback-btn"
title={isFuture ? 'Redo forward to just after this event' : 'Roll the game back to just after this event'}
on:click={() => rollback(event)}
>{isFuture ? '↪' : '↩'}</button>
{/if}
</div>
{/each}
{:else}
@@ -211,7 +266,7 @@
}
.log-entry {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-columns: auto 1fr auto auto;
gap: 8px;
align-items: baseline;
padding: 6px 8px;
@@ -222,6 +277,45 @@
line-height: 1.35;
color: color-mix(in srgb, var(--text) 85%, var(--text-muted));
}
.rollback-btn {
background: transparent;
border: 1px solid var(--edge);
color: var(--text-muted);
border-radius: 6px;
padding: 0 6px;
font-size: 0.8rem;
line-height: 1.4;
cursor: pointer;
align-self: center;
}
.rollback-btn:hover {
background: color-mix(in srgb, var(--danger) 18%, transparent);
border-color: var(--danger);
color: var(--text);
}
.log-error {
margin: 6px 8px 0;
padding: 6px 10px;
background: color-mix(in srgb, var(--danger) 14%, transparent);
border: 1px solid var(--danger);
border-radius: var(--radius-md);
color: var(--text);
font-size: 0.8rem;
}
.log-rolledback {
margin: 6px 8px 0;
padding: 6px 10px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid var(--accent);
border-radius: var(--radius-md);
color: var(--text);
font-size: 0.78rem;
line-height: 1.3;
}
.log-entry.greyed {
opacity: 0.45;
font-style: italic;
}
.log-icon {
font-size: 0.9rem;
}

View File

@@ -4,3 +4,4 @@ from .crud_character import *
from .crud_scene import *
from .crud_challenge import *
from .crud_upkeep import *
from .crud_rollback import *

216
src/pirats/crud_rollback.py Normal file
View File

@@ -0,0 +1,216 @@
"""Game-state rollback via full snapshots.
The whole of a game's gameplay state lives in a handful of small, `game_id`-scoped
tables, so a "checkpoint" is just a JSON dump of those rows. Rolling back = restoring
the dump. This sidesteps deterministic replay entirely (the deck uses random.shuffle,
whose result is materialized into state and captured verbatim).
Scope (deliberately confined to keep the edge cases out — see the plan):
- Checkpoints are captured only while the game is in the `scene` phase, one per action.
- Leaving a scene purges that scene's checkpoints, so any surviving checkpoint is in
the current scene. Rollback therefore can never cross a scene boundary.
The event log (`GameEvent`) is an append-only spine and is NOT part of a snapshot.
Each event carries a `checkpoint_id` instead (see models.py):
None = in-flight (created since the last capture; the next capture tags it)
> 0 = a live rollback target within the current scene
0 = sealed/historical (a past scene or non-scene event; never rollback-able)
Rolling back is non-destructive: it restores a checkpoint's snapshot into the live
tables and points `Game.rollback_head_checkpoint_id` at it, leaving the later
checkpoints/events in place (the frontend greys them). Rolling forward to a later
checkpoint "undoes" the rollback. The abandoned future is only discarded when a new
action is taken from a rolled-back state (see maintain_rollback_history), which is
what commits the new timeline.
"""
import json
from typing import Optional, Tuple
from sqlmodel import Session, select
from .models import Game, Player, Obstacle, Challenge, Vote, GameEvent, Checkpoint
# Game columns that are rollback *control* state, not gameplay — never snapshotted,
# and preserved (not overwritten) when a snapshot is restored.
SNAPSHOT_EXCLUDE = {"rollback_timeline_version", "rollback_head_checkpoint_id"}
# Additionally never reassigned on restore (identity / secrets that don't change).
_APPLY_SKIP = SNAPSHOT_EXCLUDE | {"id", "admin_key"}
# The child tables captured in a snapshot, in the order rows are re-inserted.
_CHILD_MODELS = (Player, Obstacle, Challenge, Vote)
def serialize_game_state(game: Game) -> str:
"""Dump a game's gameplay state (Game minus control columns, plus all child rows)
to a JSON string. The event log is intentionally excluded."""
return json.dumps({
"game": game.model_dump(exclude=SNAPSHOT_EXCLUDE),
"players": [p.model_dump() for p in game.players],
"obstacles": [o.model_dump() for o in game.obstacles],
"challenges": [c.model_dump() for c in game.challenges],
"votes": [v.model_dump() for v in game.votes],
})
def apply_game_state(db: Session, game: Game, state_json: str) -> None:
"""Restore a serialized snapshot into the live tables: overwrite the Game row's
gameplay columns, then replace all child rows (re-inserted with their original ids
so foreign keys and frontend references stay stable)."""
blob = json.loads(state_json)
for key, value in blob["game"].items():
if key not in _APPLY_SKIP:
setattr(game, key, value)
db.add(game)
# Wipe existing children via the ORM (keeps the identity map consistent so we can
# re-insert rows with the same primary keys), then flush before re-inserting.
for model in _CHILD_MODELS:
for row in db.exec(select(model).where(model.game_id == game.id)).all():
db.delete(row)
db.flush()
for row in blob["players"]:
db.add(Player(**row))
for row in blob["obstacles"]:
db.add(Obstacle(**row))
for row in blob["challenges"]:
db.add(Challenge(**row))
for row in blob["votes"]:
db.add(Vote(**row))
db.commit()
def capture_checkpoint(db: Session, game: Game) -> Checkpoint:
"""Snapshot the post-action state and tag this action's events with it.
Called from the middleware after a successful mutation while in the `scene` phase.
The in-flight events (checkpoint_id IS NULL) are exactly this action's events, since
every prior event is already tagged (>0) or sealed (0)."""
cp = Checkpoint(game_id=game.id, state_json=serialize_game_state(game))
db.add(cp)
db.commit()
db.refresh(cp)
untagged = db.exec(
select(GameEvent).where(
GameEvent.game_id == game.id,
GameEvent.checkpoint_id.is_(None),
)
).all()
for e in untagged:
e.checkpoint_id = cp.id
db.add(e)
db.commit()
return cp
def seal_and_purge(db: Session, game: Game) -> None:
"""Clear a game's rollback history. Called from the middleware after any mutation
outside the `scene` phase, so leaving a scene tidies up and the next scene starts
clean: every still-active event is sealed to 0 (committed history) and all
checkpoints are deleted."""
active = db.exec(
select(GameEvent).where(
GameEvent.game_id == game.id,
# NULL (in-flight) or >0 (current-scene targets); 0 is already sealed.
(GameEvent.checkpoint_id.is_(None)) | (GameEvent.checkpoint_id > 0),
)
).all()
for e in active:
e.checkpoint_id = 0
db.add(e)
for cp in db.exec(select(Checkpoint).where(Checkpoint.game_id == game.id)).all():
db.delete(cp)
game.rollback_head_checkpoint_id = None
db.add(game)
db.commit()
def _discard_future(db: Session, game: Game, after_checkpoint_id: int) -> None:
"""Delete the checkpoints (and their events) created after a checkpoint — the
abandoned timeline when a new action is taken from a rolled-back state."""
for e in db.exec(
select(GameEvent).where(
GameEvent.game_id == game.id,
GameEvent.checkpoint_id > after_checkpoint_id,
)
).all():
db.delete(e)
for c in db.exec(
select(Checkpoint).where(
Checkpoint.game_id == game.id,
Checkpoint.id > after_checkpoint_id,
)
).all():
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:
"""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
decision the middleware makes; kept here so it can be unit-tested without HTTP."""
if game.phase != "scene":
seal_and_purge(db, game)
return
# A new action taken from a rolled-back state commits the new timeline: discard the
# abandoned future first (its events are tagged with checkpoints past the head),
# then clear the head and bump the version so the frontend drops those events.
# (SQLite may reuse a discarded checkpoint's id for the next capture; that's safe
# since every event referencing it was just deleted.)
head = game.rollback_head_checkpoint_id
if head is not None:
_discard_future(db, game, head)
game.rollback_head_checkpoint_id = None
game.rollback_timeline_version += 1
db.add(game)
db.commit()
capture_checkpoint(db, game)
def rollback_to_checkpoint(
db: Session, game: Game, player: Player, checkpoint_id: int
) -> Tuple[bool, str]:
"""Restore the game to the given checkpoint and point the head at it.
Non-destructive: later checkpoints/events are kept (the frontend greys them) so the
rollback can be undone by rolling forward again; the same call serves both rollback
and redo. Rolling to the latest checkpoint clears the head (back to live play).
Server-enforced: only during a scene, only by an Admin or a Deep player, and only
to a checkpoint belonging to this game (which guarantees the current scene, since
older scenes' checkpoints have been purged)."""
if game.phase != "scene":
return False, "Rollback is only available during a scene."
if not (player.is_admin or player.role == "deep"):
return False, "Only an Admin or a Deep player can roll back the game."
cp = db.get(Checkpoint, checkpoint_id)
if not cp or cp.game_id != game.id:
return False, "That rollback point is no longer available."
apply_game_state(db, game, cp.state_json)
latest = db.exec(
select(Checkpoint).where(Checkpoint.game_id == game.id)
.order_by(Checkpoint.id.desc())
).first()
game.rollback_head_checkpoint_id = None if checkpoint_id == latest.id else checkpoint_id
db.add(game)
db.commit()
return True, "Rolled back."

View File

@@ -379,54 +379,6 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
status_text = "completed" if status else "unmarked"
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]:
obstacle = db.get(Obstacle, obstacle_id)
if not obstacle:
return False, "Obstacle not found."
played_list = json.loads(obstacle.played_cards)
if not played_list:
return False, "No cards to roll back."
last_play = played_list.pop()
card_code = last_play["card"]
player_id = last_play["player_id"]
# Update obstacle's value
if played_list:
prev_card_code = played_list[-1]["card"]
obstacle.current_value = cards.parse_card(prev_card_code)["numeric_value"]
else:
obstacle.current_value = cards.get_obstacle_info(obstacle.original_card)["initial_value"]
obstacle.played_cards = json.dumps(played_list)
db.add(obstacle)
# Remove the matching play from any open challenge that applied this obstacle
player = get_player(db, player_id)
if player:
game = get_game(db, player.game_id)
if game:
for challenge in game.challenges:
if challenge.status != "open":
continue
plays = json.loads(challenge.plays)
for i in range(len(plays) - 1, -1, -1):
p = plays[i]
if p.get("obstacle_id") == obstacle_id and p.get("card") == card_code and p.get("player_id") == player_id:
plays.pop(i)
challenge.plays = json.dumps(plays)
db.add(challenge)
break
# Return card to player's hand
hand = get_player_hand(player)
hand.append(card_code)
set_player_hand(player, hand)
db.add(player)
db.commit()
return True, "Rolled back the last played card."
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
obstacle = db.get(Obstacle, obstacle_id)
if obstacle:

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")
@@ -109,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],
"events": [e.model_dump() for e in events],
"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")
@@ -137,6 +165,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 +173,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")

View File

@@ -0,0 +1,55 @@
"""add rollback checkpoints
Revision ID: 480e6d83109e
Revises: b306c59e9cc2
Create Date: 2026-06-13 05:44:11.123047
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '480e6d83109e'
down_revision = 'b306c59e9cc2'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('checkpoint',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('game_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.Float(), nullable=False),
sa.Column('state_json', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['game_id'], ['game.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('checkpoint', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_checkpoint_game_id'), ['game_id'], unique=False)
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.add_column(sa.Column('rollback_timeline_version', sa.Integer(), nullable=False, server_default=sa.text('0')))
with op.batch_alter_table('gameevent', schema=None) as batch_op:
batch_op.add_column(sa.Column('checkpoint_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_gameevent_checkpoint_id'), ['checkpoint_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('gameevent', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_gameevent_checkpoint_id'))
batch_op.drop_column('checkpoint_id')
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.drop_column('rollback_timeline_version')
with op.batch_alter_table('checkpoint', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_checkpoint_game_id'))
op.drop_table('checkpoint')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""add rollback head pointer
Revision ID: e6294f6f8a81
Revises: 480e6d83109e
Create Date: 2026-06-13 06:08:17.170588
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = 'e6294f6f8a81'
down_revision = '480e6d83109e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.add_column(sa.Column('rollback_head_checkpoint_id', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.drop_column('rollback_head_checkpoint_id')
# ### end Alembic commands ###

View File

@@ -12,6 +12,8 @@ class Game(SQLModel, table=True):
current_scene_number: int = Field(default=1)
extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles)
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
rollback_timeline_version: int = Field(default=0) # Bumped whenever a rollback truncates events; lets the frontend reset its accumulated log. NEVER snapshotted (see crud_rollback.SNAPSHOT_EXCLUDE)
rollback_head_checkpoint_id: Optional[int] = Field(default=None) # When set, the game is viewing a rolled-back state at this checkpoint; events after it are the greyed, still-undoable future. None = live at the latest. NEVER snapshotted.
completed_crew_1: bool = Field(default=False) # Steal a Ship
completed_crew_2: bool = Field(default=False) # Choose a Captain
completed_crew_3: bool = Field(default=False) # Commit Piracy
@@ -22,6 +24,7 @@ class Game(SQLModel, table=True):
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True)
challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True)
checkpoints: List["Checkpoint"] = Relationship(back_populates="game", cascade_delete=True)
class Player(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
@@ -132,5 +135,21 @@ class GameEvent(SQLModel, table=True):
timestamp: float = Field(default_factory=time.time)
message: str = Field(...)
kind: str = Field(default="info") # category for frontend icons/styling: "join", "phase", "scene", "captain", "challenge", "card", "tax", "objective", "vote", "obstacle", "joker", "victory", "warning", "rank", "info"
# Links an event to the Checkpoint captured by its action (see crud_rollback):
# None = in-flight (created since the last capture; next capture tags it)
# > 0 = a live rollback target within the current scene
# 0 = sealed/historical (a past scene or non-scene event; never rollback-able)
checkpoint_id: Optional[int] = Field(default=None, index=True)
game: Optional[Game] = Relationship(back_populates="events")
class Checkpoint(SQLModel, table=True):
"""A full snapshot of a game's gameplay state, captured after each action during
a scene so it can be rolled back to. The event log itself is NOT snapshotted (it
is an append-only spine); rollback truncates it separately. See crud_rollback."""
id: Optional[int] = Field(default=None, primary_key=True) # autoincrement; the rollback target the frontend sends
game_id: str = Field(foreign_key="game.id", index=True)
created_at: float = Field(default_factory=time.time)
state_json: str = Field(...) # serialized gameplay snapshot (Game minus control cols + Players/Obstacles/Challenges/Votes)
game: Optional[Game] = Relationship(back_populates="checkpoints")

View File

@@ -0,0 +1,28 @@
from fastapi import APIRouter, Form, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlmodel import Session
from .database import get_session
from . import crud
router = APIRouter()
# Roll the game back to a checkpoint. Player-id authenticated; the actual permission
# check (Admin or Deep player, during a scene) lives in crud.rollback_to_checkpoint.
# The "/rollback" path suffix is how the broadcast middleware knows to skip capturing
# a checkpoint for this request.
@router.post("/game/{game_id}/player/{player_id}/rollback")
def rollback_route(
game_id: str,
player_id: str,
checkpoint_id: int = Form(...),
db: Session = Depends(get_session),
):
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player or player.game_id != game.id:
raise HTTPException(status_code=404, detail="Game or Player not found")
ok, msg = crud.rollback_to_checkpoint(db, game, player, checkpoint_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}

View File

@@ -106,18 +106,6 @@ def toggle_objective_route(
return {"status": "ok"}
# Deep rollbacks a card play
@router.post("/game/{game_id}/scene/rollback")
def rollback_card_play_route(
game_id: str,
obstacle_id: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.rollback_card_play(db, obstacle_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Pi-Rat sets a new name
@router.post("/game/{game_id}/player/{player_id}/set-name")
def set_name_route(

View File

@@ -1,9 +1,9 @@
import pytest
import json
from sqlmodel import SQLModel, create_engine, Session
from sqlmodel import SQLModel, create_engine, Session, select
from pirats import cards
from pirats import crud
from pirats.models import Obstacle
from pirats.models import Obstacle, Player, Challenge, GameEvent, Checkpoint
# In-memory database for testing
@pytest.fixture(name="session")
@@ -2037,3 +2037,294 @@ def test_leave_endpoint_and_last_admin_guard():
assert crud.get_player(session, creator.id) is None
finally:
app.dependency_overrides.clear()
# --- Rollback ---
def _checkpoints(session, game):
return session.exec(
select(Checkpoint).where(Checkpoint.game_id == game.id).order_by(Checkpoint.id)
).all()
def _events(session, game):
return session.exec(select(GameEvent).where(GameEvent.game_id == game.id)).all()
def test_rollback_capture_tags_only_new_events(session):
game, deep, (p2,) = make_scene_game(session)
# Simulate the middleware having sealed the lobby/setup events (it does so on
# every non-scene mutation); now only fresh scene actions get tagged.
crud.seal_and_purge(session, game)
assert all(e.checkpoint_id == 0 for e in _events(session, game))
crud.add_game_event(session, game.id, "Action A", kind="info")
cp = crud.capture_checkpoint(session, game)
tagged = [e for e in _events(session, game) if e.message == "Action A"]
assert tagged and all(e.checkpoint_id == cp.id for e in tagged)
# The previously-sealed events are untouched.
assert all(e.checkpoint_id == 0 for e in _events(session, game) if e.message != "Action A")
def test_rollback_round_trip_restores_state(session):
game, deep, (p2,) = make_scene_game(session)
crud.seal_and_purge(session, game)
obs = game.obstacles[0]
p2.hand_cards = json.dumps(["10D", "2H", "JS", "3C"])
obs.current_value = 5
session.add_all([p2, obs])
session.commit()
saved_hand = json.loads(p2.hand_cards)
saved_deck = crud.get_game_deck(game)
crud.add_game_event(session, game.id, "Known state", kind="info")
cp_known = crud.capture_checkpoint(session, game)
# A destructive action: open a challenge and play a card against the obstacle.
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="x")
crud.capture_checkpoint(session, game)
crud.play_challenge_card(session, p2.id, obs.id, "10D")
crud.capture_checkpoint(session, game)
session.refresh(obs)
assert obs.current_value == 10 # changed
assert len(_checkpoints(session, game)) == 3
ok, msg = crud.rollback_to_checkpoint(session, game, deep, cp_known.id)
assert ok, msg
# State restored exactly; the challenge (created after cp_known) is gone.
obs_after = session.get(Obstacle, obs.id)
p2_after = session.get(Player, p2.id)
session.refresh(game)
assert obs_after.current_value == 5
assert crud.get_player_hand(p2_after) == saved_hand
assert crud.get_game_deck(game) == saved_deck
assert session.exec(select(Challenge).where(Challenge.game_id == game.id)).all() == []
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)
crud.add_game_event(session, game.id, "Second", kind="info")
cp2 = crud.capture_checkpoint(session, game)
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)
# 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_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):
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):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
crud.seal_and_purge(session, game)
crud.add_game_event(session, game.id, "Floor", kind="info")
cp = crud.capture_checkpoint(session, game)
# A plain Pi-Rat (not admin, not Deep) cannot roll back.
assert not p2.is_admin and p2.role == "pirat"
ok, msg = crud.rollback_to_checkpoint(session, game, p2, cp.id)
assert not ok
# A Deep player who is not an admin can.
p3.role = "deep"
p3.is_admin = False
session.add(p3)
session.commit()
ok, msg = crud.rollback_to_checkpoint(session, game, p3, cp.id)
assert ok, msg
# Not during a scene: rejected.
game.phase = "between_scenes"
session.add(game)
session.commit()
ok, msg = crud.rollback_to_checkpoint(session, game, deep, cp.id)
assert not ok
def test_scene_end_seals_and_purges(session):
game, deep, (p2,) = make_scene_game(session)
crud.seal_and_purge(session, game)
crud.add_game_event(session, game.id, "Mid-scene", kind="info")
cp = crud.capture_checkpoint(session, game)
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)
session.commit()
ok, msg = crud.rollback_to_checkpoint(session, game, deep, cp.id)
assert not ok
def test_rollback_middleware_capture_and_route_end_to_end():
"""Exercises the real middleware capture path: a normal scene mutation over HTTP
must create a checkpoint, and the rollback endpoint must restore the prior state.
The middleware uses pirats.main.engine directly, so point it at the test engine."""
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
import pirats.main as main_module
from pirats.database import get_session
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
game, deep, (p2,) = make_scene_game(session)
obs = game.obstacles[0]
p2.hand_cards = json.dumps(["10D", "2H", "JS", "3C"])
obs.current_value = 5
session.add_all([p2, obs])
session.commit()
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="x")
# A floor checkpoint representing the pre-play state.
floor = crud.capture_checkpoint(session, game)
def get_session_override():
yield session
app = main_module.app
app.dependency_overrides[get_session] = get_session_override
saved_engine = main_module.engine
main_module.engine = engine # so the capture middleware writes to the test DB
client = TestClient(app)
try:
# Normal scene mutation over HTTP -> the middleware should capture a checkpoint.
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()
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: 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 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()
def test_rollback_route_enforces_permission():
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from pirats.main import app
from pirats.database import get_session
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
game, deep, pirats = make_scene_game(session, num_pirats=2)
p2 = pirats[0]
crud.seal_and_purge(session, game)
crud.add_game_event(session, game.id, "Floor", kind="info")
cp = crud.capture_checkpoint(session, game)
def get_session_override():
yield session
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
try:
# A plain Pi-Rat is rejected by the endpoint.
r = client.post(f"/api/game/{game.id}/player/{p2.id}/rollback",
data={"checkpoint_id": cp.id})
assert r.status_code == 400
# The Deep/admin succeeds.
r = client.post(f"/api/game/{game.id}/player/{deep.id}/rollback",
data={"checkpoint_id": cp.id})
assert r.status_code == 200
assert r.json()["status"] == "ok"
finally:
app.dependency_overrides.clear()