LimitRequestBodyMiddleware (pure ASGI, registered outermost) rejects request
bodies larger than PIRATS_MAX_BODY_BYTES (default 1 MiB) before they're buffered
into memory: it checks the declared Content-Length first, then counts the bytes
actually streamed so a chunked/length-omitting client can't bypass the header
check. Exposed as services.pirats.maxBodyBytes and documented in the README.
Tested in isolation (Content-Length fast path + streamed path) and through the
real app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Player-authored free text is scrubbed at the API boundary before storage via a
new validation.py: drop control characters and unpaired surrogates, trim
whitespace, and cap length (names 120, other text 2000). Applied to crew/player
names, the character sheet, like/hate answers, techniques, recruit techniques,
the renamed Name, the Gat description, and challenge stakes. Values matched
against stored text (swap offers, face-card assignments) and identifiers/enums
are left untouched so they still compare equal.
Constrained set-role to the two real roles so it can't stash an arbitrary string
in the column. Audited the rest: queries are parameterized by SQLModel (the lone
f-string SQL in database.py is the hardcoded legacy-migration list, no user
input); objective-type and rollback writes already whitelist their keys; no
endpoint accepts a generic (field, value) write.
Tests cover the sanitizer (control chars, surrogates, emoji, caps) and the
create/join HTTP path end-to-end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New maintenance.py runs a background asyncio loop (started from the app
lifespan) that purges games which either finished more than PIRATS_PURGE_
FINISHED_DAYS (default 14) ago or have had no activity for PIRATS_PURGE_
INACTIVE_DAYS (default 30). "Activity" is a game's most-recent GameEvent
timestamp; games with no events are undatable and left alone.
Each purge is logged with the crew name, uuid, reason, idle days and an
estimated byte footprint; every run that removes anything VACUUMs the SQLite
file and logs the disk space actually reclaimed. Child rows go via the ORM
cascade already declared on Game's relationships.
Configurable via PIRATS_PURGE_ENABLED / _INTERVAL_HOURS / _FINISHED_DAYS /
_INACTIVE_DAYS, exposed as services.pirats.purge.* in the NixOS module and
documented in the README. Unit test covers the selection logic and cascade;
smoke-tested the VACUUM/reclaim path against a file DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Crew roster bubbles: explicit text color + opaque distinct surface so
they're legible and stand out from the page in both themes (the bug was
the <button> defaulting to black-on-dark in dark mode).
- End-Scene enforcement: unless Dev Mode is on, a scene can't end until
every living Pi-Rat has faced a Deep Challenge or the Obstacle List is
empty. end_scene_and_transition now returns (ok, msg) and the route
surfaces a 400; the Deep sees a ✓/○ challenge-progress badge on each
Pi-Rat bubble. 3 new tests cover the gate.
- Hand panel: dropped the how-to-play blurb and the title in favor of a
low-profile "Your Hand" label.
- Bump VERSION to 8 with changelog entry; check off TODO.md Polish items.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completing Crew Objective 1 (Steal a Ship) no longer anoints the
highest-ranked Pi-Rat as Captain (nor vacates the seat when the ship is
lost). Captaincy is now earned: the crew chooses one via the Captaincy
UI / set_captain and Crew Objective 2. Decoupled captaincy from crew_1
in toggle_objective, dropped the now-unused random import, and updated
test_captaincy_and_hand_sizes accordingly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Deep now states what happens on success and on failure as two
separate fields when calling a Challenge, instead of one free-text
stakes blob. ChallengePanel shows them as "✅ On success" / "❌ On
failure"; the event log lists both.
- Challenge.stakes_success / stakes_failure (+ migration); the old
single `stakes` column is kept (vestigial) so in-scene rollback
snapshots taken before the upgrade still deserialize, and ChallengePanel
falls back to it for any pre-existing challenge.
- create_challenge / route / DeepControlPanel updated; tests adjusted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
Adds a "🚪 Leave Game" option to the corner menu for every player, so someone
who's done can remove themselves instead of waiting for an admin to kick them.
leave_game reuses the kick mechanics (cards to discard, reference cleanup,
phase re-check) via a shared _remove_player helper; it logs a friendlier
"left the crew" event and, like kick, refuses the last Admin so the table
isn't orphaned. The self-service route POST /game/{gid}/player/{pid}/leave
lives next to join and authenticates on the player's own id (no admin key).
The handler confirms, forgets the saved session, and routes home.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Admins can now kick players from the Admin panel so a vanished player can't
stall the table. crud.kick_player removes a player mid-game: it drops their
captaincy, deletes their votes and any Challenge they're part of, and repairs
every other player's pointers to them (pending swap offers are cleared;
delegated Like/Hate questions and recruit technique slots are handed to a
present crewmate, or pool-filled when no one is left). The row is then deleted
— a kicked player's hand simply stops being "in play", so it returns to the
discard automatically. Finally recheck_phase_completion advances the phase if
the kicked player was the last thing it was waiting on.
Auth mirrors set-admin (admin-key on POST /api/game/{gid}/admin/kick). The one
guard is that the last remaining Admin can't be kicked, so the table is never
locked out of its own panel; a vanished creator can still be kicked once a
co-admin exists. The Admin panel gains a per-row Kick button (with a confirm),
shown as "—" for the last admin.
To support re-checking phase completion after a kick, each phase's
"everyone's done -> advance" gate was extracted into an idempotent maybe_*
helper, and recheck_phase_completion dispatches the right one per Game.phase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dead and awaiting-recruit Pi-Rats can no longer be nominated to rank up
(they'd reset to Rank 1 anyway). submit_rank_vote rejects them, and the
upkeep dropdown only lists living, in-play crewmates via the shared
eligible_vote_nominees helper.
When a voter has no eligible nominees (e.g. a lone survivor whose only
crewmates are the previous Deep and a dead rat) they now skip voting
entirely and can ready up directly, instead of deadlocking — which is
why dead players were previously left nominable. The roster shows such
players as "No vote".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Players can now unlock and revise any reasonably revisable choice while
character creation is ongoing: basic Rat Records (also in recruit
creation), submitted techniques (until the swap phase begins), J/Q/K
face-card assignments (until everyone is ready), and Like/Hate answers
written for crewmates. New endpoints unsubmit-techniques and
unassign-face-techniques clear the locked-in state server-side so the
phase machine waits for the revision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enforced server-side in lobby/start and confirm_scene_setup via
crud_base.has_min_players; Dev Mode bypasses it so local single-player
testing still works. Lobby and scene-setup UIs show a player-count
message instead of the start button when below the minimum.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds Player.is_admin (creator starts with it; backfilled by migration).
Admin-key-authenticated POST /admin/set-admin grants/revokes it from the
admin panel; the creator's privileges can't be revoked. Admin-gated
backend routes (dev mode, skip character creation) and frontend controls
(corner menu, lobby start, scene start, end story) now check is_admin
instead of is_creator, and admins get the admin_key in their state blob
so granted players can open the admin panel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>