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:
2
TODO.md
2
TODO.md
@@ -1,5 +1,5 @@
|
|||||||
## Major features
|
## 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.
|
- [~] **Rollback of game state.** *Phase 1 implemented* (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; event-log buttons in `EventLog.svelte`). **Phase 1 truncates the future immediately (no undo).** *Phase 2 (remaining):* the greyed-out / undoable redo UX — roll back without discarding the future, grey later entries, and only commit the truncation when a new action is posted. The schema is already forward-compatible for this (add `Game.rollback_head_checkpoint_id`).
|
||||||
|
|
||||||
## Words Words Words
|
## Words Words Words
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,12 @@
|
|||||||
let haveMore = true;
|
let haveMore = true;
|
||||||
let loadingOlder = false;
|
let loadingOlder = false;
|
||||||
let atBottom = true;
|
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');
|
||||||
|
|
||||||
$: mergePolled(state.events);
|
$: mergePolled(state.events);
|
||||||
|
|
||||||
@@ -40,6 +46,14 @@
|
|||||||
// otherwise events the player paged back to would vanish as the window slides.
|
// otherwise events the player paged back to would vanish as the window slides.
|
||||||
async function mergePolled(polled) {
|
async function mergePolled(polled) {
|
||||||
if (!polled) return;
|
if (!polled) return;
|
||||||
|
// A rollback truncates the log server-side; the version bump tells us to drop
|
||||||
|
// everything we'd accumulated (those future events no longer exist) 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) {
|
if (events.length === 0) {
|
||||||
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
||||||
}
|
}
|
||||||
@@ -107,6 +121,21 @@
|
|||||||
function formatTime(ts) {
|
function formatTime(ts) {
|
||||||
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function rollback(event) {
|
||||||
|
if (!confirm(`Roll the game back to just after "${event.message}"? Everything after it will be undone.`)) return;
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="floating-event-log {open ? 'open' : ''}">
|
<div class="floating-event-log {open ? 'open' : ''}">
|
||||||
@@ -115,6 +144,9 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
|
{#if error}
|
||||||
|
<div class="log-error">{error}</div>
|
||||||
|
{/if}
|
||||||
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
|
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
|
||||||
{#if events.length}
|
{#if events.length}
|
||||||
<div class="log-top">
|
<div class="log-top">
|
||||||
@@ -131,6 +163,13 @@
|
|||||||
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
|
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
|
||||||
<span class="log-message">{event.message}</span>
|
<span class="log-message">{event.message}</span>
|
||||||
<span class="log-time">{formatTime(event.timestamp)}</span>
|
<span class="log-time">{formatTime(event.timestamp)}</span>
|
||||||
|
{#if canRollback && event.checkpoint_id > 0}
|
||||||
|
<button
|
||||||
|
class="rollback-btn"
|
||||||
|
title="Roll the game back to just after this event"
|
||||||
|
on:click={() => rollback(event)}
|
||||||
|
>↩</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -211,7 +250,7 @@
|
|||||||
}
|
}
|
||||||
.log-entry {
|
.log-entry {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr auto auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
@@ -222,6 +261,31 @@
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
color: color-mix(in srgb, var(--text) 85%, var(--text-muted));
|
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-icon {
|
.log-icon {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ from .crud_character import *
|
|||||||
from .crud_scene import *
|
from .crud_scene import *
|
||||||
from .crud_challenge import *
|
from .crud_challenge import *
|
||||||
from .crud_upkeep import *
|
from .crud_upkeep import *
|
||||||
|
from .crud_rollback import *
|
||||||
|
|||||||
173
src/pirats/crud_rollback.py
Normal file
173
src/pirats/crud_rollback.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""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)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import 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"}
|
||||||
|
# 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)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
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":
|
||||||
|
capture_checkpoint(db, game)
|
||||||
|
else:
|
||||||
|
seal_and_purge(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 discard everything after it.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Truncate the future: events and checkpoints created after the target. Sealed
|
||||||
|
# history (0) and the target's own events (== checkpoint_id) are kept.
|
||||||
|
for e in db.exec(
|
||||||
|
select(GameEvent).where(
|
||||||
|
GameEvent.game_id == game.id,
|
||||||
|
GameEvent.checkpoint_id > checkpoint_id,
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
db.delete(e)
|
||||||
|
for c in db.exec(
|
||||||
|
select(Checkpoint).where(
|
||||||
|
Checkpoint.game_id == game.id,
|
||||||
|
Checkpoint.id > checkpoint_id,
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
db.delete(c)
|
||||||
|
|
||||||
|
# Bump so the frontend EventLog drops the events it had accumulated.
|
||||||
|
game.rollback_timeline_version += 1
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
return True, "Rolled back."
|
||||||
@@ -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"
|
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")
|
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):
|
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
||||||
obstacle = db.get(Obstacle, obstacle_id)
|
obstacle = db.get(Obstacle, obstacle_id)
|
||||||
if obstacle:
|
if obstacle:
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
|
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.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import crud
|
from . import crud
|
||||||
from .database import run_migrations, get_session
|
from .database import run_migrations, get_session, engine
|
||||||
from .ws import manager
|
from .ws import manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
EVENT_PAGE_SIZE = 50
|
EVENT_PAGE_SIZE = 50
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
@@ -28,6 +32,19 @@ api = APIRouter()
|
|||||||
# Every state mutation is a POST under /api/game/{game_id}/..., so one
|
# 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.
|
# middleware can notify a game's websocket clients after any successful write.
|
||||||
GAME_PATH_RE = re.compile(r"^/api/game/([^/]+)/")
|
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")
|
@app.middleware("http")
|
||||||
async def broadcast_state_changes(request, call_next):
|
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:
|
if request.method == "POST" and response.status_code < 400:
|
||||||
match = GAME_PATH_RE.match(request.url.path)
|
match = GAME_PATH_RE.match(request.url.path)
|
||||||
if match:
|
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
|
return response
|
||||||
|
|
||||||
@app.websocket("/api/game/{game_id}/ws")
|
@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_challenge import router as challenge_router
|
||||||
from .routes_upkeep import router as upkeep_router
|
from .routes_upkeep import router as upkeep_router
|
||||||
from .routes_admin import router as admin_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(lobby_router)
|
||||||
api.include_router(character_router)
|
api.include_router(character_router)
|
||||||
@@ -144,6 +171,7 @@ api.include_router(scene_router)
|
|||||||
api.include_router(challenge_router)
|
api.include_router(challenge_router)
|
||||||
api.include_router(upkeep_router)
|
api.include_router(upkeep_router)
|
||||||
api.include_router(admin_router)
|
api.include_router(admin_router)
|
||||||
|
api.include_router(rollback_router)
|
||||||
|
|
||||||
# Mount API at /api
|
# Mount API at /api
|
||||||
app.include_router(api, prefix="/api")
|
app.include_router(api, prefix="/api")
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -12,6 +12,7 @@ class Game(SQLModel, table=True):
|
|||||||
current_scene_number: int = Field(default=1)
|
current_scene_number: int = Field(default=1)
|
||||||
extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles)
|
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", ...]
|
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)
|
||||||
completed_crew_1: bool = Field(default=False) # Steal a Ship
|
completed_crew_1: bool = Field(default=False) # Steal a Ship
|
||||||
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
||||||
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
||||||
@@ -22,6 +23,7 @@ class Game(SQLModel, table=True):
|
|||||||
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
|
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
events: List["GameEvent"] = 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)
|
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):
|
class Player(SQLModel, table=True):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||||
@@ -132,5 +134,21 @@ class GameEvent(SQLModel, table=True):
|
|||||||
timestamp: float = Field(default_factory=time.time)
|
timestamp: float = Field(default_factory=time.time)
|
||||||
message: str = Field(...)
|
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"
|
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")
|
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")
|
||||||
|
|||||||
28
src/pirats/routes_rollback.py
Normal file
28
src/pirats/routes_rollback.py
Normal 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"}
|
||||||
@@ -106,18 +106,6 @@ def toggle_objective_route(
|
|||||||
|
|
||||||
return {"status": "ok"}
|
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
|
# Pi-Rat sets a new name
|
||||||
@router.post("/game/{game_id}/player/{player_id}/set-name")
|
@router.post("/game/{game_id}/player/{player_id}/set-name")
|
||||||
def set_name_route(
|
def set_name_route(
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import json
|
import json
|
||||||
from sqlmodel import SQLModel, create_engine, Session
|
from sqlmodel import SQLModel, create_engine, Session, select
|
||||||
from pirats import cards
|
from pirats import cards
|
||||||
from pirats import crud
|
from pirats import crud
|
||||||
from pirats.models import Obstacle
|
from pirats.models import Obstacle, Player, Challenge, GameEvent, Checkpoint
|
||||||
|
|
||||||
# In-memory database for testing
|
# In-memory database for testing
|
||||||
@pytest.fixture(name="session")
|
@pytest.fixture(name="session")
|
||||||
@@ -2037,3 +2037,224 @@ def test_leave_endpoint_and_last_admin_guard():
|
|||||||
assert crud.get_player(session, creator.id) is None
|
assert crud.get_player(session, creator.id) is None
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
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 test_rollback_truncates_future_and_bumps_version(session):
|
||||||
|
game, deep, (p2,) = make_scene_game(session)
|
||||||
|
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}
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
assert len(_checkpoints(session, game)) == 1
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
assert _checkpoints(session, game) == []
|
||||||
|
assert all(e.checkpoint_id == 0 for e in _events(session, game))
|
||||||
|
# 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.
|
||||||
|
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]
|
||||||
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user