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>
Add configure_logging() (main.py): always logs to stderr, plus a rotating
file when PIRATS_LOG_FILE is set; level from PIRATS_LOG_LEVEL (default INFO).
Called from both the CLI entrypoint and the app lifespan (idempotent), which
also logs startup/migration/shutdown.
Targeted server-side events for ops/debugging (not every game event): game
created, player joined, player removed (kick/leave), game ended.
NixOS service: new logFile (/var/log/pirats/pirats.log) and logLevel options,
wired to the env vars; LogsDirectory=pirats makes the path writable under the
sandboxed DynamicUser. README documents the env vars/options.
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>
Two scene-layout tweaks to the inline (third-column) Event Log:
- It can now be collapsed to a fixed corner button (✕ in its header) to declutter,
and reopened from that button. ScenePhase owns `logOpen` and reflows the grid to
two columns while collapsed; EventLog dispatches `collapse`.
- The panel now pins at top:3.5rem (matching .dashboard-container's top padding)
with max-height calc(100vh - 4.5rem), so its bottom stays on-screen at any scroll
position and .log-content scrolls internally — previously the bottom sat ~24px
below the fold at the top of the page.
Verified across Pi-Rat and Deep views, scroll extremes, and 3-col / 1-col widths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per playtest feedback:
- Drop the per-challenge 'Plays:' line — redundant with the card column and the
event log (also removes the now-unused chPlays const).
- Drop the 'Attempted by … drag a card … assist' how-to paragraph; players know
the basics, and the challenge header already names who's challenged.
- Replace the 'Red/Black Obstacle · match to draw' tag with the original card in
the title (e.g. '5♣ — Loitering Mermaid'), and color the title by suit. The
left border already marks color; the dead .obstacle-color-tag CSS is removed.
Suit glyphs in the title use plain text symbols (not SUIT_EMOJI) so they take
the theme-aware suit color and stay readable on the dark panel. Verified in
light and dark.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Cheesy Heist walkthrough was a wall of text. Add a card-state diagram at
each step (after the draw, and after each of the three Challenges) showing both
Obstacle columns as they grow — reusing the same overlapping-column visual as
the in-app obstacle display: original card at the back with a gold ring, plays
stacked in front (green = beat the difficulty, red = didn't), newest in full
view. Self-contained CSS in rules.html; card faces stay light parchment in both
themes. Verified in light/dark and on mobile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the big-original-card + row-of-minis layout with a single vertical
column: the original sits at the back (accent ring), each play stacks in front
with a heavy negative margin so only its rank+suit peeks out, and the latest
card is fully visible at the bottom. Played cards keep a green/red success ring;
the ChallengePanel's per-play list still credits each player.
Top-aligns the Current Difficulty / Successes boxes so they no longer stretch to
the now-taller card column. Matches the rulebook's column card layout and is far
more compact. Verified in the ChallengePanel, the obstacle list, and on mobile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Start tracking a version number and a player-facing changelog, surfaced from
the ☰ menu via a new About item.
- frontend/src/lib/changelog.js: VERSION (bump every commit) + CHANGELOG.
- AboutModal.svelte: shows the version and changelog using the shared modal CSS;
closes via ×, backdrop click, or Escape.
- CornerMenu.svelte: new 'ℹ️ About' menu item, modal rendered outside the menu's
stacking context so it overlays correctly on every page.
- AGENTS.md: documents the bump-every-commit / log-only-user-facing convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Returning players are the common case, so surface their saved crews first.
The two glass panels had no margin between them; add spacing on the
Rejoin box so it sits clear of the Muster box below it.
Also tidies the TODO 'Polish' section (renamed from 'UI Polish'; drops the
now-committed Rank-3 bonus 'Gameplay gaps' entry).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RankBonusModal.svelte, scene/ObstacleItem.svelte, and lib/tooltip.js were created during the 2026-06-15 scene-dashboard and Rank-3-bonus work and are imported by already-committed components (Dashboard, ChallengePanel, ObstacleBoard, Card, components.css), but were never git-added — so HEAD did not build on a clean checkout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up tweaks to the scene dashboard:
- Bubbles now show the human player name alongside the Pi-Rat name.
- The Captain is named "Captain <name>" (the "Recruit" honorific swapped,
or "Captain" prepended once Named) instead of a 🛞 icon — applied on
bubbles and the character-sheet/duel headings via new crewLabel/
captainName helpers in lib/cards.js.
- "Objectives" → "Crew Objectives" moved to the end of the roster as a
left-aligned toggle with a right-aligned X/3 completed counter; it now
slides the detailed list open in place (svelte/transition slide)
instead of opening a modal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the roster banner and the standalone character-sheet panel with
a left-hand column of crew bubbles. Each bubble shows the player's role
icon, name, rank, current hand size, and a 🛞 for the Captain; clicking
one pops out that player's character sheet as a modal.
- New CrewColumn.svelte: bubbles (Pi-Rats first, Deep last) + an
Objectives button that opens a crew-objectives popup.
- CharacterSheet.svelte is now a target-driven modal: description,
likes/hates, and personal objectives for anyone; secret J/Q/K only on
your own sheet; the Duel UI (no "Challenge whom?" — target is fixed)
only when a living Pi-Rat views another living Pi-Rat.
- ScenePhase.svelte → 3-column layout (crew | obstacles | hand/deep);
crew column collapses to a horizontal strip on narrow screens.
- Shared .modal-backdrop/.modal-box/.modal-close moved into components.css;
removed the now-dead roster-banner and captain-badge styles.
Backend unchanged — the state blob already carries hands, roles, ranks,
objectives, and the captain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
During between_scenes there's now just one centered panel with the
voting controls, nomination progress, and Crew Rank Ledger. The separate
"Resting Deep Upkeep" panel (which only said hand refresh would happen
later) is rendered solely during deep_upkeep, where the actual discard/
redraw happens. Moved the rank ledger into the voting panel and added a
.between-grid.single layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the name modal: earning the Gat objective sets
Player.needs_gat_description, and GatModal.svelte (rendered by Dashboard
across all phases) prompts for a one-of-a-kind Gat description, stored in
Player.gat_description and shown on the character sheet. Like a stolen
Name, the Gat's description travels with it through Gat Tax transfers.
- Player.gat_description / needs_gat_description (+ migration)
- toggle_objective personal_1 sets/clears the prompt
- crud_challenge gat-tax paths move the description with the Gat
- set-gat-description route; CharacterSheet displays it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the Event Log is collapsed, a freshly-arrived event now flashes as
a toast anchored above the button, then animates down and fades into it.
Suppressed on the initial state load and on rollback reseeds so it only
fires for genuinely new events; clicking the toast opens the log.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Admin page's back button now returns to this browser's own Pi-Rat
dashboard for the game (looked up from the saved sessions), labelled
"← Back to Game". Falls back to the join page only when this browser has
no saved session for the game.
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>
The post-voting (deep_upkeep) screen now names who won the rank-up vote
and their new rank, instead of just "Voting complete!". Adds
Game.last_rank_up_player_id (+ migration), set when votes are tallied
and cleared when the next scene begins; falls back to a "no one ranked
up" message when there were no votes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After a resting Deep player confirms their hand refresh, replace the
discard/keep drag editor with a confirmation panel showing the freshly
drawn hand and a "Waiting for the rest of the crew" message, instead of
re-rendering the editable drag UI (which made it look like nothing
happened). Also stops nagging "please complete your Hand Refresh" once
they're done. Visible whenever they remain in deep_upkeep waiting on
other resting Deep players.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An admin can now opt into "teaching mode" from the lobby, seeding
themselves as the Rank-3 player who must play the Deep in the first
scene (instead of leaving it to the random starting-rank roll), so they
can run the table for new crews.
- Game.teaching_deep_player_id (+ Alembic migration)
- maybe_finish_assign_techniques honors it before rolling ranks, so both
the normal flow and the dev-mode skip path respect it
- Admin-gated POST .../teaching-mode toggle (pre-rank phases only)
- Lobby checkbox UI, greyed out if another admin already volunteered
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking Revise on basic records, techniques, or J/Q/K assignment now
offers a Cancel button that throws away any edits made since Revise and
restores the previously-saved values. Basic-records cancel is a pure
client-side toggle; techniques/face cancel re-submit a snapshot taken at
Revise time (since Revise immediately unsubmits/unassigns server-side).
Also added Cancel to the recruit-creation basic-records revise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a Pi-Rat completes their second objective and ranks up, they now
get a blocking modal popup (NameModal.svelte, rendered by Dashboard
across all phases) to claim their Pirate Name, rather than an optional
box buried in their character sheet. Removed the old inline prompt-box
and its handler from CharacterSheet.svelte.
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>
Joining a game now records the session (game ID, player ID, plus
player/rat/crew names) in localStorage via the new lib/sessions.js, and
the dashboard keeps it enriched as state loads (so the rat name tracks
earned Names). The home page lists saved sessions in a "Rejoin a Crew"
menu, most-recent first, alongside creating a new game.
Deleting an entry only touches localStorage, never the backend, and is
undoable: the row greys out with an Undo button and is dropped from
storage immediately, so a page refresh finalizes the removal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each routed page now sets a descriptive document.title so tabs and
browser history entries are distinguishable: the dashboard shows the
Pi-Rat (and human player) name plus crew, the admin panel shows
"Admin · {crew}", the join page is labeled, and home resets to the
app name. New lib/title.js helper composes "{parts} — Rats with Gats".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Admin panel now shows the crew's join link (origin/#/game/{id}/join)
with Open and Copy buttons, so an admin can invite new players without
returning to the lobby. Matches the existing rejoin-link copy pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Card tooltips now show what a card represents *as* an Obstacle (the
rulebook obstacle table entry) in addition to its suit theme, which is
relevant when challenging another Pi-Rat. The obstacle tables are served
from the backend via GET /api/obstacles (single source of truth in
cards.py) and cached client-side in cards.js, with a retry so a transient
first-load failure doesn't silently disable the tooltips.
Enriched everywhere cards appear in the challenge flow: hand/board cards
(Card.svelte), PvP defend buttons and the temporary-obstacle line
(ChallengePanel), and the duel card selector, which also shows an
inline description of what the chosen card becomes as an Obstacle.
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>