Compare commits

..

98 Commits

Author SHA1 Message Date
d8d68a80a3 Pin event log across all phases 2026-07-10 19:24:04 -07:00
b9cbbda1a2 Keep crew roster in a consistent sidebar 2026-07-10 19:17:39 -07:00
3e912b9458 Make join codes easy to copy 2026-07-10 19:12:03 -07:00
929941b8ff Add combined development server launcher 2026-07-10 19:09:16 -07:00
9a5a319cb9 Keep player rosters vertical 2026-07-10 14:06:01 -07:00
a5bfb7905f Promote admin join code display 2026-07-10 12:32:08 -07:00
b253a06b45 Add short game join codes 2026-07-10 12:30:43 -07:00
8470da4aa9 Remove expired rejoin sessions 2026-07-10 12:27:32 -07:00
2de4648288 Remember player name for new crews 2026-07-10 12:25:55 -07:00
3fe3333768 Align admin link controls 2026-07-10 12:22:53 -07:00
feb595af16 Clarify unnamed Pi-Rats in admin panel 2026-07-10 12:21:07 -07:00
1a4bc86139 Keep event log toggle in place 2026-07-10 11:59:30 -07:00
2210bcd48e Widen character sheet modal 2026-07-10 11:58:34 -07:00
bf800a06db Add CLAUDE.md 2026-06-15 16:49:11 -07:00
692c6d26c1 Cap request body size (HTTP 413)
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>
2026-06-15 15:59:48 -07:00
122e66b817 Defensive coding: sanitize free text, constrain inputs
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>
2026-06-15 15:54:21 -07:00
ccb32e7103 Periodic purge of old finished/inactive games
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>
2026-06-15 15:47:44 -07:00
5e9f9bfb13 Backend logging: stderr + configurable rotating file
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>
2026-06-15 15:42:06 -07:00
b1a559cf39 Polish: legible crew roster, End-Scene enforcement, decluttered Hand
- 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>
2026-06-15 15:34:10 -07:00
6fa7073f10 Make the inline Event Log collapsible and viewport-bounded (v7)
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>
2026-06-15 15:16:57 -07:00
b18bf683a9 Simple tasks: Tooltips and UI Layout
Completed tasks: 'PvP challenge tooltips', 'Secret technique tooltips', and 'Character sheet layout' from TODO.md
2026-06-15 11:01:16 -07:00
e1779292e5 Simple tasks: Event log and personal objective cursor
Completed tasks: 'Event log items' and 'Personal objectives for pi-rats' from TODO.md
2026-06-15 10:57:00 -07:00
1c8db78c43 Fix other broken migration 2026-06-15 10:40:36 -07:00
c03efd4a65 Fix SQL migration error 2026-06-15 10:38:41 -07:00
e4c78391fc Declutter the challenge panel and obstacle titles (v4)
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>
2026-06-15 09:56:07 -07:00
922fc38c0a Rules: add card diagrams to the Example of Play (v3)
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>
2026-06-15 09:44:59 -07:00
cf9ee33b52 Condense obstacle cards into an overlapping column (v2)
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>
2026-06-15 09:38:01 -07:00
17160f9d9b Add version number + About dialog with changelog (v1)
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>
2026-06-15 09:20:47 -07:00
44f7fd939b Home: list Rejoin a Crew above Muster, with a gap between
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>
2026-06-15 09:14:27 -07:00
5b5087ac23 Track files left untracked by recent commits
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>
2026-06-15 09:10:10 -07:00
daeac91d07 Dead code cleanups 2026-06-15 07:46:32 -07:00
045857a8fd Refine crew column: names, Captain label, slide-out objectives
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>
2026-06-14 20:12:44 -07:00
2ea91c066a Refresh the scene dashboard around a crew bubble column
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>
2026-06-14 20:03:41 -07:00
8614a6c820 Collapse between-scenes upkeep into a single panel
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>
2026-06-14 10:57:55 -07:00
44bbb88836 Add gat-description modal when a Pi-Rat earns a Gat
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>
2026-06-14 09:46:02 -07:00
7825ad2cca Toast new events into the Event Log button
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>
2026-06-14 09:39:54 -07:00
473fd74a00 Point Admin back button at the active game, not the join screen
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>
2026-06-14 09:33:53 -07:00
ceaf88ef22 Stop auto-assigning the Captain when a ship is stolen
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>
2026-06-14 09:30:46 -07:00
39dc07d6b6 Split Deep challenge stakes into success and failure fields
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>
2026-06-14 09:28:38 -07:00
5a8919d967 Display rank-up vote winner on the post-voting upkeep screen
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>
2026-06-14 09:23:18 -07:00
b37b655118 Confirm hand refresh visually in Deep Upkeep
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>
2026-06-14 09:19:58 -07:00
bfe982251e Add teaching mode: admin can seed themselves as scene-1 Deep
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>
2026-06-14 09:14:58 -07:00
5a9d712c95 Add Cancel button to character-creation Revise flows
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>
2026-06-14 09:09:24 -07:00
45fefc0c71 Add modal name popup when a Pi-Rat earns a Name
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>
2026-06-14 09:05:02 -07:00
f40724c9d2 Clean up ruff E701 warnings
Split multi-statement-on-one-line colon constructs in crud_scene.py
and routes_scene.py onto separate lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:00:44 -07:00
3bb4940df7 Hide deep's hand 2026-06-14 08:57:08 -07:00
55f4085d7a Add likes/hates to character sheet UI 2026-06-13 09:58:16 -07:00
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
e7db19f27e Let players voluntarily leave a game
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>
2026-06-12 21:58:07 -07:00
428af784e3 Add player kicking for admins
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>
2026-06-12 21:48:03 -07:00
d04e5013fa Streamline dead Pi-Rat rank voting
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>
2026-06-12 21:21:38 -07:00
345ef4088f Save active sessions in browser local storage
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>
2026-06-12 21:14:27 -07:00
d68333ec05 Add crew/player/rat name to page title
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>
2026-06-12 21:09:36 -07:00
fb17c632b0 Add invite link to Admin panel
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>
2026-06-12 21:07:28 -07:00
e502155af7 More detailed card tooltips
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>
2026-06-12 21:05:20 -07:00
952b1be872 Uncommit feature in character creation
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>
2026-06-12 17:05:52 -07:00
ba32c96b7b Light mode update 2026-06-12 16:57:07 -07:00
fa37cffe47 Technique swapping implementation 2026-06-12 16:46:03 -07:00
121f2f7498 Player minimum of 3 to start a game or scene
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>
2026-06-12 16:16:49 -07:00
5ad9c9db07 Multiple admins
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>
2026-06-12 16:03:14 -07:00
e24e6d3b46 Add rules button to join page 2026-06-12 12:18:41 -07:00
3395b127b4 Dev mode revamp 2026-06-12 11:50:34 -07:00
f61201144f Dead Pi-Rat reroll rework 2026-06-12 11:32:15 -07:00
df7a0cc83f Add Discord link to rules 2026-06-12 10:03:16 -07:00
4e262a5345 Update TODOs 2026-06-12 09:58:54 -07:00
5f647d1940 Commit .claude/launch.json, gitignore local Claude settings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:17:25 -07:00
96eeb442fd Database migrations 2026-06-12 07:14:50 -07:00
0535c68647 Fix flake with new hash 2026-06-12 07:01:55 -07:00
0744893e7b Re-theme the app: token-based CSS and a full face lift
CSS refactor: every color, font, radius, and shadow is now defined once
in theme.css; all other styles derive translucent variants with
color-mix(), so re-theming means editing one file. Removed the
duplicate core.css/style.css import chains, dead styles, and the
Bootstrap/Tailwind orphan classes that were silently unstyled
(the main reason the splash page looked broken).

New "Lantern & Brine" look: brass and parchment on a lantern-lit
ink-navy night, Pirata One wordmark with Alegreya SC/Sans body fonts
(self-hosted via fontsource — they were previously referenced but
never loaded), parchment-faced playing cards, and a rebuilt splash
page. Inline color styles in components were replaced with semantic
classes (prompt-box, notice-banner, status chips, etc.), the rulebook's
embedded palette was updated to match, and the leftover Vite favicon
and "frontend" page title were replaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 06:50:00 -07:00
ba6bf35579 Websockets instead of polling 2026-06-12 06:19:22 -07:00
b72aea9747 Combine Rules and Admin corner links into one upper-right menu
Replace the fixed Rules link (upper-left) and the creator-only Admin
link (upper-right) with a single collapsible ☰ Menu in the upper right,
leaving the event log alone in its corner. CornerMenu always lists the
Rules link; pages can contribute context-specific entries through the
extraMenuLinks store, which Dashboard uses for the creator-only Admin
link. This gives future misc sub-pages a home without eating more
screen corners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:47:56 -07:00
4fcd2b693c Make the rulebook's obstacle reference section collapsible
The Obstacle List & Type Charts section is long reference material the
app already implements, so wrap it in a <details> element. A small
script expands it when the TOC link or a deep link targets #obstacles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:42:15 -07:00
683c60fff9 Serve the rulebook as a static HTML page from FastAPI
Replace the SPA Rules route with a self-contained src/pirats/rules.html
served at GET /rules (registered before the SPA mount, included in
package data for the Nix build). Plain anchor links replace the
scrollIntoView workaround since there's no hash router to fight. The
corner Rules link and splash-page link now point at /rules, and the
Vite dev server proxies /rules to the backend like /api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:35:04 -07:00
69e7d24e10 Add HTML rulebook page with always-available Rules link
New /#/rules route renders the full rulebook transcribed from
"Rats with Gats beta.pdf" (intro, character creation, gameplay,
obstacle charts, challenge resolution, example of play, and setting
info), with an in-page table of contents. A fixed corner link in
App.svelte opens it in a new tab from every page, and the splash
page's previously broken /rules link now points at the new route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:30:00 -07:00
ed7900656d Replace copy-link alert() with inline button feedback
The lobby Copy buttons now flip to a transient "✓ Copied" label instead
of popping a modal alert, matching the admin page's copy buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:18:48 -07:00
bc867c95a1 Add player re-join links to admin page
Each player row now has Open/Copy actions for their dashboard link, so
the creator can recover access for players who lose their URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:17:30 -07:00
3a00774b50 Fable will fix it! Pt 3 2026-06-12 01:13:11 -07:00
6dcb2a9ae9 Commit todo file 2026-06-11 22:46:28 -07:00
a074ff77b1 Fable will fix it! Pt 2 2026-06-11 21:32:00 -07:00
a7cab7bcb6 Better technique suggestions 2026-06-11 15:59:52 -07:00
443acd8400 Fable will fix it! 2026-06-11 15:57:08 -07:00
2af98a3eeb Add rulebook/docs 2026-06-11 14:40:33 -07:00
58a7e4d4f1 Switch to Svelte 2026-06-11 14:39:41 -07:00
29860061c4 Add event log 2026-06-11 10:20:14 -07:00
1b8452bf56 Add AGENTS file 2026-06-10 15:57:22 -07:00
d7f8483a41 Objectives & Milestones Update 2026-06-10 15:56:32 -07:00
2ddcb86cc0 Refactor for smaller context 2026-06-10 15:29:03 -07:00
2d57973cd8 Fixes and updates 2026-06-09 23:29:05 -07:00
41731459a7 More responsive drag and drop for card plays 2026-06-09 19:45:59 -07:00
e312d8efc1 Add httpx dependency 2026-06-09 19:15:20 -07:00
8650ebe5b2 Bug fixes 2026-06-09 19:12:19 -07:00
274ae3fb07 Remove flake-utils dependency 2026-06-09 16:41:23 -07:00
a5d8ba6709 Fix bug in test 2026-06-09 16:22:40 -07:00
66777ab1bc Add gitignore 2026-06-09 16:22:32 -07:00
af9133258c Add README 2026-06-09 16:21:48 -07:00
129 changed files with 19048 additions and 4508 deletions

12
.claude/launch.json Normal file
View File

@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "pirats",
"runtimeExecutable": ".venv/bin/python",
"runtimeArgs": ["-m", "uvicorn", "pirats.main:app", "--host", "127.0.0.1", "--port", "8123"],
"port": 8123,
"env": { "DATABASE_URL": "sqlite:///smoke_test.db", "PYTHONPATH": "src" }
}
]
}

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.venv
rats_with_gats.db
smoke_test.db
result
*.pdf
# Built Svelte frontend (copied in from frontend/dist by the Nix build)
src/pirats/static/
# Machine-local Claude Code permission approvals
.claude/settings.local.json
# Emacs autosaves
\#*

35
AGENTS.md Normal file
View File

@@ -0,0 +1,35 @@
Use the version of Python/Pytest in the .venv directory when attempting to run commands, as that's where the pip dependencies (and pytest) are installed.
## Database migrations
Schema changes use Alembic (migrations live in `src/pirats/migrations/`, shipped inside the package). The app applies them automatically at startup via `pirats.database.run_migrations()`; databases predating Alembic are normalized and stamped at the `0001` baseline first.
After editing `src/pirats/models.py`, generate a migration and sanity-check it:
```
.venv/bin/alembic revision --autogenerate -m "describe the change"
.venv/bin/alembic upgrade head # or just start the app
```
**Note on adding NOT NULL columns in SQLite:**
SQLite does not support adding a `NOT NULL` column without a default value to an existing table. When Alembic auto-generates a migration that adds a `nullable=False` column, you MUST manually edit the migration to include `server_default="..."` in the `sa.Column` definition (e.g., `server_default=""` for strings) before applying it. Otherwise, the migration will crash with `Cannot add a NOT NULL column with default value NULL`.
Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list exists only to upgrade pre-Alembic databases to the baseline and must not grow.
## Learning during testing
When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions.
- If Alembic autogeneration says the target database is not up to date, create a temporary database with `DATABASE_URL=sqlite:////tmp/<name>.db .venv/bin/alembic upgrade head`, then run the autogeneration command with that same `DATABASE_URL`.
## Versioning & changelog
The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
- Bump `VERSION` by one on **every** commit.
- When a commit changes something players can see, add an entry to the **top** of `CHANGELOG` (`{ version, date, changes: [...] }`) describing it in player-facing terms. Group everything shipping under one version into a single entry.
- The changelog is for players: skip refactors, tests, tooling, and other internal-only changes. A commit with no user-facing change bumps `VERSION` but adds no entry.
## Work order
You will be working on tasks in [TODO.md](./TODO.md]. Work on at most two tasks at a time (one is preferable, but two is fine if they dove tail really nicely), and after you finish each task, make a git commit and update the TODO list to check off the completed task.

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

189
EXAMPLESCENE.md Normal file
View File

@@ -0,0 +1,189 @@
# Rats with Gats - Full Example Scene: The Cheesy Heist
This is a complete example of a Scene you might play out in a game of *Rats with Gats*!
For the core rules of the game, see [RULEBOOK.md](file:///Users/ttm/Projects/pirats/RULEBOOK.md).
---
## Players and their Pi-Rats
* **Gabe**: playing "Whiskey Shrinkage" (Nameless, Rank 3)
* **Sally**: playing the Deep for this scene (normally plays "Piss Whiskers")
* **Jake**: playing "Carlos" (Named, Rank 3, Captain)
---
## Setting the Scene
### Step 1: Declare Roles!
* **Gabe**: "I'll play my Pi-Rat in this scene, since I was the Deep last Scene."
* **Sally**: "I'll be the Deep this time. Piss Whiskers needs a bath, anyway."
* **Jake**: "Carlos is in! And I'm still the Captain, ya hear? So you rats better listen up!"
### Step 2: Shuffle and Draw Obstacles!
Sally returns all discarded cards to the deck and shuffles thoroughly. There's currently one Obstacle on the Obstacle List from the last scene (**7 of Hearts: Bad Breakups**). Since there's only one Deep Player (Sally) in this scene, she needs to draw cards until there are a total of two Obstacles on the list.
Sally draws from the top of the deck: **10 of Hearts: Cheese Thief**
The Obstacle List now has:
1. **7 of Hearts: Bad Breakups** (Value: 7)
2. **10 of Hearts: Cheese Thief** (Value: 10)
### Step 3: Frame the Scene!
* **Sally (Deep)**: "Alright, so you rowdy rats are in the galley below deck, drinking grog, and shootin' the breeze. The crew's been tense ever since that breakup between Salty Socks Pete and Long-Tail Lamar split everyone into factions. But NOW, someone's been stealing the cheese rations, and the crew is absolutely losing their minds about it. Pete's faction blames Lamar's, and Lamar's faction blames Pete's! You all can hear angry shouting from the deck above."
* **Gabe**: "Is there any cheese left?"
* **Sally (Deep)**: "There's one wheel of aged cheddar sitting on the galley table, guarded by a rotating shift of Pi-Rats from both factions."
* **Jake**: "Perfect. Carlos wants that cheese. That's a captain's cheese."
* **Gabe**: "I'm on it, Boss!"
---
## Playing Out the Scene!
* **Jake**: "Carlos struts into the galley, adjusting his fancy captain's hat. 'Aye, what's all this commotion about cheese, ya yeasty beasties?'"
* **Sally (Deep)**: "The two guards immediately turn on you. 'Captain! Someone's been stealin' our cheese! How could you let this happen? In this economy, no less!' They're getting pretty heated. I'm calling for a Challenge—can Carlos calm them down before this turns into a mutiny?"
### Challenge 1: Carlos Calms the Crew
#### Step 1: Establish Stakes!
* **Sally (Deep)**: "If you succeed, you calm the crew and maybe figure out who the Cheese Thief is. If you fail, the crew's anger starts growing out of control, and this could get violent."
#### Step 2: Apply Obstacles!
* **Sally (Deep)**: "I'm applying both Obstacles. The **Bad Breakups** Obstacle means half the crew won't listen to anything the other half says, and the **Cheese Thief** Obstacle represents how absolutely furious everyone is about the missing cheese. Bad Breakups is currently value 7, and Cheese Thief is value 10."
#### Step 3: Play Cards!
Jake looks at his hand: **8 of Spades**, **3 of Hearts**, **Queen of Diamonds**, **Jack of Clubs**.
* **Jake**: "Oof, that 10 is rough, but I got this. I'll play my 3 of Hearts against the Cheese Thief Obstacle, and my 8 of Spades against the Bad Breakups Obstacle."
#### Step 4: Place Cards!
Jake places his 3 of Hearts face-up in a column below the **10 of Hearts: Cheese Thief** card, rotating it 90°. He does the same with his 8 of Spades below the **7 of Hearts: Bad Breakups** card.
The Obstacle List now shows:
1. **7 of Hearts: Bad Breakups**
* *Card underneath*: 8 of Spades (rotated 90°)
* *New Value*: 8 (1 Success)
2. **10 of Hearts: Cheese Thief**
* *Card underneath*: 3 of Hearts (rotated 90°)
* *New Value*: 3 (0 Success - 3 is less than 10)
#### Step 5: Narrate Results!
* **Jake**: "Since I played Spades—Sneakin' and Schemin'—Carlos cleverly pulls aside the guards from both breakup factions and whispers to each that the OTHER side is clearly innocent. He gets them to make peace, at least temporarily. BUT, I did fail against the Cheese Thief obstacle..."
* **Sally (Deep)**: "Yeah, the crew unites under their Captain—but they also seem to be itching for some mob justice. Things could still escalate quickly!"
#### Step 6: Draw Cards!
Jake played a red card (3 of Hearts) against a red Obstacle (10 of Hearts). Since both were red, Jake draws 1 card and adds it to his hand.
* **Sally (Deep)**: "Challenge resolved! Good job. Gabe, what are you up to during this time?"
* **Gabe**: "While everyone's yelling at Carlos, I sneak toward the cheese wheel on the table, looking for clues, and maybe to steal it."
* **Sally (Deep)**: "Oh no you don't! Challenge time! Can you get close enough to examine and maybe swipe that cheese without getting caught?"
---
### Challenge 2: Whiskey Shrinkage Sneaks the Cheese
#### Step 1: Establish Stakes!
* **Sally (Deep)**: "Success means you get the cheese and some answers. Failure means you get caught red-handed and the crew knows YOU'RE the real Cheese Thief."
#### Step 2: Apply Obstacles!
* **Sally (Deep)**: "I'm only applying the Cheese Thief Obstacle. The crew's watching you like rat-hawks. Its current value is 3 (updated from the previous challenge)."
#### Step 3: Play Cards!
Gabe looks at his hand: **2 of Clubs**, **9 of Hearts**, **King of Spades**.
* **Gabe**: "I'm going all out. I'll play my King of Spades! That's a Secret Pirate Technique!"
* **Sally (Deep)**: "Nice! What is your King Technique?"
* **Gabe** (checks his Rat Records): "My Kings are... *Secret Pirate Technique #79: 'There's always a secret entrance sometimes.'* Whiskey Shrinkage doesn't even go for the cheese on the table. He knows there's a secret compartment under the floorboards where the REAL cheese stash is hidden. He pops it open and pulls out an entire log of fancy Havarti!"
* **Sally (Deep)**: "You automatically succeed!"
#### Step 4: Place Cards!
Gabe places his King of Spades in the column below the Cheese Thief Obstacle, rotating it 90°.
The Obstacle List now shows:
1. **7 of Hearts: Bad Breakups**
* *Cards underneath*: 8 of Spades (rotated 90°)
* *Value*: 8 (1 Success)
2. **10 of Hearts: Cheese Thief**
* *Cards underneath*: 3 of Hearts (rotated 90°) $\rightarrow$ King of Spades (rotated 90°)
* *New Value*: 3 (Gabe's current Rank) (1 Success - Face card value equals player's Rank)
#### Step 5: Narrate Results!
* **Gabe**: "After opening the secret compartment, Whiskey Shrinkage casually walks out of the galley with a wheel of cheese under each arm while everyone's still yelling at Carlos."
#### Step 6: Draw Cards!
Gabe played a King of Spades (Black) against an Obstacle that's Red (10 of Hearts), so he doesn't draw any cards.
* **Gabe**: "Whiskey Shrinkage is up on deck now, eating cheese openly. He's taunting the crew with it, hoping to draw out the Cheese Thief. 'Will the owner of this delicious havarti come to the lost and found?' I'll make sure everyone can see me, too."
* **Sally (Deep)**: "Uh oh! Crew members from across the deck take notice, and everyone is looking around to see who steps up for the cheese! Just as it seems no one is willing to accept ownership, a Pi-Rat jumps down from the crow's nest with her knives drawn, and barrels down on Whiskey Shrinkage. Challenge time!"
---
### Challenge 3: The Crows Nest Confrontation
#### Step 1: Establish Stakes!
* **Sally (Deep)**: "If you succeed, you fight off the Cheese Thief and keep the cheese you found. If you fail, the Cheese Thief beats you up and accuses you of being the mastermind behind the cheese theft."
#### Step 2: Apply Obstacles!
* **Sally (Deep)**: "I'm applying the **Cheese Thief** Obstacle again—they're absolutely enraged. Its current value is 3. I'm also applying the **Bad Breakups** Obstacle again, since tensions are so high right now. Its current value is 8."
* **Jake (Captain)**: "Wait! I want to help! Can Carlos assist?"
* **Sally (Deep)**: "Sure! You can play a card against one of the Obstacles applied to the Challenge."
#### Step 3: Play Cards!
* **Gabe**: "I'll play my 9 of Hearts against the Cheese Thief Obstacle. That beats the 3!"
* **Jake**: "And I'll play my Queen of Hearts against the Bad Breakups Obstacle to help out. That's my *'when in doubt, flirt your way out!'* Secret Pirate Technique, which automatically beats the Bad Breakups Obstacle value of 8."
#### Step 4: Place Cards!
Gabe places his 9 of Hearts below the Cheese Thief Obstacle. Jake places their Queen of Hearts below the Bad Breakups Obstacle.
The Obstacle List now shows:
1. **7 of Hearts: Bad Breakups**
* *Cards underneath*: 8 of Spades (rotated 90°) $\rightarrow$ Queen of Hearts (rotated 90°)
* *New Value*: Rank (Jake's Rank of 3) (2 Successes)
2. **10 of Hearts: Cheese Thief**
* *Cards underneath*: 3 of Hearts (rotated 90°) $\rightarrow$ King of Spades (rotated 90°) $\rightarrow$ 9 of Hearts (rotated 90°)
* *New Value*: 9 (2 Successes)
#### Step 5: Narrate Results!
* **Gabe**: "I played Hearts—Shoutin' and Singin'—so Whiskey Shrinkage starts singing a sea shanty about cheese so beautiful and ridiculous that the sailors stop in their tracks, mesmerized!"
* **Jake**: "Carlos joins in with a sexy rat dance, and does their best to give off 'hot captain' vibes."
* **Sally (Deep)**: "Weird, but whatever. You both share in the success—the cheese is safe, and the original culprit puts her knives away and surrenders to the crew. 'I would have gotten away with it too if it wasn't for your stupid musical number!' the Cheese Thief says."
#### Step 6: Draw Cards!
* Gabe played a 9 of Hearts (Red) against a red Obstacle, so he draws 1 card.
* Jake played a Queen of Hearts (Red) against a red Obstacle, so he would draw 1 card, but because he was assisting, he does *not* draw.
---
## Ending the Scene
* **Sally (Deep)**: "Alright, let's check the requirements for ending the Scene. Both Pi-Rat Players have been challenged at least once. Has anyone made progress on their Objectives?"
* **Gabe**: "Whiskey Shrinkage doesn't have a real name yet, but maybe I earned one with my clever detective skills and singing?"
* **Sally (Deep)**: "Sure, but I don't know if that was name-worthy. We can always have a vote to see if you completed this Objective, if you'd like?"
* **Gabe**: "Yeah, let's do it! Who thinks I earned a real Pirate name and completed my second Personal Objective?"
* **Sally (Deep)**: "I vote yes."
* **Jake**: "Same here!"
* **Gabe**: "Well, I'm definitely voting in favor of myself!"
* **Sally (Deep)**: "It looks like you got a name! What is it?"
* **Gabe**: "You can call me… **Tim**."
* **Sally (Deep)**: "Yeah, yeah, alright 'Tim'. Make sure to write down your new name and mark off that you completed your second Objective. What about Jake?"
* **Jake**: "Carlos is the Captain, so that's Crew Objective 2 already completed. And we control the ship, so Crew Objective 1 is done. But we haven't committed any piracy yet. I don't think I completed any Personal Objectives during this Scene, but I did my duties as Captain!"
* **Gabe**: "Agreed!"
* **Sally (Deep)**: "Fair enough. And we still have both Obstacles on the list, so they're not cleared, but both are close to being discarded (2 successes each, in a 3-player game they need 3 successes to be discarded). I think there's more story here. But actually, I'm going to end the scene here anyway. This was a good warm-up Scene! You rats have had enough fun for now, and we can always come back to this cheese situation later."
* **Jake**: "Okay!"
* **Gabe**: "Sounds good!"
---
## Between Scenes
### Step 1: Pirate Ranking (Voting)
* **Gabe**: "I vote for Carlos. They stepped up to defend me when it could have gone really bad."
* **Jake**: "I vote for Whiskey Shrinkage—sorry, I mean Tim. That Secret Pirate Technique with the hidden cheese was amazing."
* **Sally**: "I'm giving my vote to Gabe as well. Tim gets a Rank!"
* *Tim's Rank increases from 3 to 4.* (Updated in Rat Records)
### Step 2: Additional Rank Bonuses
Gabe completed his second Personal Objective too, so he increases his Rank again, putting him at **5**! Tim is now tied with Carlos for the highest-ranking Pi-Rat, which means Gabe's Hand size increases by 1 (to 4 cards). Because his Hand size increased, Gabe immediately draws a card from the deck.
### Step 3: Deep Players Draw Cards
Sally was the Deep, so she may discard any cards in her hand, and then draws back up to her maximum Hand size, which is now **2** (since she has the lowest Rank of all Pi-Rats, at Rank 2).
### Step 4: Start a New Scene!
* **Sally**: "Okay, next scene! Let's see what other chaos these rats get into..."
*(Someone shuffles the deck and everyone prepares for the next scene.)*

92
OBSTACLES.md Normal file
View File

@@ -0,0 +1,92 @@
# Rats with Gats - Obstacle Type Charts
This file contains the full list of Obstacle Type Charts for *Rats with Gats*, categorized by card suit. During the game, when the Deep draws an Obstacle card, its suit and value determine which threat from these tables is introduced.
For the core rules of the game, see [RULEBOOK.md](file:///Users/ttm/Projects/pirats/RULEBOOK.md).
---
## Clubs: Unwanted Passengers
These types of Obstacles represent unexpected visitors, stowaways, and boarding parties.
| Card | Name | Description |
| :---: | :--- | :--- |
| **A** | **Animal Stowaways** | Octopuses, seagulls, and lobsters infiltrating the crew, pretending they belong while being terrible at their jobs! |
| **2** | **Squidfolk Fisherman** | Confused passenger who thinks this is a merchant vessel looking to buy his daily catch. He is quite insistent! |
| **3** | **Goblin Research Team** | These interstellar beings like to conduct invasive studies all across Yeld. For whatever reason, the Pi-Rats have captured the Goblins' attention, and now they won't leave the ship alone! At least the goblins are mostly harmless, even if they are annoying and constantly in the way with their invasive probing. |
| **4** | **Fairy Deserter** | Deserter from an enemy vessel seeking asylum. No one trusts a deserter, but Fairies are almost five times the size of a Pi-Rat. It might be best to play nice! |
| **5** | **Loitering Mermaid** | Mermaids love to hitch rides on unsuspecting ships, drinking all the booze, smoking all the cigarettes, and starting all kinds of drama among the crew. Some Mermaids do this professionally as saboteurs, while others simply do it for the love of the game. |
| **6** | **Mergang** | A group of Mermaid Privateers have emerged from the ocean below to board the ship in a good old-fashioned robbery. While these Mermaids aren't necessarily out for blood, they are highly trained in naval combat and won't back down easily! |
| **7** | **Lustful Bean Whale** | A giant, magical whale made of beans! The creature seems to have mistaken the ship for a potential mate. If the Bean Whale gets too frisky, its massive body could do some real damage to the ship! |
| **8** | **Nautical Knights** | Oh Shit, it's the feds! A band of Vampire Knights loyal to the Kingdom of Yeld have snuck aboard somehow and are slaughtering everyone on the ship. It's every Rat for themselves! |
| **9** | **Deep Mage Fleet Inquisitor** | Deep Mages are powerful spell casters who work directly for the oceanic god hidden below the waves. This Deep Mage is currently inspecting and investigating all seafaring vessels in the area, looking for traitors, turncoats, and spies working directly against the Deep. It's probably best to cooperate and hope we get a pass! |
| **10** | **Pi-Rat Commodore** | A Pi-Rat commodore has come to claim the ship as one of their own by out-pirating the current Captain. We can't let ourselves be absorbed into some other rat's crew! |
| **J** | **Annoying Sailor** | Ship's previous crewmember who wants their bunk back, and maybe some revenge. |
| **Q** | **Bossy First Mate** | Ship's previous first mate still trying to give orders. The problem is some of the crew keep listening to 'em! |
| **K** | **Vengeful Captain** | Ship's previous Captain who refuses to let go, and keeps challenging the current Captain for control of the ship. |
> [!NOTE]
> **Ghosts**: Sometimes the Pi-Rats must face an unwanted passenger again, even if they had killed them in a previous Scene. When this happens, the unwanted passenger will return as a ghost, haunting the crew in obstructive ways and creating challenges like any other Obstacle would.
---
## Spades: Oceanic Occurrences
These types of Obstacles represent environmental dangers and external threats from the sea.
| Card | Name | Description |
| :---: | :--- | :--- |
| **A** | **Freaky Fog** | Heavy fog has reduced visibility to almost nothing, and the crew is a little spooked. Everyone should be extra careful! |
| **2** | **Dead Seas** | A strange, ghostly stillness and silence claims the area. No waves, no wind, no life to be found. The living shouldn't stay here for long! |
| **3** | **Secret Reefs** | Dangerous shallow waters and secret reefs hide who knows what else. Navigating this will be difficult! |
| **4** | **Deep Legion Blockade** | A blockade of Deep Legion Ships has cut off our route. They may want to come aboard to perform an inspection before we can pass. Better be on our best behavior! |
| **5** | **Pirate Hideout** | A secret pirate island happens to be nearby. Crews from all over come to drink, relax, and swap tales with each other. Sometimes a fight breaks out, but most pirates end up surviving it, so no hard feelings! |
| **6** | **Wicked Whirlpool** | A Whirlpool of epic proportions has captured every vessel in the area, sending everyone spiraling. The Whirlpool threatens to pull the ship down, along with everything else. Is it natural, or is there something else going on? |
| **7** | **Floating Fairy Fortress** | Fairy sailors working for the Vampire Prince have built a massive fortress that floats off the Kingdom's coastline. The fortress has decimated Legion ships for a season, and most pirates have fled the area. If any Fairy scouts spot us we'll have a nasty cannon battle on our hands! |
| **8** | **Frozen Fury** | A brutal blizzard has covered the sea in freezing rain and thick snow, and crew members are freezing in place across the ship. Worst still, hidden icebergs have been spotted nearby! |
| **9** | **Hurricane Havoc** | A massive and deadly storm comes rolling in. The wind and rain beats the ship from all sides as the waves throw it every which way. Secure the cargo! Batten down the hatches! Get below deck! |
| **10** | **Ship-wrecker Queen's Graveyard** | A colossal, Kaiju-sized Mermaid known as a Shipwrecker Queen has made a hunting grounds out of the area. In her wake is an endless graveyard of ships, stacked and strewn across the sea. Each ship in the graveyard was torn apart by the hands of the massive Mermaid, as she scoured the vessels for any tasty crew to eat. Don't let her see us, or we'll be next! |
| **J** | **Fish Storm** | The ship is being pursued by a magic fish storm. Aquatic life of all shapes and sizes rain down from the clouds above, and they seem just as upset about it as we are! |
| **Q** | **Mysterious Pursuers** | The ship is being chased by an unknown vessel. It seems to keep its distance, but may retaliate if provoked. Who could it be? |
| **K** | **Cursed Island** | A strange island keeps appearing before the ship, no matter the direction we sail in. Almost every crewmember says they've had strange dreams about the island for days, and a few have even claimed to have heard whispers and voices beckoning the crew to investigate. We need answers! |
---
## Hearts: Pi-Rat Problems
These types of Obstacles represent internal crew conflicts and personal issues.
| Card | Name | Description |
| :---: | :--- | :--- |
| **A** | **Over Served** | Everyone got really drunk on the job. This isn't really a problem for Pi-Rats. |
| **2** | **Pirate Flu** | There's a bug going around the ship and everyone calling in sick to work! |
| **3** | **Tail Measuring Contest** | A tail measuring contest has led to some major Gat Envy amongst the crew! Everybody is in their feelings about it, and petty arguments have broken out several times. |
| **4** | **Gambling Problems** | The Pi-Rats have been gambling over everything and it's starting to cause tension and debt. Crew members have lost all their possessions, and there's even been talk about "collecting kidneys" next. There's only one way out for those in debt: More gambling! |
| **5** | **Scurvy** | The scourge of the seas! Scurvy has taken down many a Pi-Rat, and now it's come for the crew. There are few things more dramatic than a Pi-Rat who thinks they are dying of Scurvy. |
| **6** | **Doldrums** | A wave of depression has hit the crew, and everyone's feeling pretty down right now. What's even the point, ya'know? |
| **7** | **Bad Breakups** | A lover's quarrel has divided the crew! Everyone has to choose a side, and it's going to be really awkward for a while. |
| **8** | **Crew Wedding** | Members of the crew are getting married! Everyone is trying to juggle their pirate duties with their wedding duties, and nothing is going right. |
| **9** | **Double Down Dares** | A series of escalating pirate dares among the crew has gotten completely out of hand. If this keeps going, someone's going to get shot on purpose, or worse, sink the ship! |
| **10** | **Cheese Thief** | Someone has been stealing cheese, and now everyone is livid. Crimes like these are too important to let slide. Everyone stop what you're doing! The culprit must be found, and immediately dealt with... Pi-Rat style. |
| **J** | **Super-duper-stitious** | The crew has grown superstitious as of late, and morale has taken a dive. Only more superstition can save us! |
| **Q** | **Pirate Curse** | The crew has been magically cursed by some unknown thing. There has to be some kind of complicated or convoluted way of breaking it! |
| **K** | **Among us** | Someone on the ship is a traitor, and everyone is suspicious of everyone else. The traitor must be found before they can sell us out to our enemies! |
---
## Diamonds: Ship Troubles
These types of Obstacles represent damage and malfunctions affecting the vessel.
| Card | Name | Description |
| :---: | :--- | :--- |
| **A** | **Rat Bastards** | Regular rats have eaten through the cargo, destroying, devouring, and defecating on everything below deck. It's a real mess! |
| **2** | **Slip and Slide** | The deck has been freshly swabbed, but the crew used slippery soap to do it. Those fools! Now everyone is sliding around. Some have even slipped overboard! |
| **3** | **Broken Straps** | Loose cargo has broken from its secured position and is rolling around dangerously all over the ship. The cargo must be collected and tied back down before it's lost or someone gets squished! |
| **4** | **Low Supplies** | The ship is running low on the essentials: alcohol, tobacco, fresh water, and food (in that order). If we don't get some supplies soon we'll have a bunch of starving, cranky, and sober mutineers on board! |
| **5** | **Torn Sails** | The sails have been badly shredded, slowing the ship's max speed significantly. These sails will need to be patched or replaced! |
| **6** | **Jammed Helm and Rudder** | Something has jammed or damaged the rudder (rutter) and now the helm won't budge. We can't change course! |
| **7** | **Stuck Anchor** | The anchor won't lower or raise. Either it's stuck somewhere on the reefs, wreckage, or seabed, or it won't deploy at all, causing the ship to drift in the wind and waves! |
| **8** | **Broken Bilge Pump** | The bilge pump has failed and the ship has begun taking on too much water. Somebody get some buckets before we sink! |
| **9** | **Severed Mast** | The main mast has been heavily damaged, compromising the rigging and sails. The ship isn't going anywhere now! |
| **10** | **Powder Bomb** | The ship's stockpile of ammunition and explosives has detonated, causing massive damage and starting several large fires. Some of the crew have already fled, but the ship can still be saved if everyone works together! |
| **J** | **Mystery Leak** | Somewhere on board is a leak, and no one can seem to find it. Maybe it's random magical phenomena, or maybe it's something more nefarious? If the leak isn't dealt with, we're all going to wake up underwater! |
| **Q** | **Bad Navigation** | Incorrect navigational charts and bad directions have sent the ship way off course and into stranger seas. Where are we, and where do we go from here? |
| **K** | **It's Alive** | The ship is brought to life through magical means. It's not going to cooperate without an incentive, now that it has its own wants and needs. |

188
README.md Normal file
View File

@@ -0,0 +1,188 @@
# Rats with Gats Remote Play (`pirats`)
A web-based remote play companion app for the tabletop roleplaying game **Rats with Gats** (where players roleplay as gunslinging pirate rats, or "Pi-Rats", in the magical land of Yeld). The application manages lobbies, character creation, card mechanics, scene phases, obstacles, challenges, and player voting. See [RULEBOOK.md](RULEBOOK.md) for the game rules.
It is built with a **FastAPI** + **SQLModel** (SQLite) backend and a **Svelte 5** single-page frontend (in `frontend/`), which polls the backend's JSON state endpoint for near-real-time updates.
---
## Features
- **Game Lobby:** Create games and join as players via a unique game ID.
- **Character Creation:**
- Fill out the character profile (appearance, smell, catchphrase).
- Delegate custom "Like/Hate" relationship questionnaire cards to other crewmates (answering them for each other!).
- Draft three "Secret Pirate Techniques" which are automatically shuffled and swapped among players.
- Bind swapped techniques to Face Cards (Jack, Queen, King) for play.
- **Dynamic Card Mechanics:**
- Automated 54-card deck shuffling and card drawing.
- Automatically matches card suits to thematic obstacles (Clubs for combat, Spades for stealth/cunning, Hearts for social/morale, Diamonds for nautical/athletics).
- **Scene Management:**
- Assign roles (Pi-Rats or "The Deep" narrator).
- Dynamic challenge creation linking multiple active obstacles.
- Automatically handles card matching colors/values, automatic success when playing Face Card techniques, and Joker card triggers to discard/replace obstacles.
- **Between-Scenes Voting:**
- Vote on crewmates to rank up.
- Toggle personal objectives (Get a Gat, Earn a Name, Die like a Pirate).
---
## Installation & Running
This project supports standard Python package tools as well as Nix flakes.
### Option 1: Standard Python Installation
#### Prerequisites
- Python >= 3.9
- Virtual environment tool (optional but recommended)
#### 1. Setup Virtual Environment (Optional)
```bash
python -m venv .venv
source .venv/bin/activate
```
#### 2. Install Package
Install the package and its dependencies in editable mode:
```bash
pip install -e .
```
#### 3. Run the App
Launch the FastAPI server using the installed package entrypoint:
```bash
pirats --host 0.0.0.0 --port 8000 --reload
```
Alternatively, run it with `uvicorn`:
```bash
uvicorn pirats.main:app --host 0.0.0.0 --port 8000 --reload
```
Once running, access the web UI at [http://localhost:8000](http://localhost:8000).
#### Configuration (environment variables)
| Variable | Default | Purpose |
| --- | --- | --- |
| `DATABASE_URL` | `sqlite:///./rats_with_gats.db` | SQLAlchemy database URL. |
| `PIRATS_LOG_FILE` | _(unset)_ | If set, also write logs to this file (rotating, 10 MB × 5 backups). Logs always go to stderr regardless. |
| `PIRATS_LOG_LEVEL` | `INFO` | Minimum log severity (`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`). |
| `PIRATS_DEV_MODE` | _(unset = on)_ | Default Dev Mode for new games. Unset is treated as a local checkout (on); set to `0`/`false` to disable. |
| `PIRATS_PURGE_ENABLED` | `true` | Periodically purge old finished/inactive games. |
| `PIRATS_PURGE_INTERVAL_HOURS` | `24` | How often the purge task runs. |
| `PIRATS_PURGE_FINISHED_DAYS` | `14` | Purge games that finished more than this many days ago. |
| `PIRATS_PURGE_INACTIVE_DAYS` | `30` | Purge games with no activity for this many days. |
| `PIRATS_MAX_BODY_BYTES` | `1048576` | Reject request bodies larger than this (HTTP 413). |
---
### Option 2: Using Nix Flakes
If you use the Nix package manager, this repository provides a complete environment.
#### 1. Enter the Development Shell
Includes Python, all runtime dependencies, and testing tools (`pytest`):
```bash
nix develop
```
#### 2. Run the Application
Run the package directly from the flake:
```bash
nix run . -- --host 0.0.0.0 --port 8000
```
#### 3. Build the Application Package
```bash
nix build .
```
The executable will be located in `./result/bin/pirats`.
---
## Deploying as a NixOS Service
The Nix flake exposes a NixOS module that configures the application to run as a systemd service with appropriate sandboxing.
Add this flake to your system configuration and configure the service:
```nix
services.pirats = {
enable = true;
host = "127.0.0.1";
port = 8000;
databasePath = "/var/lib/pirats/rats_with_gats.db";
logFile = "/var/log/pirats/pirats.log"; # rotating; stderr also goes to the journal
logLevel = "info";
openFirewall = false; # Set to true to open ports in the firewall
purge = {
enable = true; # periodically remove old finished/inactive games
intervalHours = 24;
finishedDays = 14; # purge games finished more than 14 days ago
inactiveDays = 30; # ...or with no activity for 30 days
};
};
```
The service writes its log to `logFile` (under the systemd-managed `/var/log/pirats`
`LogsDirectory`, so the sandboxed `DynamicUser` can write to it) and additionally
to the systemd journal via stderr (`journalctl -u pirats`).
---
## Running Tests
Tests are written using `pytest`.
### Running with Pip
```bash
pytest
```
### Running with Nix
```bash
nix develop --command pytest
```
---
## Project Structure
```
pirats/
├── pyproject.toml # Python package metadata and dependencies
├── flake.nix # Nix package (incl. frontend build), dev shell, NixOS service module
├── tests/
│ └── test_game.py # Game logic and CRUD test suite (pure-crud, no HTTP for most)
├── frontend/ # Svelte 5 + Vite SPA
│ └── src/
│ ├── pages/ # Routes: Home, Join, Dashboard (game UI), Admin
│ ├── components/ # One component per game phase, Card.svelte, and scene/ (ScenePhase sub-panels)
│ └── lib/ # api.js (fetch wrapper), cards.js (card display helpers), suggestions.js (Suggest-button pools)
└── src/
└── pirats/ # FastAPI backend package
├── main.py # App setup, /api/game create/join/state endpoints, CLI entrypoint
├── models.py # SQLModel tables (Game, Player, Obstacle, Challenge, Vote, GameEvent)
├── cards.py # Card parsing, deck building, obstacle table from the rulebook
├── crud.py # Facade re-exporting all crud_* modules
├── crud_*.py # Game logic by phase: base (deck/hands/rank), character, scene, challenge, upkeep
├── routes_*.py # Thin API routers by phase, mounted under /api
└── static/ # Built frontend lands here (gitignored; populated by the Nix build)
```
### Frontend Development
Start the backend and Vite dev server together from the repository root. Pressing Ctrl-C stops both:
```bash
./dev.sh
```
To run only the frontend, start the backend separately on port 8000, then run:
```bash
cd frontend && npm install && npm run dev
```
Vite proxies `/api` to the backend on port 8000.

309
RULEBOOK.md Normal file
View File

@@ -0,0 +1,309 @@
# Rats with Gats: A Story Game about the Pi-Rats of Yeld!
**Written & Designed by**: Nick Smith
**Art by**: J. Richmond
**Yeld Team**: Sally Hsu (Producer), Maia and EmmaVoid (Support)
*Copyright 2025 Nick Smith and J. Richmond. The Magical Land of Yeld is a product of Yeld LLC.*
Welcome to **Rats with Gats**, a collaborative storytelling game about ordinary stowaway rats onboard a ship who are transformed into gunslinging Pi-Rat pirates through the radical power of Math Magic!
---
## Quick Reference Links
* [OBSTACLES.md](file:///Users/ttm/Projects/pirats/OBSTACLES.md) - Tables of Obstacle Types by suit (Clubs, Spades, Hearts, Diamonds)
* [EXAMPLESCENE.md](file:///Users/ttm/Projects/pirats/EXAMPLESCENE.md) - Full walkthrough of a play scene ("The Cheesy Heist")
---
# Table of Contents
1. [Part 1: Introduction](#part-1-introduction)
2. [Part 2: Creating a Pi-Rat](#part-2-creating-a-pi-rat)
* [Rank and Hand Size](#rank-and-hand-size)
* [Secret Pirate Techniques](#secret-pirate-techniques)
* [Objectives](#objectives)
* [Personal Objectives](#personal-objectives) (Gat & Name Privileges/Taxes)
* [Crew Objectives](#crew-objectives) (Captain rules)
3. [Part 3: Playing The Game](#part-3-playing-the-game)
* [Setting Scenes](#setting-scenes)
* [Playing Out a Scene](#playing-out-a-scene)
* [Ending a Scene](#ending-a-scene)
* [Between Scenes](#between-scenes)
* [Resolving Challenges](#resolving-challenges)
* [Using Face Cards & Jokers](#using-face-cards-jokers)
* [Advanced Resolution Mechanics & Questions](#advanced-resolution-mechanics-questions)
4. [Part 4: Setting Information](#part-4-setting-information)
5. [Final Thoughts](#final-thoughts)
---
# Part 1: Introduction
What Are Pi-Rats?
Pi-Rats are four-foot-tall, gun-toting, ship-stealing rodents created by accident during a long naval war between the Kingdom of Yeld and the Legions of the Deep. Pi-Rats were originally designed to be weapons—disposable soldiers who could overwhelm enemy crews. Instead, they became something far more dangerous: pirates with attitude, cunning, and an unshakeable love of violence and the sea. Pi-Rats are scrappy, loyal to their crews, and almost never die of old age (mostly because they rarely survive that long).
Your Story Begins
You and your fellow players are ordinary rats—stowaways hiding in the cargo hold of a ship sailing the dangerous waters of Yeld. But when an unknown Mathematical mage casts the “Pie” spell, everything changes. Youre suddenly bigger, smarter, and armed with an overwhelming urge to commit piracy. Youre now a Pi-Rat!
Your first order of business? Steal the ship youre on. Deal with the current crew, establish control, and make it yours. From there, the real adventure begins: choosing a captain, assembling your crew, and committing enough piracy to earn your place in Pi-Rat legend.
The World Of Yeld
Yeld is a vast and dangerous fantasy world—a place of magic, monsters, and mystery. The oceans are controlled by the Deep, an ancient oceanic god who wages eternal war against the Vampire Prince Dragul and his Fairy armies, who rule the Kingdom of Yeld. Pi-Rats exist in the chaos between these powers, stealing ships from both sides and carving out their own chaotic existence on the high seas. Expect to encounter Mermaids, Squidfolk, vengeful ghosts, magical storms, rival pirates, and the occasional Bean Whale whos fallen in love with your ship.
### What Do I Need To Play?
* **36 Players** with a strong enthusiasm for whimsical piracy or rodentia.
* **A standard deck of playing cards** with both Jokers still in there.
* **A couple sheets of paper, some note cards, and at least 1 pencil** for tracking Objectives, Pi-Rats (Rat Records), and Secret Pirate Techniques.
### Summary Of Play
1. Each Player creates a Pi-Rat crewmember and 3 Secret Pirate Techniques.
2. As a group, take turns setting scenes where some Players control their Pi-Rats while others play as the Deep.
3. During each scene, participating Pi-Rat Players attempt to complete Personal and/or Crew Objectives.
4. Deep Players draw Obstacle cards and challenge the Pi-Rats.
5. Pi-Rats play cards from their Hand to overcome Obstacles.
6. A scene ends when all Pi-Rats have fled or the Deep chooses to end it.
7. Between scenes, Players vote to Rank up crew members and rotate roles.
---
# Part 2: Creating a Pi-Rat
To create a Pi-Rat, answer the following 6 questions and record them on a sheet of paper (your **Rat Records**). Go with your gat!
1. **What does your Pi-Rat look like?** Pi-Rats look very similar to one another, but distinguish themselves with bandanas, eyepatches, clothing, scars, tattoos, or body paint.
2. **What does your Pi-Rat smell like?** Pi-Rats identify each other by scent. Before receiving a proper name, they are referred to by the smells they produce.
3. **What was the first thing your Pi-Rat said after transforming?** A Pi-Rats first words indicate their pirate personality.
4. **Is your Pi-Rat good at Math?** This has no implications or meaning.
5. **What is something other pirates like about your Pi-Rat?** Let another Player answer this question for you.
6. **What is something other pirates hate about your Pi-Rat?** Let another Player answer this question for you.
*(Dont worry about a name right now; that comes later!)*
### Determine Starting Rank
Once questions are answered, randomly determine each Player's starting Rank:
* **One Player** starts at **Rank 3**. This Player must play the **Deep** during the first scene of the game.
* **One Player** starts at **Rank 1**. This Player must play their **Pi-Rat** during the first scene.
* **All remaining Players** start at **Rank 2**. These Players may choose to play either their Pi-Rat or the Deep during the first scene.
---
## Rank and Hand Size
Your Rank represents your status and reputation. The higher your Rank, the more respected you are. Pi-Rats gain Ranks as follows:
* Complete your first Personal Objective $\rightarrow$ **Gain 1 Rank**
* Complete your second Personal Objective $\rightarrow$ **Gain 1 Rank**
* Complete your third Personal Objective $\rightarrow$ **Choose another Pi-Rat to gain 1 Rank**
* Receive the most votes during Between Scene voting $\rightarrow$ **Gain 1 Rank**
* Special circumstances determined by the group $\rightarrow$ **Gain 1 Rank**
Your Hand size determines how many cards you have available for Challenges. Compare Ranks to determine Hand Sizes:
| Rank Placement | Maximum Hand Size |
| :--- | :---: |
| **Highest Rank Pi-Rat(s)** | 4 cards |
| **Middle Rank Pi-Rat(s)** | 3 cards |
| **Lowest Rank Pi-Rat(s)** | 2 cards |
> [!IMPORTANT]
> Anytime your Hand size increases, you immediately draw a card and add it to your Hand. This may happen in the middle of a Scene.
---
## Secret Pirate Techniques
Any time a Pi-Rat Player plays a Face Card (Jack, Queen, King) when resolving a Challenge, that Challenge is resolved utilizing a **Secret Pirate Technique**! These represent special moves, tricks, or cartoon logic.
**Example Techniques:**
* *Secret Pirate Technique #64*: "I missed on purpose"
* *Secret Pirate Technique #3*: "Carlos will figure it out"
* *Secret Pirate Technique #57*: "Never trust a hatless captain"
* *Secret Pirate Technique #90*: "I'm not left handed"
* *Secret Pirate Technique #15*: "The Albatross is always watching"
* *Secret Pirate Technique #99*: "I've got a bomb"
#### Technique Creation & Assignment
1. Using individual note cards, each Player writes down the names of 3 Secret Pirate Techniques.
2. Once created, Players swap those techniques, one at a time, with other Players until they have 3 new techniques created by others.
3. Write these 3 Techniques in your Rat Records.
4. Assign each of the 3 Techniques to a different Face Card type (**Jack, Queen, King**). Playing that Face Card type activates that specific Technique, allowing you to narrate a spectacular, automatic success!
---
## Objectives
Objectives guide your actions. Always consult them for inspiration during a scene.
### Personal Objectives
Must be completed in order:
1. **Get a Gat (or something just as good)**: Acquire an impressive weapon (pistol, cutlass, etc.).
* **Gat Privileges**: While you have a Gat, Black cards you play (Clubs ♣ and Spades ♠) during Challenges have their Value increased by 1.
* **Gat Tax**: When a Gatless Pi-Rat faces a Challenge, they may ask a Pi-Rat with a Gat in the same Scene to attempt it for them.
* *If accepted*: The Gatless Pi-Rat gives the Gatted Pi-Rat one random card from their Hand (the Gatted player chooses without looking). The Gatted Pi-Rat attempts the Challenge, sharing the successes and failures.
* *If refused*: The Gatted Pi-Rat must hand over their Gat to the Gatless Pi-Rat (who immediately completes Personal Objective 1). The newly Gatted Pi-Rat attempts the Challenge. If they succeed, they keep the Gat (the original owner must find a new one). If they fail, they return the Gat and cannot initiate a Gat/Name Tax for the rest of the Scene.
2. **Earn a Name (a normal one is fine)**: Gain a proper pirate name (e.g., Carlos, Barry, Jaspar) instead of temporary crew nicknames (e.g., Piss Whiskers, Dainty Spanker).
* **Name Privileges**: While you have a Name, Red cards you play (Diamonds ♦ and Hearts ♥) during Challenges have their Value increased by 1.
* **Name Tax**: When a Pi-Rat with a Gat but no Name faces a Challenge, they may ask a Named Pi-Rat in the same Scene to attempt it for them.
* *If accepted*: The Nameless Pi-Rat gives the Named Pi-Rat one random card from their Hand. The Named Pi-Rat attempts the Challenge, sharing successes/failures.
* *If refused*: The Named Pi-Rat gives their Name to the Nameless Pi-Rat (who immediately completes Personal Objective 2). The newly Named Pi-Rat attempts the Challenge. If they succeed, they keep the Name. If they fail, they return the Name and cannot initiate a Gat/Name Tax for the rest of the Scene.
3. **Die Like a Pirate (some pirates retire instead; those pirates are smarter than you)**: Go out in a blaze of glory or retire honorably.
### Crew Objectives
Must be completed in order:
1. **Steal a Ship (the one we're on right now is a good choice)**: Take control of a vessel by dealing with the crew.
2. **Choose a Captain**: Establish a leader.
3. **Commit Piracy**: Plunder, raid, bury treasure, and build your reputation.
#### Pi-Rat Captain Rules
* **Becoming Captain**: Once the crew controls a ship, the highest-Ranked Pi-Rat becomes the de facto Captain. Any Pi-Rat can Challenge for leadership at any time during a scene if both are present. The Captain remains in power until they give up the position, are defeated in a challenge, die, or the ship is lost.
* **Captain Privileges**: The Captain's Hand size is increased by +1, regardless of Rank. The Captain has the final say in all ship/crew disputes, and wins ties in crew votes.
* **Captain Tax**: Being Captain is high pressure! Captains lose 1 Rank each time they personally fail a Challenge during a scene.
---
# Part 3: Playing The Game
## Setting Scenes
Before each Scene, complete these setup steps:
1. **Declare Roles!** Players announce their role: **Pi-Rat Players** (control characters) or **Deep Players** (control environment, threats, NPCs). There must be at least 1 of each per scene. Players who were Deep in the previous scene must play Pi-Rats in the next.
2. **Shuffle and Draw Obstacles!** Return discards to the deck and shuffle. Draw cards from the top and add them to the **Obstacle List** until the total is **at least one greater than the number of Deep Players** in the scene.
3. **Frame the Scene!** Deep Players collaboratively describe the location, situation, and how the drawn Obstacles manifest as an urgent problem.
> [!NOTE]
> **First Scene Setup**: The first scene must establish that the Pi-Rats are on a ship they can steal (or have immediate opportunity to do so). Do not start far from water.
---
## Playing Out a Scene
* **Pi-Rat Players** narrate actions, roleplay, make decisions, and play cards to overcome Obstacles.
* **Deep Players** control NPCs, hazards, enemies, and call for Challenges when Pi-Rats attempt something risky. Deep Players can distribute the Obstacles among themselves or share them.
---
## Ending a Scene
Deep Players choose when to end a Scene. Minimum requirements to end a Scene:
* Each participating Pi-Rat has been challenged at least once and had a chance to progress an Objective.
* OR there are no Obstacles remaining on the Obstacle List.
* OR all Pi-Rat Players have fled or been narratively incapacitated (death, capture, etc.).
---
## Between Scenes
Upkeep steps before starting a new Scene:
1. **Pirate Ranking (Voting)**: Each Player nominates one *other* Pi-Rat Player to Rank up. The player with the most votes gains 1 Rank. Ties are resolved by discussion or random choice. Deep Players from the previous scene cannot be nominated but may vote.
2. **Additional Rank Bonuses**: Apply Ranks for completed Personal Objectives.
3. **Deep Players Draw Cards**: Players who were Deep in the previous scene may discard any number of cards, then draw back up to their maximum Hand size (based on their Rank).
4. **Start a New Scene!** Repeat setup.
---
## Ending the Game
End the story when the group feels ready. For the best experience, ensure:
* Each player has played the Deep at least once.
* Each Pi-Rat has completed at least one Personal Objective.
* The crew has completed all 3 Crew Objectives.
* *Recommended*: Include a wrap-up scene where the Pi-Rats party til they pass out in a pile.
---
# Resolving Challenges
When a Deep Player calls for a Challenge, follow these steps:
### Step 1: Establish Stakes!
Deep Players explain what success and failure mean.
### Step 2: Apply Obstacles!
Deep Players apply one or more cards from the **Obstacle List** to the challenge. (See [OBSTACLES.md](file:///Users/ttm/Projects/pirats/OBSTACLES.md) for the suit/value meanings).
### Step 3: Play Cards!
The Pi-Rat Player plays one card from their Hand for each applied Obstacle.
* **Card Beats Obstacle Value**: Success!
* **Card is Equal to or Lower than Obstacle Value (or no card played)**: Failure!
* *Note*: If multiple Obstacles are applied, you only need to succeed at one to pass the Challenge, but each failed Obstacle creates a narrative complication.
#### Card Values:
* **Numbers (210)**: Face value.
* **Aces**: Value of 1.
* **Face Cards (J, Q, K)**: When played by a Pi-Rat, activates a Secret Pirate Technique (automatic success). When acting as an Obstacle, their value equals the Rank of the challenged Pi-Rat.
### Step 4: Place Cards!
Played cards are placed face up below the applied Obstacle card and rotated 90°. **The last played card in the column determines the Obstacle's new value for future challenges.** The original Obstacle card always determines the suit color and narrative chart entry.
### Step 5: Narrate Results!
The suit of the card played to successfully beat an Obstacle determines how the Pi-Rat overcomes it:
* **♣ Clubs - Shootin' and Stabbin'**: You use violence, combat, or direct confrontation.
* **♦ Diamonds - Swimmin' and Sailin'**: You use nautical skills, athletics, or mobility.
* **♠ Spades - Sneakin' and Schemin'**: You use stealth, deception, or cunning plans.
* **♥ Hearts - Shoutin' and Singin'**: You use social skills, performance, or charisma.
*(If you play a Face Card, narrate using the name of your Secret Pirate Technique instead).*
### Step 6: Draw Cards!
Draw 1 card back into your Hand for each card you played that **matched the suit color** (Red/Black) of the original Obstacle card.
---
# Using Face Cards & Jokers
### Jacks, Queens, and Kings
* **Played by a Pi-Rat**: Triggers a assigned Secret Pirate Technique. **Automatic success** regardless of the Obstacle value. Describe the ridiculous trick in action.
* **Drawn as or placed below an Obstacle**: The value of the card is equal to the **Rank** of the challenged Pi-Rat.
### Jokers (Pirate Luck)
* **Played by a Pi-Rat**: Play at any time (before or after a challenge) to discard one Obstacle from the list entirely (along with its column) and draw a new one. The Joker is then discarded.
* **Drawn as an Obstacle**: Discard the Joker immediately, and permanently increase the number of Obstacles required on the list for the following scene by 1.
---
# Advanced Resolution Mechanics & Questions
* **Can the Deep say no?** Yes. Deep Players can veto impossible or context-breaking actions without offering a challenge.
* **Can multiple Pi-Rats attempt the same Challenge?** Yes. Other participating Pi-Rats can play their own cards against the applied Obstacles on behalf of the challenged Pi-Rat. They narrate their assistance and share in the successes and failures, but **only the originally challenged Pi-Rat draws cards** in Step 6.
* **Can Pi-Rats Challenge each other?** Yes. The challenging Pi-Rat plays 1 card from their hand face up as a temporary Obstacle (narrating based on suit). The defender plays a card against it. Resolve normally. Afterward, discard the temporary Obstacle and cards below it. Only the defender draws cards in Step 6.
* **How do we get rid of Obstacles?** Once an Obstacle has been successfully beaten a number of times **equal to the total number of players**, it is discarded. Unbeaten or partially beaten Obstacles remain on the list across scenes.
* **Can my Pi-Rat flee from a Scene?** Yes, anytime before or after a Challenge. Fleeing Pi-Rats cannot participate until the next Scene and cannot progress Objectives.
* **What do we do with discarded cards?** Place them in the discard pile. They are shuffled back into the deck during Step 2 of Setting Scenes.
* **What happens when my Pi-Rat dies, retires, or is kicked out?** Your story arc is complete! Create a new Rank 1 recruit with new random techniques and fresh objectives. Alternatively, you can play your deceased Pi-Rat as a **Ghost** (unable to interact well with the living but still helpful).
* **What happens when our ship sinks?** Stealing a new ship becomes the immediate priority. The Pi-Rats might wash up on an island, get rescued (and steal the rescue ship), build a raft, or escape capture.
---
# Part 4: Setting Information
The Magical Land of Yeld
Built on the back of a slumbering Serpent God, Yeld is a mysterious fantasy land bordered by dangerous wildlands. The Fairies claim to be the oldest inhabitants, but even they do not know all its secrets. It is a land of ruins, magic, monsters, and varied climates (mountains, swamps, deserts, forests, and frozen tundras).
The Vampire Prince, Dragul
Dragul usurped control of Yeld nearly 500 years ago with the help of Fairies from the Fairy Lands. A civil war crushed resistance and spread undeath. Centuries later, the oceanic god (the Deep) sent its legions of Mermaids and Squidfolk to eradicate Dragul and the undead, resulting in an ongoing war on land and sea.
The Deep
The Deep is an ancient oceanic god who detests Prince Dragul and undeath. It created species like Mermaids and Squidfolk to act as commanders and soldiers in its legions.
The Pi-Rats of Yeld
Created during the war when Goblin Math Divers cast the spell "Pie" (a spell of recited numbers that transforms stowaway rats into giant, clever gunslinging rodents to overwhelm enemy crews). The Pi-Rats proved too clever, learned to sail and speak, and stole so many ships they temporarily ground the war to a halt! They have a reputation for chaos, violence, and loyalty. There are 43 traditionally accepted Pi-Rat names, and a rat must perform a legendary feat to add a new name to the list.
### Other Peoples of Yeld
* **King's People**: Human in appearance with pointed ears. The original rulers of Yeld before Dragul's usurpation.
* **Fairies**: Large, furry creatures with pointed ears, sharp teeth, and flat feet. Sturdy, strong enough to throw a bull, and loyal to Dragul.
* **Mermaids**: Daughters of the Deep. Humanoid with finned ears and tails that propel them through water. They can transform their tails into legs to walk and fight on land.
* **Squidfolk**: The Deep's first creations. Retain weird squid heads. Initially poor spies, they now live peacefully in Yeld after being replaced by the Octopus Tribe.
* **Vampires**: Created by Dragul. Possess Fairy-like strength, speed, and immortality, but must consume flesh and blood to survive. Only King's People and Mermaids can become Vampires.
* **Goblins**: Short, mossy-green beings with large ears and stubby tails. They wear containment suits because they cannot breathe Yeld's air. Gifted merchants, mechanics, and traders.
* **Animal Tribes**: The true natives of Yeld who lived there before Witches or Fairies. Hundreds of tribes serve the Deep under the waves.
* **Ghosts**: Inhabitants of the Ghost World, a gray filter over reality. Ghosts can look back into the living world, watch over friends, or haunt enemies.
---
# Final Thoughts
*Rats with Gats* is about embracing madness, celebrating small victories, and telling ridiculous stories. Get silly, don't take it too seriously, and name your techniques ridiculous things (like *"The Cheese Tickler's Gambit"* or *"Omae Wa Mou Shindeiru"*). Every Pi-Rat deserves a chance at glory.
*Want more? Get the full game "The Magical Land of Yeld" at [YeldStuff.com](http://YeldStuff.com)!*

15
TODO.md Normal file
View File

@@ -0,0 +1,15 @@
## Polish
- [x] The character sheet modal popup is narrower than it needs to be on wide screens. I think it could safely be like 90% as wide as the non-modal UI.
- [x] Popping out the event log should leave a close button where the "Event Log" open button is, so that you don't have to move your mouse to quickly toggle it. The existing close button can remain.
- [x] Use the same vertical UI for the player list in all phases
- [x] In the admin panel, if a pi-rat doesn't have a name yet, it should show something like 'not chosen yet' rather than the player name
- [x] In the admin panel, the "Open" and "Copy" buttons should be the same size
- [x] Store the player name in local storage and pre-populate it when joining a new game (but don't force it, let the player edit it if they'd prefer a different name)
- [x] If a player tries to rejoin a game that has ended or that they've been kicked from, they should get an error message and it should be removed from the list of re-joinable games
- [x] Games should have join codes in addition to join links. Join codes should be a sequence of 5 alphanumeric characters (upper case letters only, no ambiguous letters like 0/O/1/I). Add a panel to the home page for joining a game via code, and display the join code on the admin page
## Words Words Words
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.

47
alembic.ini Normal file
View File

@@ -0,0 +1,47 @@
# Dev-only config for the alembic CLI (the app runs migrations itself at
# startup via pirats.database.run_migrations).
#
# New migration: .venv/bin/alembic revision --autogenerate -m "add foo column"
# Apply: .venv/bin/alembic upgrade head
#
# No sqlalchemy.url here: env.py uses the app's engine (DATABASE_URL or
# ./rats_with_gats.db). Override with: alembic -x url=sqlite:///other.db ...
[alembic]
script_location = src/pirats/migrations
file_template = %%(rev)s_%%(slug)s
prepend_sys_path = src
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

38
dev.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PIDS=()
cleanup() {
local status=$?
trap - EXIT INT TERM
if ((${#PIDS[@]})); then
kill "${PIDS[@]}" 2>/dev/null || true
wait "${PIDS[@]}" 2>/dev/null || true
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
cd "$ROOT_DIR"
echo "Starting backend at http://localhost:8000"
"$ROOT_DIR/.venv/bin/uvicorn" pirats.main:app --host 0.0.0.0 --port 8000 --reload &
PIDS+=("$!")
echo "Starting frontend at http://localhost:5173"
(
cd "$ROOT_DIR/frontend"
exec ./node_modules/.bin/vite
) &
PIDS+=("$!")
echo "Press Ctrl-C to stop both servers."
wait

34
flake.lock generated
View File

@@ -1,23 +1,5 @@
{ {
"nodes": { "nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1780749050, "lastModified": 1780749050,
@@ -36,24 +18,8 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

179
flake.nix
View File

@@ -3,54 +3,87 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
}; };
outputs = { self, nixpkgs, flake-utils, ... }: outputs = { self, nixpkgs, ... }:
flake-utils.lib.eachDefaultSystem (system: let
let # Systems to support
pkgs = nixpkgs.legacyPackages.${system}; supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
python = pkgs.python3;
# Helper to generate an attrset for all supported systems
# Define the application package forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
piratsApp = python.pkgs.buildPythonApplication { in
pname = "pirats"; {
version = "0.1.0"; packages = forAllSystems (system:
src = ./.; let
format = "pyproject"; pkgs = nixpkgs.legacyPackages.${system};
python = pkgs.python3;
# Build the Svelte frontend
frontend = pkgs.buildNpmPackage {
pname = "pirats-frontend";
version = "0.1.0";
src = ./frontend;
npmDepsHash = "sha256-S2CXmFgovGD40wS4bTsiVF84J3zEQK6qrg7EPy+ojPY=";
installPhase = ''
mkdir -p $out
cp -r dist $out/
'';
};
in
{
default = python.pkgs.buildPythonApplication {
pname = "pirats";
version = "0.1.0";
src = ./.;
format = "pyproject";
nativeBuildInputs = with python.pkgs; [ nativeBuildInputs = with python.pkgs; [
setuptools setuptools
wheel wheel
]; ];
propagatedBuildInputs = with python.pkgs; [ propagatedBuildInputs = with python.pkgs; [
fastapi alembic
sqlmodel fastapi
uvicorn sqlmodel
jinja2 uvicorn
python-multipart websockets
]; python-multipart
httpx
];
nativeCheckInputs = with python.pkgs; [ nativeCheckInputs = with python.pkgs; [
pytestCheckHook pytestCheckHook
]; ];
preBuild = ''
mkdir -p src/pirats/static
cp -r ${frontend}/dist/* src/pirats/static/
'';
pythonImportsCheck = [ "pirats" ]; pythonImportsCheck = [ "pirats" ];
}; };
in pirats = self.packages.${system}.default;
{ }
packages.default = piratsApp; );
packages.pirats = piratsApp;
devShells = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.default ];
packages = with pkgs; [
pkgs.python3.pkgs.pytest
pkgs.nodejs
];
};
}
);
devShells.default = pkgs.mkShell {
inputsFrom = [ piratsApp ];
packages = with pkgs; [
python.pkgs.pytest
];
};
}
) // {
# NixOS Module to run the application as a service # NixOS Module to run the application as a service
nixosModules.default = { config, lib, pkgs, ... }: nixosModules.default = { config, lib, pkgs, ... }:
let let
@@ -79,11 +112,59 @@
default = "/var/lib/pirats/rats_with_gats.db"; default = "/var/lib/pirats/rats_with_gats.db";
description = "Path to the SQLite database file."; description = "Path to the SQLite database file.";
}; };
logFile = lib.mkOption {
type = lib.types.str;
default = "/var/log/pirats/pirats.log";
description = ''
Path to the server log file (rotating, 10 MB x 5 backups). stderr is
also captured by the systemd journal. The default lives under the
service's LogsDirectory (/var/log/pirats), which the sandboxed
DynamicUser can write to; a custom path elsewhere must be made
writable by the service separately.
'';
};
logLevel = lib.mkOption {
type = lib.types.enum [ "debug" "info" "warning" "error" "critical" ];
default = "info";
description = "Minimum severity of log messages to record.";
};
maxBodyBytes = lib.mkOption {
type = lib.types.ints.positive;
default = 1048576;
description = "Reject request bodies larger than this many bytes (HTTP 413).";
};
openFirewall = lib.mkOption { openFirewall = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = false; default = false;
description = "Open ports in the firewall for the service."; description = "Open ports in the firewall for the service.";
}; };
devModeDefault = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether new games start with Dev Mode (testing shortcuts) enabled.";
};
purge = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Periodically purge old finished/inactive games.";
};
intervalHours = lib.mkOption {
type = lib.types.numbers.positive;
default = 24;
description = "How often (in hours) the purge task runs.";
};
finishedDays = lib.mkOption {
type = lib.types.numbers.positive;
default = 14;
description = "Purge games that finished more than this many days ago.";
};
inactiveDays = lib.mkOption {
type = lib.types.numbers.positive;
default = 30;
description = "Purge games with no activity for this many days.";
};
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
@@ -96,15 +177,29 @@
environment = { environment = {
DATABASE_URL = "sqlite:///${cfg.databasePath}"; DATABASE_URL = "sqlite:///${cfg.databasePath}";
# Unset means "local checkout" and defaults Dev Mode on, so the
# service must always set it explicitly.
PIRATS_DEV_MODE = lib.boolToString cfg.devModeDefault;
PIRATS_LOG_FILE = cfg.logFile;
PIRATS_LOG_LEVEL = cfg.logLevel;
PIRATS_PURGE_ENABLED = lib.boolToString cfg.purge.enable;
PIRATS_PURGE_INTERVAL_HOURS = toString cfg.purge.intervalHours;
PIRATS_PURGE_FINISHED_DAYS = toString cfg.purge.finishedDays;
PIRATS_PURGE_INACTIVE_DAYS = toString cfg.purge.inactiveDays;
PIRATS_MAX_BODY_BYTES = toString cfg.maxBodyBytes;
}; };
serviceConfig = { serviceConfig = {
ExecStart = "${cfg.package}/bin/pirats --host ${cfg.host} --port ${toString cfg.port}"; ExecStart = "${cfg.package}/bin/pirats --host ${cfg.host} --port ${toString cfg.port}";
Restart = "always"; Restart = "always";
# Sandboxing and security # Sandboxing and security
DynamicUser = true; DynamicUser = true;
StateDirectory = "pirats"; StateDirectory = "pirats";
# Creates /var/log/pirats owned by the dynamic user and adds it to
# ReadWritePaths, so the default logFile under it is writable
# despite ProtectSystem=strict.
LogsDirectory = "pirats";
WorkingDirectory = "/var/lib/pirats"; WorkingDirectory = "/var/lib/pirats";
ProtectSystem = "strict"; ProtectSystem = "strict";

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

43
frontend/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Svelte + Vite
This template should help get you started developing with Svelte in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `checkJs` in the JS template?**
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

19
frontend/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rats with Gats</title>
<script>
// Apply the saved theme before first paint to avoid a flash of the
// wrong palette. lib/theme.js owns this key afterwards.
document.documentElement.dataset.theme =
localStorage.getItem('pirats-theme') === 'dark' ? 'dark' : 'light';
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

33
frontend/jsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"types": ["vite/client"],
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}

1216
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "^5.55.5",
"vite": "^8.0.12"
},
"dependencies": {
"@fontsource/alegreya-sans": "^5.2.8",
"@fontsource/alegreya-sc": "^5.2.8",
"@fontsource/pirata-one": "^5.2.8",
"svelte-spa-router": "^5.1.0"
}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="2" y="2" width="60" height="60" rx="14" fill="#131822" stroke="#d9a23c" stroke-width="3"/>
<text x="32" y="44" font-size="36" text-anchor="middle">🐀</text>
</svg>

After

Width:  |  Height:  |  Size: 240 B

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

20
frontend/src/App.svelte Normal file
View File

@@ -0,0 +1,20 @@
<script>
import Router from 'svelte-spa-router';
import Home from './pages/Home.svelte';
import Join from './pages/Join.svelte';
import Dashboard from './pages/Dashboard.svelte';
import Admin from './pages/Admin.svelte';
import CornerMenu from './components/CornerMenu.svelte';
const routes = {
'/': Home,
'/game/:id/join': Join,
'/game/:id/player/:pid': Dashboard,
'/game/:id/admin': Admin,
};
</script>
<main>
<Router {routes} />
<CornerMenu />
</main>

17
frontend/src/app.css Normal file
View File

@@ -0,0 +1,17 @@
/* ==========================================================================
RATS WITH GATS — STYLESHEET INDEX
theme.css holds every color/font/shape token (edit it to re-theme).
base.css holds the reset, typography, and utility classes.
The rest are component- and phase-specific styles built on those tokens.
========================================================================== */
@import './assets/css/theme.css';
@import './assets/css/base.css';
@import './assets/css/components.css';
@import './assets/css/card.css';
@import './assets/css/welcome.css';
@import './assets/css/lobby.css';
@import './assets/css/character.css';
@import './assets/css/scene-setup.css';
@import './assets/css/scene-play.css';
@import './assets/css/upkeep.css';
@import './assets/css/admin.css';

View File

@@ -0,0 +1,164 @@
/* --- Admin panel --- */
.admin-page {
max-width: 56rem;
margin: 0 auto;
padding: 2rem 1rem;
}
.admin-page h1 {
font-family: var(--font-display);
font-weight: 400;
font-size: 2.2rem;
color: var(--accent);
margin-bottom: 1.5rem;
letter-spacing: 1px;
}
.admin-key-chip {
background: var(--well);
border: 1px solid var(--edge);
border-radius: 4px;
padding: 0.15rem 0.5rem;
font-family: ui-monospace, monospace;
font-size: 0.875rem;
}
.admin-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
}
.admin-summary-details {
min-width: 0;
}
.admin-join-code {
flex: 0 0 auto;
min-width: 10.5rem;
padding: 0.85rem 1.1rem 0.95rem;
background: color-mix(in srgb, var(--accent) 12%, var(--well));
color: var(--text);
border: 2px solid var(--accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
font: inherit;
text-align: center;
cursor: pointer;
transition: var(--transition-smooth);
}
.admin-join-code:hover {
background: color-mix(in srgb, var(--accent) 20%, var(--well));
transform: translateY(-1px);
}
.admin-join-code:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent);
outline-offset: 2px;
}
.admin-join-code.copied {
border-color: var(--success);
}
.admin-join-code-label {
display: block;
margin-bottom: 0.2rem;
color: var(--text-muted);
font-family: var(--font-heading);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.admin-join-code strong {
display: block;
color: var(--accent);
font-family: ui-monospace, monospace;
font-size: 2rem;
line-height: 1;
letter-spacing: 0.16em;
}
.admin-join-code-hint {
display: block;
margin-top: 0.35rem;
color: var(--text-muted);
font-size: 0.75rem;
}
@media (max-width: 600px) {
.admin-summary {
flex-direction: column;
}
.admin-join-code {
width: 100%;
}
}
.admin-table {
width: 100%;
text-align: left;
background: var(--well);
border: 1px solid var(--edge);
border-radius: var(--radius-sm);
border-collapse: collapse;
margin-top: 1rem;
}
.admin-table th, .admin-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--edge-soft);
}
.admin-table th {
font-family: var(--font-heading);
color: var(--accent);
}
.link-copy-action {
display: flex;
gap: 0.5rem;
align-items: center;
flex: 1;
max-width: 500px;
}
.link-copy-action > .btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 5.5rem;
height: 2.3rem;
flex-shrink: 0;
text-align: center;
}
/* --- Game over --- */
.gameover-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.gameover-card {
text-align: center;
}
.gameover-card h1 {
font-family: var(--font-display);
font-weight: 400;
font-size: 2.6rem;
color: var(--accent);
letter-spacing: 1px;
margin-bottom: 0.75rem;
}
.gameover-card .sheet-group {
text-align: left;
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,231 @@
/* --- Reset & page setup --- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background:
radial-gradient(ellipse at 50% -20%, color-mix(in srgb, var(--accent) 7%, transparent) 0%, transparent 55%),
radial-gradient(circle at 50% 60%, var(--bg) 0%, var(--bg-deep) 100%);
background-attachment: fixed;
color: var(--text);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
h1, h2, h3, h4, h5 {
font-family: var(--font-heading);
font-weight: 700;
line-height: 1.25;
}
a {
color: var(--deep);
}
::selection {
background: color-mix(in srgb, var(--accent) 35%, transparent);
}
input[type="checkbox"] {
accent-color: var(--accent);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-deep);
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--accent) 50%, var(--bg-deep));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
/* --- App layout --- */
.app-main {
flex: 1;
padding: 2rem;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
/* Top padding clears the fixed corner menu */
.dashboard-container {
padding: 3.5rem 2rem 4rem;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
.phase-view-layout {
display: grid;
grid-template-columns: 180px minmax(0, 1fr) minmax(300px, 0.8fr);
gap: 1.5rem;
align-items: start;
}
.phase-main-column {
min-width: 0;
}
@media (min-width: 1101px) {
.phase-view-layout.log-collapsed {
grid-template-columns: 180px minmax(0, 1fr);
}
}
@media (max-width: 1100px) {
.phase-view-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.dashboard-container {
padding: 3.5rem 1rem 4rem;
}
}
.view-header {
margin-bottom: 1.5rem;
}
.view-header h2,
.lobby-view h2,
.scene-setup-view h2,
.between-scenes-view h2 {
font-size: 2.1rem;
color: var(--accent);
margin-bottom: 0.5rem;
letter-spacing: 0.5px;
}
.description {
color: var(--text-muted);
max-width: 720px;
margin: 0 auto 1.5rem;
}
/* --- Loader / spinner widgets --- */
.loader-container {
padding: 4rem;
}
.spinner {
width: 50px;
height: 50px;
border: 3px solid var(--edge-soft);
border-radius: 50%;
border-top-color: var(--accent);
animation: spin 1s ease-in-out infinite;
margin: 0 auto 1.5rem;
}
.spinner-small {
width: 20px;
height: 20px;
border: 2px solid var(--edge-soft);
border-radius: 50%;
border-top-color: var(--accent);
animation: spin 1s ease-in-out infinite;
display: inline-block;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* --- Text utilities --- */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-sm { font-size: 0.875rem; }
.italic { font-style: italic; }
.font-bold { font-weight: 700; }
.font-mono { font-family: ui-monospace, monospace; }
.underline { text-decoration: underline; }
.text-muted { color: var(--text-muted); }
.text-accent, .gold-text { color: var(--accent); }
.text-danger { color: var(--danger); }
.text-success, .success-text { color: var(--success); font-weight: 600; }
.text-warning { color: var(--warning); }
.text-deep, .deep-text { color: var(--deep); }
.info-text {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.section-desc {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.empty-text {
color: var(--text-muted);
font-style: italic;
font-size: 0.9rem;
}
.footnote, .footnote-desc {
font-size: 0.8rem;
color: var(--text-muted);
}
.footnote-desc {
margin-top: 0.15rem;
margin-left: 1.5rem;
}
/* --- Spacing & layout utilities --- */
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mt-6 { margin-top: 1.5rem; }
.mt-8 { margin-top: 2rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
.margin-top { margin-top: 1.5rem; }
.margin-top-small { margin-top: 0.5rem; }
.flex { display: flex; }
.flex-grow { flex: 1; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.w-full { width: 100%; }
.max-w-4xl { max-width: 56rem; }
.max-w-5xl { max-width: 64rem; }
.space-y-2 > * + * { margin-top: 0.5rem; }
.space-y-6 > * + * { margin-top: 1.5rem; }
.center-block { margin: 1rem auto; max-width: 500px; }
.inline-form { display: flex; gap: 0.5rem; width: 100%; }
.inline-group { display: flex; gap: 0.5rem; width: 100%; }
/* --- Ghost World --- */
.ghost-world {
filter: grayscale(95%) brightness(0.85) sepia(15%) contrast(1.05);
transition: filter 1.2s ease-in-out;
}

View File

@@ -0,0 +1,200 @@
/* ============================================================
Playing-card widgets (Card.svelte: mini / medium / large)
Light parchment faces on the dark table for contrast.
============================================================ */
/* Shared face */
.card-mini, .card-medium, .card-large {
background: linear-gradient(150deg, var(--card-face) 0%, var(--card-face-shade) 100%);
border: 1px solid color-mix(in srgb, var(--card-ink-black) 35%, var(--card-face-shade));
color: var(--card-ink-black);
}
.card-mini.suit-h, .card-mini.suit-d,
.card-medium.suit-h .val, .card-medium.suit-h .suit, .card-medium.suit-h .card-center,
.card-medium.suit-d .val, .card-medium.suit-d .suit, .card-medium.suit-d .card-center,
.card-large.suit-h .val, .card-large.suit-h .suit, .card-large.suit-h .card-center,
.card-large.suit-d .val, .card-large.suit-d .suit, .card-large.suit-d .card-center {
color: var(--card-ink-red);
}
.card-medium.joker-card, .card-large.joker-card {
background: linear-gradient(150deg, color-mix(in srgb, var(--accent) 30%, var(--card-face)) 0%, var(--card-face-shade) 100%);
border-color: var(--accent-dark);
}
/* --- Mini (played-card columns) --- */
.card-mini {
width: 32px;
height: 48px;
border-radius: 4px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 2px 4px;
font-size: 0.7rem;
font-weight: 900;
position: relative;
}
.card-mini.base-card {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
}
.card-mini.rotated {
transform: rotate(90deg);
margin: 0 4px;
}
.card-mini.success {
box-shadow: 0 0 0 2px var(--success);
}
.card-mini.failure {
box-shadow: 0 0 0 2px var(--danger);
}
.card-mini .val {
align-self: flex-start;
}
.card-mini .suit {
align-self: flex-end;
}
.card-mini .owner {
font-size: 0.5rem;
text-transform: uppercase;
position: absolute;
bottom: -1px;
left: 1px;
color: color-mix(in srgb, var(--card-ink-black) 60%, var(--card-face));
}
/* --- Medium (obstacle display) --- */
.card-medium {
width: 75px;
height: 112px;
border-radius: var(--radius-sm);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0.3rem;
position: relative;
font-size: 0.85rem;
}
.card-medium .card-corner {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
}
.card-medium .card-corner .val {
font-weight: 900;
font-size: 0.9rem;
}
.card-medium .card-corner .suit {
font-size: 0.75rem;
}
.card-medium .card-center {
font-size: 1.8rem;
text-align: center;
align-self: center;
margin: auto 0;
}
/* --- Large (the player's hand) --- */
.card-large {
width: 110px;
height: 165px;
border-radius: var(--radius-md);
border-width: 2px;
box-shadow: var(--shadow-card-raised);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0.5rem;
position: relative;
transition: var(--transition-smooth);
}
.card-large:hover {
transform: translateY(-8px) scale(1.05);
box-shadow: var(--shadow-card-lifted), 0 0 14px color-mix(in srgb, var(--accent) 30%, transparent);
}
.card-large .card-corner {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.1;
}
.card-large .card-corner .val {
font-size: 1.2rem;
font-weight: 900;
}
.card-large .card-corner .suit {
font-size: 1rem;
}
.card-large .card-center {
font-size: 2.8rem;
text-align: center;
align-self: center;
margin: auto 0;
}
.card-large .card-tech-overlay {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
right: 0.5rem;
text-align: center;
}
.card-large .tech-tag {
background: color-mix(in srgb, var(--surface) 92%, transparent);
border: 1px solid var(--accent);
color: var(--accent-bright);
font-size: 0.65rem;
padding: 0.15rem 0.3rem;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.joker-action {
display: flex;
gap: 2px;
}
/* --- Drag & drop --- */
.card-large[draggable="true"] {
cursor: grab;
}
.card-large[draggable="true"]:active {
cursor: grabbing;
}
.card-large.dragging {
opacity: 0.4;
border-style: dashed;
transform: scale(0.95);
}
.obstacle-item.drag-over {
border-color: var(--accent) !important;
background: color-mix(in srgb, var(--accent) 9%, transparent) !important;
box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 20%, transparent) !important;
transform: translateY(-2px) scale(1.01);
transition: var(--transition-smooth);
}

View File

@@ -0,0 +1,402 @@
/* --- Character creation & sheet --- */
.creation-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-top: 2rem;
}
@media (max-width: 900px) {
.creation-grid {
grid-template-columns: 1fr;
}
.creation-grid > .card {
grid-column: span 1 !important;
}
}
.record-line {
background: var(--well);
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
margin-bottom: 0.75rem;
border-left: 3px solid var(--accent);
}
.delegation-box {
margin-bottom: 1.25rem;
padding: 1.25rem;
text-align: left;
}
.delegation-box h4 {
font-family: var(--font-body);
font-size: 0.95rem;
color: var(--text);
margin-bottom: 0.5rem;
}
.answer-box {
font-style: italic;
color: var(--accent-bright);
font-size: 1.05rem;
}
.author-label {
font-size: 0.8rem;
color: var(--text-muted);
font-style: normal;
}
.inbox-list:has(.inbox-item) .inbox-empty-message {
display: none;
}
.inbox-item {
padding: 1rem;
margin-bottom: 0.75rem;
border-left: 3px solid var(--deep);
background: color-mix(in srgb, var(--deep) 4%, transparent);
}
.inbox-item label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.submitted-techniques .tech-chip {
background: color-mix(in srgb, var(--accent) 6%, transparent);
border: 1px solid var(--accent);
color: var(--accent);
padding: 0.6rem 1rem;
font-size: 0.95rem;
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
font-family: var(--font-heading);
}
.tech-assignment-pill {
background: color-mix(in srgb, var(--deep) 6%, transparent);
border: 1px solid var(--deep);
color: var(--text);
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
text-align: left;
display: flex;
align-items: center;
gap: 1rem;
}
.card-rank {
background: var(--accent);
color: var(--text-inverse);
font-weight: 900;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 0.85rem;
flex-shrink: 0;
}
/* --- Character sheet layout --- */
.character-sheet-details {
text-align: left;
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.character-sheet-details {
flex-direction: row;
align-items: flex-start;
gap: 1.5rem;
}
.sheet-main-column {
flex: 2;
min-width: 0;
}
.sheet-side-column {
flex: 1;
min-width: 0;
}
}
.techniques-list-sheet {
list-style: none;
}
.techniques-list-sheet li {
margin-bottom: 0.25rem;
font-size: 0.95rem;
}
/* --- Face-card technique assignment (drag & drop) --- */
.face-tech-drag-form {
margin-top: 1.5rem;
}
.available-techniques-container {
background: var(--well);
border: 1px dashed var(--edge);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 2rem;
}
.available-techniques-container h4 {
color: var(--accent);
font-size: 1.1rem;
margin-bottom: 0.25rem;
letter-spacing: 0.5px;
}
.instruction-help {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.techniques-drag-pool {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
min-height: 50px;
align-items: center;
}
.draggable-tech-chip {
background: var(--surface-raised);
border: 1px solid var(--accent);
color: var(--text);
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
cursor: grab;
user-select: none;
font-size: 0.95rem;
font-weight: 500;
transition: var(--transition-smooth);
box-shadow: var(--shadow-soft);
display: flex;
align-items: center;
gap: 0.5rem;
}
.draggable-tech-chip:hover {
transform: translateY(-2px);
border-color: var(--accent-bright);
}
.draggable-tech-chip:active {
cursor: grabbing;
}
.draggable-tech-chip.dragging {
opacity: 0.4;
border-style: dashed;
}
.draggable-tech-chip.assigned-hidden {
opacity: 0.2;
pointer-events: none;
cursor: not-allowed;
border-color: var(--edge);
}
.drag-icon {
color: var(--accent);
font-size: 0.8rem;
opacity: 0.7;
}
/* Face-card slots */
.face-cards-slots-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
}
@media (max-width: 768px) {
.face-cards-slots-grid {
grid-template-columns: 1fr;
}
}
.face-card-slot-wrapper {
display: flex;
justify-content: center;
}
.face-card-slot {
position: relative;
width: 100%;
max-width: 220px;
aspect-ratio: 2 / 3;
min-height: 280px;
background: var(--well);
border: 2px dashed var(--edge-accent);
border-radius: var(--radius-lg);
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: var(--transition-smooth);
overflow: hidden;
cursor: pointer;
box-shadow: var(--shadow-soft);
}
.face-card-slot:hover {
border-color: var(--accent);
transform: translateY(-4px);
}
.face-card-slot.drag-over {
border-color: var(--accent-bright);
border-style: solid;
background: color-mix(in srgb, var(--accent) 7%, transparent);
transform: scale(1.02);
}
.face-card-slot.has-assignment {
border-style: solid;
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 5%, var(--well));
}
.card-bg-letter {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: var(--font-display);
font-size: 9rem;
color: color-mix(in srgb, var(--text) 4%, transparent);
line-height: 1;
pointer-events: none;
user-select: none;
transition: var(--transition-smooth);
}
.face-card-slot.has-assignment .card-bg-letter {
color: color-mix(in srgb, var(--accent) 8%, transparent);
}
.card-slot-header, .card-slot-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-heading);
font-weight: 700;
font-size: 1.1rem;
color: var(--text-muted);
pointer-events: none;
user-select: none;
}
.face-card-slot.has-assignment .card-slot-header,
.face-card-slot.has-assignment .card-slot-footer {
color: var(--accent);
}
.card-slot-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
z-index: 2;
padding: 0.5rem 0;
}
.card-title {
font-family: var(--font-heading);
font-size: 1.2rem;
font-weight: 700;
color: var(--text);
margin-bottom: 0.75rem;
pointer-events: none;
}
.face-card-slot.has-assignment .card-title {
color: var(--accent);
}
.drop-zone-placeholder {
font-size: 0.8rem;
color: var(--text-muted);
border: 1px dashed var(--edge);
background: var(--well);
border-radius: var(--radius-sm);
padding: 0.5rem 0.75rem;
transition: var(--transition-smooth);
pointer-events: none;
}
.face-card-slot:hover .drop-zone-placeholder {
color: var(--text);
border-color: var(--edge-accent);
}
.assigned-tech-content {
width: 100%;
display: none;
}
.face-card-slot.has-assignment .drop-zone-placeholder {
display: none;
}
.face-card-slot.has-assignment .assigned-tech-content {
display: block;
}
.assigned-tech-chip {
background: color-mix(in srgb, var(--accent) 9%, transparent);
border: 1px solid var(--accent);
color: var(--text);
padding: 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
position: relative;
box-shadow: var(--shadow-soft);
word-break: break-word;
animation: fadeIn 0.3s ease;
}
.remove-tech-btn {
position: absolute;
top: -8px;
right: -8px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--danger);
border: 1px solid color-mix(in srgb, var(--danger) 70%, var(--text));
color: var(--text-inverse);
font-size: 10px;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: var(--shadow-soft);
transition: var(--transition-smooth);
}
.remove-tech-btn:hover {
transform: scale(1.15);
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}

View File

@@ -0,0 +1,489 @@
/* --- Panels --- */
.glass-panel {
background: color-mix(in srgb, var(--surface) 88%, transparent);
border: 1px solid var(--edge);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow-soft);
transition: var(--transition-smooth);
}
.glass-panel:hover {
border-color: var(--edge-accent);
}
.card {
background: var(--surface);
border: 1px solid var(--edge);
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-soft);
}
.card h3 {
color: var(--accent);
font-size: 1.35rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--edge-accent);
padding-bottom: 0.5rem;
}
/* Sunken sub-section inside a panel (character sheet groups, ledgers...) */
.sheet-group {
background: var(--well);
padding: 1rem;
border-radius: var(--radius-sm);
border-left: 2px solid var(--accent-dark);
}
.sheet-group h4 {
color: var(--accent);
font-size: 1.05rem;
margin-bottom: 0.5rem;
letter-spacing: 0.5px;
}
/* Notice banners (first-scene framing, over-limit warnings...) */
.notice-banner {
border: 1px dashed var(--edge);
border-radius: var(--radius-md);
padding: 1rem 1.5rem;
margin: 0 auto 1.5rem;
max-width: 700px;
text-align: left;
}
.notice-banner h4 {
margin-bottom: 0.5rem;
}
.notice-banner.accent {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.notice-banner.accent h4 { color: var(--accent); }
.notice-banner.danger {
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 9%, transparent);
}
.notice-banner.danger h4 { color: var(--danger); }
/* Prompt boxes (character-sheet callouts: death, ghost, name...) */
.prompt-box {
border: 1px dashed var(--edge);
border-left: 2px solid var(--edge);
padding: 1rem;
border-radius: var(--radius-md);
margin-bottom: 1rem;
background: var(--well);
}
.prompt-box h4 {
margin-bottom: 0.5rem;
}
.prompt-box.accent {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.prompt-box.accent h4 { color: var(--accent); }
.prompt-box.danger {
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 9%, transparent);
}
.prompt-box.danger h4 { color: var(--danger); }
.prompt-box.ghostly {
border-color: var(--ghost);
background: color-mix(in srgb, var(--ghost) 10%, transparent);
}
.prompt-box.ghostly h4 { color: var(--ghost); }
/* --- Forms & controls --- */
.form-group {
margin-bottom: 1.25rem;
text-align: left;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.95rem;
color: var(--text-muted);
font-weight: 500;
}
.input-field, .form-control, .select-field, .copy-input {
width: 100%;
padding: 0.7rem 1rem;
background: var(--well);
border: 1px solid var(--edge);
border-radius: var(--radius-sm);
color: var(--text);
font-family: var(--font-body);
font-size: 1rem;
transition: var(--transition-smooth);
}
.input-field::placeholder, .form-control::placeholder {
color: color-mix(in srgb, var(--text-muted) 70%, transparent);
}
.input-field:focus, .form-control:focus, .select-field:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.select-field {
cursor: pointer;
}
.input-large {
padding: 0.9rem 1.1rem;
font-size: 1.1rem;
}
.input-row {
display: flex;
gap: 0.5rem;
}
.input-row .input-field {
flex: 1;
}
.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; }
.select-xsmall { padding: 0.4rem 0.5rem; font-size: 0.85rem; width: 140px; }
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.95rem;
}
.checkbox-label:has(input:disabled) {
cursor: default;
}
.objectives-checklist {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
/* --- Buttons --- */
.btn {
display: inline-block;
padding: 0.7rem 1.5rem;
font-family: var(--font-heading);
font-weight: 700;
font-size: 1rem;
letter-spacing: 1px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition-smooth);
text-decoration: none;
}
.btn-primary {
background: linear-gradient(160deg, var(--accent-bright) 0%, var(--accent) 45%, var(--accent-dark) 100%);
border-color: var(--accent-dark);
color: var(--text-inverse);
box-shadow: var(--shadow-soft);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 35%, transparent);
}
.btn-secondary {
background: color-mix(in srgb, var(--text) 8%, transparent);
border-color: var(--edge);
color: var(--text);
}
.btn-secondary:hover:not(:disabled) {
background: color-mix(in srgb, var(--text) 15%, transparent);
transform: translateY(-2px);
}
.btn-deep {
background: color-mix(in srgb, var(--deep) 10%, var(--surface));
border-color: var(--deep);
color: var(--deep);
}
.btn-deep:hover:not(:disabled) {
background: var(--deep);
color: var(--text-inverse);
transform: translateY(-2px);
}
.btn-danger {
background: color-mix(in srgb, var(--danger) 18%, var(--surface));
border-color: var(--danger);
color: color-mix(in srgb, var(--danger) 70%, var(--text));
}
.btn-danger:hover:not(:disabled) {
background: var(--danger);
color: var(--text-inverse);
transform: translateY(-2px);
}
.btn-success {
background: color-mix(in srgb, var(--success) 18%, var(--surface));
border-color: var(--success);
color: color-mix(in srgb, var(--success) 70%, var(--text));
}
.btn-success:hover:not(:disabled) {
background: var(--success);
color: var(--text-inverse);
}
.btn-gold {
background: transparent;
border: 2px solid var(--accent);
color: var(--accent);
}
.btn-gold:hover:not(:disabled) {
background: var(--accent);
color: var(--text-inverse);
box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 40%, transparent);
transform: translateY(-2px);
}
.btn-full, .btn-block { width: 100%; }
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
.btn-large { padding: 0.9rem 2rem; font-size: 1.15rem; }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.glow-effect:hover {
filter: brightness(1.15);
}
/* --- Alerts --- */
.alert {
padding: 0.75rem 1.25rem;
margin-bottom: 1.5rem;
border-radius: var(--radius-sm);
text-align: center;
font-weight: 500;
}
.alert-danger {
background: color-mix(in srgb, var(--danger) 14%, transparent);
border: 1px solid var(--danger);
color: color-mix(in srgb, var(--danger) 75%, var(--text));
}
.deep-chip {
border-color: var(--deep);
background: color-mix(in srgb, var(--deep) 5%, transparent);
}
.pirat-chip {
border-color: var(--pirat);
background: color-mix(in srgb, var(--pirat) 5%, transparent);
}
/* --- Badges --- */
.creator-badge {
background: var(--accent);
color: var(--text-inverse);
font-size: 0.7rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 4px;
text-transform: uppercase;
}
.deep-badge, .pirat-badge, .ghost-badge {
padding: 0.1rem 0.45rem;
font-size: 0.75rem;
font-weight: 700;
border-radius: 4px;
margin-left: 0.35rem;
white-space: nowrap;
}
.deep-badge {
background: color-mix(in srgb, var(--deep) 14%, transparent);
border: 1px solid var(--deep);
color: var(--deep);
}
.pirat-badge {
background: color-mix(in srgb, var(--pirat) 14%, transparent);
border: 1px solid var(--pirat);
color: var(--pirat);
}
.ghost-badge {
background: color-mix(in srgb, var(--ghost) 14%, transparent);
border: 1px solid var(--ghost);
color: var(--ghost);
}
/* --- Waiting indicator --- */
.waiting-box, .waiting-indicator {
font-size: 0.95rem;
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
/* --- Modal overlay (shared by popups) --- */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
backdrop-filter: blur(6px);
}
.modal-box {
position: relative;
max-width: 440px;
width: 100%;
max-height: 88vh;
overflow-y: auto;
padding: 1.75rem;
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
}
.modal-box h3 {
margin-top: 0;
}
.modal-box.sheet-modal {
max-width: 520px;
text-align: left;
}
.modal-box.character-sheet-modal {
max-width: 1260px;
}
.modal-close {
position: absolute;
top: 0.6rem;
right: 0.75rem;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
padding: 0.1rem 0.4rem;
border-radius: var(--radius-sm);
}
.modal-close:hover {
color: var(--text);
background: color-mix(in srgb, var(--text) 10%, transparent);
}
/* --- Graphical tooltip (lib/tooltip.js action) --- */
.tooltip-pop {
position: fixed;
z-index: 3000;
max-width: 280px;
padding: 0.6rem 0.7rem;
background: var(--surface-raised);
border: 1px solid var(--edge-accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
color: var(--text);
font-family: var(--font-body);
font-size: 0.82rem;
line-height: 1.4;
pointer-events: none;
opacity: 0;
transform: translateY(3px);
transition: opacity 0.12s ease, transform 0.12s ease;
}
.tooltip-pop.visible {
opacity: 1;
transform: translateY(0);
}
.tooltip-pop .tt-head {
display: flex;
align-items: baseline;
gap: 0.4rem;
margin-bottom: 0.3rem;
}
.tooltip-pop .tt-card {
font-family: var(--font-heading);
font-weight: 900;
font-size: 1rem;
}
.tooltip-pop .tt-card.tt-red { color: var(--suit-red); }
.tooltip-pop .tt-card.tt-black { color: var(--suit-black); }
.tooltip-pop .tt-card.tt-joker { color: var(--accent); }
.tooltip-pop .tt-color {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
padding: 0.05rem 0.35rem;
border-radius: 999px;
}
.tooltip-pop .tt-color-red {
color: var(--suit-red);
background: color-mix(in srgb, var(--suit-red) 16%, transparent);
}
.tooltip-pop .tt-color-black {
color: var(--suit-black);
background: color-mix(in srgb, var(--suit-black) 16%, transparent);
}
.tooltip-pop .tt-theme {
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.tooltip-pop .tt-row {
margin-top: 0.25rem;
}
.tooltip-pop .tt-label {
display: block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
font-weight: 700;
}

View File

@@ -0,0 +1,62 @@
/* --- Lobby / Ship's Hold --- */
.lobby-view {
max-width: 700px;
margin: 2rem auto;
}
.links-box {
text-align: left;
margin-bottom: 2rem;
}
.teaching-box {
text-align: left;
margin-bottom: 2rem;
padding: 1rem 1.25rem;
}
.teaching-box .checkbox-label.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.teaching-box .info-text {
margin: 0.5rem 0 0;
font-size: 0.85rem;
}
.link-item {
margin-bottom: 1.5rem;
}
.link-item h4 {
color: var(--text);
margin-bottom: 0.5rem;
}
.lobby-join-code-item {
display: flex;
justify-content: center;
}
.copy-container {
display: flex;
gap: 0.5rem;
}
.copy-input {
flex: 1;
background: var(--bg-deep);
font-size: 0.95rem;
padding: 0.5rem 1rem;
}
.copy-input.admin-input {
border-color: var(--edge-accent);
color: var(--accent);
}
.admin-link-item {
border-top: 1px dashed var(--edge-accent);
padding-top: 1.5rem;
}

View File

@@ -0,0 +1,482 @@
/* --- Scene board layout --- */
.scene-view-layout {
display: grid;
grid-template-columns: 180px minmax(0, 1.7fr) minmax(300px, 1fr);
gap: 1.5rem;
align-items: start;
}
/* The middle column stacks hand / challenge / obstacle cards. */
.table-column {
display: flex;
flex-direction: column;
}
/* Stretch the log column to the full row height so its sticky panel can travel. */
.log-column {
align-self: stretch;
}
/* When the inline log is collapsed, reclaim its column (3-col layout only). */
@media (min-width: 1101px) {
.scene-view-layout.log-collapsed {
grid-template-columns: 180px minmax(0, 1fr);
}
}
/* Corner button that toggles the inline Event Log without moving. */
.log-reopen-btn {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
padding: 10px 14px;
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
color: var(--text);
font-weight: bold;
font-family: var(--font-heading);
cursor: pointer;
backdrop-filter: blur(10px);
}
.log-reopen-btn:hover {
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
}
@media (max-width: 1100px) {
.scene-view-layout {
grid-template-columns: 1fr;
}
}
/* --- Crew bubble column --- */
.crew-column {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.crew-bubbles {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.crew-bubble {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
width: 100%;
text-align: left;
padding: 0.55rem 0.7rem;
background: var(--surface-raised);
color: var(--text);
border: 1px solid var(--edge);
border-left: 4px solid var(--pirat);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
cursor: pointer;
transition: var(--transition-smooth);
}
.crew-bubble:hover {
background: color-mix(in srgb, var(--accent) 10%, var(--surface-raised));
transform: translateX(2px);
}
.crew-bubble.is-static {
cursor: default;
}
.crew-bubble.is-static:hover {
background: var(--surface-raised);
transform: none;
}
.crew-bubble.is-deep {
border-left-color: var(--deep);
background: color-mix(in srgb, var(--deep) 12%, var(--surface-raised));
}
.crew-bubble.is-you {
border-color: var(--accent);
border-left-color: var(--accent);
}
.crew-bubble.is-ghost { opacity: 0.7; filter: grayscale(0.6); }
.crew-bubble.is-dead { opacity: 0.6; filter: grayscale(0.8); }
/* Challenge-progress badge the Deep sees floating just outside the bubble */
.challenge-check {
position: absolute;
left: -0.6rem;
top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
background: var(--surface-raised);
border: 1px solid var(--edge);
color: var(--text-muted);
box-shadow: var(--shadow-card);
cursor: help;
}
.challenge-check.done {
background: var(--success);
border-color: var(--success);
color: var(--surface);
}
.crew-bubble .bubble-icon {
font-size: 1.1rem;
}
.crew-bubble .bubble-name {
font-weight: 700;
font-size: 0.9rem;
line-height: 1.15;
overflow-wrap: anywhere;
}
.crew-bubble .bubble-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Crew Objectives toggle at the end of the roster */
.crew-objectives-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
text-align: left;
padding: 0.55rem 0.7rem;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid var(--accent);
border-radius: var(--radius-md);
color: var(--text);
font-family: var(--font-heading);
font-size: 0.85rem;
cursor: pointer;
transition: var(--transition-smooth);
}
.crew-objectives-toggle:hover {
background: color-mix(in srgb, var(--accent) 22%, transparent);
}
.crew-objectives-toggle .co-count {
font-weight: 700;
color: var(--accent);
}
.crew-objectives-detail {
padding: 0.6rem 0.7rem;
background: var(--well);
border: 1px solid var(--edge);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
}
/* Keep the crew roster vertical at every width, matching the other phases. */
@media (max-width: 1100px) {
.crew-bubble {
width: 100%;
}
.crew-objectives-toggle {
width: 100%;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--edge-accent);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.card-header h3 {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.deck-counter {
font-size: 0.85rem;
color: var(--accent);
font-weight: 700;
}
/* --- Obstacles --- */
.obstacles-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.obstacle-item {
border: 1px solid var(--edge);
border-radius: var(--radius-md);
padding: 1.25rem;
background: var(--well);
display: grid;
grid-template-columns: 0.8fr 2fr 1fr 1fr;
gap: 1rem;
position: relative;
overflow: hidden;
}
.obstacle-item.in-challenge {
box-shadow: 0 0 0 2px var(--deep);
}
@media (max-width: 768px) {
.obstacle-item {
grid-template-columns: 0.8fr 2fr 1.5fr;
}
}
@media (max-width: 600px) {
.obstacle-item {
grid-template-columns: 1fr;
}
}
/* Suit accent stripes */
.suit-c, .suit-s { border-left: 4px solid var(--suit-black); }
.suit-h, .suit-d { border-left: 4px solid var(--suit-red); }
/* Overlapping card column for an Obstacle. Cards stack with a heavy negative
margin so each earlier card shows only its top strip (rank + suit); the most
recent play sits fully visible at the bottom. Rings: accent = the original
card, green/red = a play that did / didn't beat the difficulty. */
.obstacle-stack {
--peek: 34px; /* visible strip of each earlier card */
--card-h: 112px; /* medium card height (card.css) */
align-self: start;
display: flex;
flex-direction: column;
align-items: center;
padding: 2px; /* room for the rings, which overflow:hidden would clip */
}
.stack-card {
position: relative;
border-radius: var(--radius-sm);
line-height: 0;
}
.stack-card + .stack-card {
margin-top: calc(var(--peek) - var(--card-h));
}
.stack-card.is-original {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
}
.stack-card.is-success {
box-shadow: 0 0 0 2px var(--success);
}
.stack-card.is-failure {
box-shadow: 0 0 0 2px var(--danger);
}
.suit-badge {
font-weight: 900;
font-size: 1.1rem;
}
.suit-c .suit-badge, .suit-s .suit-badge { color: var(--suit-black); }
.suit-h .suit-badge, .suit-d .suit-badge { color: var(--suit-red); }
.card-code {
font-size: 0.8rem;
color: var(--text-muted);
font-family: var(--font-heading);
}
.obstacle-details h4 {
color: var(--text);
margin-bottom: 0.25rem;
font-size: 1.15rem;
}
/* The title and its "K♦" card-code prefix take the Obstacle's fixed suit color
(red border on the left already marks color, so the old tag is gone). */
.suit-h .obstacle-details h4, .suit-d .obstacle-details h4 { color: var(--suit-red); }
.suit-c .obstacle-details h4, .suit-s .obstacle-details h4 { color: var(--suit-black); }
.obs-card-code { opacity: 0.8; }
.obstacle-details .in-challenge-tag {
color: var(--deep);
font-size: 0.8rem;
}
.obstacle-details .desc {
font-size: 0.85rem;
color: var(--text-muted);
}
.obstacle-value-display {
align-self: start;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: color-mix(in srgb, var(--accent) 5%, transparent);
border: 1px dashed var(--edge-accent);
border-radius: var(--radius-sm);
padding: 0.5rem;
}
.obstacle-successes-display {
align-self: start;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: color-mix(in srgb, var(--success) 5%, transparent);
border: 1px dashed color-mix(in srgb, var(--success) 30%, transparent);
border-radius: var(--radius-sm);
padding: 0.5rem;
}
.obstacle-successes-display .val-number {
color: var(--success);
font-size: 1.5rem;
}
.val-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
}
.val-number {
font-size: 1.8rem;
font-family: var(--font-heading);
font-weight: 900;
color: var(--accent);
}
.clear-obstacle-row {
grid-column: 1 / -1;
}
/* --- Challenges --- (framed in the Deep's teal so red/black always means suit color) */
/* The challenge-area card IS the box; the challenge content sits flush inside it. */
.challenge-area-card {
border-color: color-mix(in srgb, var(--deep) 45%, transparent);
}
.challenge-item {
margin-bottom: 1rem;
text-align: left;
}
.challenge-item + .challenge-item {
border-top: 1px solid var(--edge-soft);
padding-top: 1rem;
}
.challenge-item h4 {
color: var(--deep);
font-size: 1.25rem;
margin: 0;
}
.challenge-head {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.challenge-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.tax-callout {
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px dashed var(--accent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.tax-callout p {
margin: 0 0 0.5rem 0;
}
/* --- Challenge area (middle column) --- */
/* Obstacles applied to an active Challenge, pulled up into the Challenge panel */
.challenge-obstacles {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 0.75rem;
}
/* The Deep's call-a-challenge + end-scene controls live below any open Challenges */
.deep-challenge-controls {
margin-top: 0.5rem;
}
/* When a Challenge is active the rest of the Obstacle list collapses out of the way */
.obstacle-collapse > summary {
cursor: pointer;
color: var(--text-muted);
font-family: var(--font-heading);
font-size: 0.9rem;
padding: 0.35rem 0;
}
.obstacle-collapse[open] > summary {
margin-bottom: 1rem;
}
.scene-upkeep-controls {
margin-top: 1rem;
border-top: 1px solid var(--edge-soft);
padding-top: 1rem;
}
/* --- Hand --- */
.hand-label {
display: block;
margin-bottom: 0.6rem;
font-family: var(--font-heading);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.hand-flex {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}

View File

@@ -0,0 +1,70 @@
/* --- Scene setup / role choice --- */
.setup-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 2rem;
margin: 2rem 0;
}
.setup-grid.single {
grid-template-columns: minmax(0, 700px);
justify-content: center;
}
@media (max-width: 800px) {
.setup-grid {
grid-template-columns: 1fr;
}
}
.role-buttons {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 1.5rem;
}
.role-btn {
padding: 1.5rem;
font-size: 1.2rem;
background: var(--well);
border: 2px solid var(--edge);
color: var(--text-muted);
}
.role-btn.pirat-btn:hover:not(:disabled), .role-btn.pirat-btn.active {
border-color: var(--pirat);
color: var(--text);
background-color: color-mix(in srgb, var(--pirat) 8%, transparent);
}
.role-btn.deep-btn:hover:not(:disabled), .role-btn.deep-btn.active {
border-color: var(--deep);
color: var(--text);
background-color: color-mix(in srgb, var(--deep) 8%, transparent);
}
.role-badge {
padding: 0.15rem 0.75rem;
border-radius: var(--radius-sm);
font-family: var(--font-heading);
font-size: 0.9rem;
}
.role-badge.deep {
background: color-mix(in srgb, var(--deep) 14%, transparent);
border: 1px solid var(--deep);
color: var(--deep);
}
.role-badge.pirat {
background: color-mix(in srgb, var(--pirat) 14%, transparent);
border: 1px solid var(--pirat);
color: var(--pirat);
}
.role-badge.unassigned {
background: var(--well);
border: 1px dashed var(--edge);
color: var(--text-muted);
}

View File

@@ -0,0 +1,156 @@
/* ============================================================
RATS WITH GATS — THEME TOKENS
Two themes:
- "Fair Winds" (default, light): blue skies, bright seas, and
parchment treasure maps with big red Xs. Wind Waker energy.
- "Lantern & Brine" (data-theme="dark"): a lantern-lit pirate
ship at night.
This file is the ONLY place colors, fonts, radii, and shadows
are defined. Every other stylesheet derives translucent
variants with color-mix(), so re-theming the app means
editing this file and nothing else. The active theme is the
data-theme attribute on <html>, set from localStorage by an
inline script in index.html and toggled via lib/theme.js.
(Font files are bundled via @fontsource imports in main.js.)
============================================================ */
:root {
/* --- "Fair Winds" raw palette (light, default) ----------- */
--sky: #cde6f5; /* page background: midday sky */
--sky-deep: #9fc9e2; /* page edges: deeper blue toward the horizon */
--sail: #f8f1dd; /* panel surface: weathered sailcloth */
--sail-bright: #fffaec; /* raised surface */
--map-ink: #33291a; /* primary text: sepia chart ink */
--map-faded: #7d6e54; /* muted text */
--doubloon: #a8770f; /* primary accent: antique gold */
--doubloon-bright: #c08a13;
--doubloon-dark: #7c560a;
--lagoon: #0c8d96; /* the Deep: tropical shallows */
--palm: #2f8f4e; /* Pi-Rats, success */
--x-red: #c92a35; /* danger: the X that marks the spot */
--cannonball: #2c3245; /* black suits: dark navy iron */
--sunset: #c2640e; /* warnings, jokers */
--sea-orchid: #7757c9; /* votes, objectives, misc arcana */
--sea-mist: #69808f; /* dead & ghostly things */
/* Playing-card faces (crisp cards on the sailcloth table) */
--card-face: #fffdf2;
--card-face-shade: #e7d7ac;
--card-ink-red: #b03546;
--card-ink-black: #2a3040;
/* --- Semantic assignments -------------------------------- */
--bg: var(--sky);
--bg-deep: var(--sky-deep);
--surface: var(--sail);
--surface-raised: var(--sail-bright);
--well: color-mix(in srgb, var(--map-ink) 8%, transparent);
--text: var(--map-ink);
--text-muted: var(--map-faded);
--text-inverse: #fff8e6;
--accent: var(--doubloon);
--accent-bright: var(--doubloon-bright);
--accent-dark: var(--doubloon-dark);
--deep: var(--lagoon);
--pirat: var(--palm);
--success: var(--palm);
--danger: var(--x-red);
--warning: var(--sunset);
--mystic: var(--sea-orchid);
--ghost: var(--sea-mist);
--suit-red: var(--x-red);
--suit-black: var(--cannonball);
/* Borders */
--edge: color-mix(in srgb, var(--map-ink) 22%, transparent);
--edge-soft: color-mix(in srgb, var(--map-ink) 11%, transparent);
--edge-accent: color-mix(in srgb, var(--accent) 40%, transparent);
/* --- Type ------------------------------------------------ */
--font-display: 'Pirata One', 'Alegreya SC', serif;
--font-heading: 'Alegreya SC', serif;
--font-body: 'Alegreya Sans', sans-serif;
/* --- Shape & motion -------------------------------------- */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow-soft: 0 4px 14px rgba(36, 62, 87, 0.18);
--shadow-deep: 0 10px 30px rgba(36, 62, 87, 0.28);
--shadow-card: 0 3px 10px rgba(36, 62, 87, 0.22);
--shadow-card-raised: 0 4px 15px rgba(36, 62, 87, 0.3);
--shadow-card-lifted: 0 10px 25px rgba(36, 62, 87, 0.32);
--transition-smooth: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
:root[data-theme="dark"] {
/* --- "Lantern & Brine" raw palette ----------------------- */
--ink-deep: #0a0d13; /* darkest: page edges, sunken wells */
--ink: #131822; /* page background */
--hull: #1b212e; /* panel surface */
--hull-light: #262e3f; /* raised surface */
--parchment: #efe7d3; /* primary text */
--driftwood: #a89e8a; /* muted text */
--brass: #d9a23c; /* primary accent */
--brass-bright: #f3c365;
--brass-dark: #93701f;
--tide: #41c9bd; /* the Deep */
--kelp: #6cc983; /* Pi-Rats, success */
--coral: #e8606e; /* danger, red suits */
--bone: #dfe6ef; /* black suits */
--ember: #f0954c; /* warnings, jokers */
/* Playing-card faces (light parchment cards on a dark table) */
--card-face: #f2e9d2;
--card-face-shade: #ddcfa9;
/* --- Semantic assignments -------------------------------- */
--bg: var(--ink);
--bg-deep: var(--ink-deep);
--surface: var(--hull);
--surface-raised: var(--hull-light);
--well: color-mix(in srgb, var(--ink-deep) 55%, transparent);
--text: var(--parchment);
--text-muted: var(--driftwood);
--text-inverse: #1c1508;
--accent: var(--brass);
--accent-bright: var(--brass-bright);
--accent-dark: var(--brass-dark);
--deep: var(--tide);
--pirat: var(--kelp);
--success: var(--kelp);
--danger: var(--coral);
--warning: var(--ember);
--mystic: #a08cd8;
--ghost: #8fa3b8;
--suit-red: var(--coral);
--suit-black: var(--bone);
/* Borders */
--edge: color-mix(in srgb, var(--parchment) 14%, transparent);
--edge-soft: color-mix(in srgb, var(--parchment) 8%, transparent);
--edge-accent: color-mix(in srgb, var(--accent) 35%, transparent);
/* --- Shadows --------------------------------------------- */
--shadow-soft: 0 4px 14px rgba(0, 0, 0, 0.35);
--shadow-deep: 0 10px 30px rgba(0, 0, 0, 0.5);
--shadow-card: 0 3px 10px rgba(0, 0, 0, 0.4);
--shadow-card-raised: 0 4px 15px rgba(0, 0, 0, 0.5);
--shadow-card-lifted: 0 10px 25px rgba(0, 0, 0, 0.55);
}

View File

@@ -0,0 +1,141 @@
/* --- Between-scenes upkeep --- */
.between-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin: 2rem 0;
}
/* Between scenes there's only the voting/roster panel — center it. */
.between-grid.single {
grid-template-columns: 1fr;
max-width: 640px;
margin-left: auto;
margin-right: auto;
}
@media (max-width: 800px) {
.between-grid {
grid-template-columns: 1fr;
}
}
.voting-card, .deep-rest-card {
text-align: left;
}
.voted-confirmation-box {
padding: 1.5rem;
text-align: center;
border: 1px solid var(--success);
}
.ranks-ledger {
list-style: none;
}
.ranks-ledger li {
padding: 0.5rem;
border-bottom: 1px solid var(--edge-soft);
display: flex;
justify-content: space-between;
}
/* --- Death fate panel --- */
.death-fate-card {
border: 2px solid var(--danger);
background: color-mix(in srgb, var(--danger) 5%, var(--surface));
padding: 2.5rem;
margin: 2rem auto;
max-width: 650px;
text-align: center;
}
.death-fate-card > h2 {
color: var(--danger);
font-size: 2rem;
margin-bottom: 1rem;
}
.death-fate-card .description {
font-size: 1.15rem;
color: var(--text);
margin-bottom: 2rem;
}
.fate-choices {
display: flex;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.creation-form-wrapper {
text-align: left;
background: var(--well);
padding: 1.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--edge-soft);
}
.creation-form-wrapper h3 {
color: var(--accent);
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--edge-accent);
padding-bottom: 0.5rem;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.form-actions .btn {
flex: 1;
}
/* --- Deep hand refresh drag & drop --- */
.upkeep-drag-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin: 2rem 0;
}
@media (max-width: 768px) {
.upkeep-drag-container {
grid-template-columns: 1fr;
}
}
.upkeep-box {
min-height: 400px;
display: flex;
flex-direction: column;
}
.upkeep-box h4 {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.upkeep-flex {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1.5rem;
background: var(--well);
border: 1px dashed var(--edge);
border-radius: var(--radius-md);
align-content: flex-start;
min-height: 300px;
transition: var(--transition-smooth);
}
.end-story-box {
margin-top: 1.5rem;
border-top: 1px solid var(--edge-soft);
padding-top: 1rem;
}

View File

@@ -0,0 +1,89 @@
/* --- Welcome / splash & join screens --- */
.welcome-container {
max-width: 560px;
margin: 0 auto;
padding: 4rem 1.5rem 3rem;
text-align: center;
}
.welcome-hero {
margin-bottom: 2.5rem;
}
.hero-overline {
font-family: var(--font-heading);
font-size: 0.95rem;
letter-spacing: 4px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.wordmark {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(3.2rem, 10vw, 5rem);
line-height: 1;
letter-spacing: 2px;
background: linear-gradient(175deg, var(--accent-bright) 20%, var(--accent) 55%, var(--accent-dark) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 3px 12px color-mix(in srgb, var(--accent) 30%, transparent));
margin-bottom: 0.75rem;
}
.wordmark .amp {
font-size: 0.45em;
vertical-align: 0.45em;
letter-spacing: 1px;
}
.welcome-hero .subtitle {
font-size: 1.15rem;
color: var(--text-muted);
max-width: 420px;
margin: 0 auto;
}
/* Anchor divider: ——— ⚓ ——— */
.hero-flourish {
display: flex;
align-items: center;
gap: 1rem;
color: var(--accent);
font-size: 1.2rem;
margin: 1.75rem auto 0;
max-width: 320px;
}
.hero-flourish::before,
.hero-flourish::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, var(--edge-accent), transparent);
}
.creation-card {
text-align: left;
}
.creation-card h2 {
color: var(--accent);
font-size: 1.5rem;
margin-bottom: 1.25rem;
text-align: center;
}
.join-hint {
font-size: 0.85rem;
color: var(--text-muted);
text-align: center;
margin-top: 1.25rem;
margin-bottom: 0;
}
.welcome-links {
margin-top: 1.75rem;
}

View File

@@ -0,0 +1,82 @@
<script>
import { createEventDispatcher } from 'svelte';
import { VERSION, CHANGELOG } from '../lib/changelog';
const dispatch = createEventDispatcher();
function close() { dispatch('close'); }
</script>
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
<div class="modal-backdrop" on:click|self={close}>
<div class="modal-box sheet-modal glass-panel about-modal">
<button class="modal-close" on:click={close} aria-label="Close">×</button>
<h3>About</h3>
<p class="about-tagline">A remote-play companion for <em>Rats with Gats</em>.</p>
<p class="about-version">Version {VERSION}</p>
<h4 class="about-heading">What's New</h4>
<ul class="changelog">
{#each CHANGELOG as entry (entry.version)}
<li class="changelog-entry">
<div class="changelog-head">
<span class="changelog-ver">v{entry.version}</span>
<span class="changelog-date text-muted">{entry.date}</span>
</div>
<ul class="changelog-changes">
{#each entry.changes as change}
<li>{change}</li>
{/each}
</ul>
</li>
{/each}
</ul>
</div>
</div>
<style>
.about-tagline {
margin: 0.25rem 0 0.75rem;
color: var(--text-muted);
}
.about-version {
font-family: var(--font-heading);
font-weight: 700;
color: var(--accent);
margin: 0;
}
.about-heading {
margin: 1.25rem 0 0.5rem;
border-top: 1px solid var(--edge-soft);
padding-top: 1rem;
}
.changelog {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.changelog-head {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin-bottom: 0.25rem;
}
.changelog-ver {
font-family: var(--font-heading);
font-weight: 700;
color: var(--accent);
}
.changelog-date {
font-size: 0.85rem;
}
.changelog-changes {
margin: 0;
padding-left: 1.2rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
</style>

View File

@@ -0,0 +1,59 @@
<script>
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, cardTooltipHtml, obstacleTable } from '../lib/cards';
import { tooltip } from '../lib/tooltip';
export let card; // card code, e.g. "10D" or "Joker1"
export let size = 'large'; // 'large' | 'medium' | 'mini'
export let draggable = false;
export let rotated = false; // mini: rendered as a played (rotated) column card
export let success = null; // mini: true/false adds success/failure styling
export let owner = ''; // mini: short label of who played the card
export let title = ''; // explicit override; otherwise the card describes itself
export let techs = null; // large: {J, Q, K} technique names for the face-card overlay
export let style = '';
$: joker = isJoker(card);
$: val = joker ? '🃏' : cardValue(card);
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
$: techName = techs && !joker ? techs[cardValue(card)] : null;
// Tooltip: an explicit title wins (plain text); otherwise a rich graphical
// tooltip describing the card itself (suit theme + Obstacle meaning), which
// recomputes once the obstacle table loads.
$: tipContent = title ? title : { html: cardTooltipHtml(card, $obstacleTable) };
$: ariaLabel = title || cardTooltip(card, $obstacleTable);
</script>
{#if size === 'mini'}
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}"
use:tooltip={tipContent} aria-label={ariaLabel}>
<span class="val">{val}</span>
<span class="suit">{suit}</span>
{#if owner}<span class="owner">{owner}</span>{/if}
</div>
{:else}
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
use:tooltip={tipContent} aria-label={ariaLabel} {draggable} {style}
on:dragstart on:dragend on:click>
<div class="card-corner top-left">
<span class="val">{val}</span>
<span class="suit">{suit}</span>
</div>
<div class="card-center">
{#if joker}
🃏
{:else}
<span class="giant-symbol" style={size === 'medium' ? 'font-size: 1.5rem;' : ''}>{suit}</span>
{/if}
</div>
{#if techName}
<div class="card-tech-overlay">
<div class="tech-tag" use:tooltip={`Automatic success when played\n\n"${techName}"`}>{cardValue(card)}: "{techName}"</div>
</div>
{/if}
<div class="card-corner bottom-right">
<span class="val">{val}</span>
<span class="suit">{suit}</span>
</div>
</div>
{/if}

View File

@@ -0,0 +1,570 @@
<script>
import { onMount } from 'svelte';
import { apiRequest } from '../lib/api';
import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards';
import FaceCardAssigner from './FaceCardAssigner.svelte';
export let state;
// Form states
let basicDetails = {
avatar_look: state.player.avatar_look || '',
avatar_smell: state.player.avatar_smell || '',
first_words: state.player.first_words || '',
good_at_math: state.player.good_at_math || ''
};
let savingBasic = false;
let editingBasic = false;
async function saveBasicDetails() {
savingBasic = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails);
editingBasic = false;
} catch(e) {
console.error(e);
} finally {
savingBasic = false;
}
}
function reviseBasicDetails() {
basicDetails = {
avatar_look: state.player.avatar_look || '',
avatar_smell: state.player.avatar_smell || '',
first_words: state.player.first_words || '',
good_at_math: state.player.good_at_math || ''
};
editingBasic = true;
}
// Discard any edits made since clicking Revise and return to the saved view.
function cancelReviseBasic() {
editingBasic = false;
}
// Like/Hate tasks are assigned automatically when character creation starts.
// This is a fallback for games that entered the phase before assignments existed.
onMount(async () => {
if (!delegateComplete && state.players.length >= 2) {
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/delegate/auto`, 'POST');
} catch(e) {
console.error(e);
}
}
});
// A helper to get player name
function getPlayerName(id) {
if (!id) return "None";
const p = state.players.find(x => x.id === id);
return p ? displayName(p) : "Unknown";
}
// Techniques
let tech1 = '', tech2 = '', tech3 = '';
let savingTechs = false;
let techError = '';
// True while editing previously-submitted techniques (vs. first-time entry),
// so we know to offer a Cancel button. techSnapshot holds the saved values.
let revisingTechs = false;
let techSnapshot = [];
async function submitTechniques() {
savingTechs = true;
techError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
tech1, tech2, tech3
});
revisingTechs = false;
} catch(e) {
techError = e.message;
} finally {
savingTechs = false;
}
}
async function reviseTechniques() {
techSnapshot = [...createdTechniques];
[tech1 = '', tech2 = '', tech3 = ''] = createdTechniques;
revisingTechs = true;
techError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unsubmit-techniques`, 'POST');
} catch(e) {
techError = e.message;
revisingTechs = false;
}
}
// Throw away edits and re-submit the techniques as they were before Revise.
async function cancelReviseTechniques() {
savingTechs = true;
techError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
tech1: techSnapshot[0] || '',
tech2: techSnapshot[1] || '',
tech3: techSnapshot[2] || ''
});
revisingTechs = false;
} catch(e) {
techError = e.message;
} finally {
savingTechs = false;
}
}
// Technique swapping (swap_techniques phase)
let offerTech = '', offerTargetId = '';
let acceptChoice = {};
let swapBusy = false;
let swapError = '';
async function swapApi(path, body) {
swapBusy = true;
swapError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/${path}`, 'POST', body);
} catch (e) {
swapError = e.message;
} finally {
swapBusy = false;
}
}
function offerSwap() {
swapApi('offer-swap', { target_player_id: offerTargetId, technique: offerTech });
}
function cancelOffer() {
swapApi('cancel-swap-offer');
}
function respondOffer(offererId, accept) {
swapApi('respond-swap-offer', {
offerer_player_id: offererId,
accept,
technique: accept ? (acceptChoice[offererId] || '') : ''
});
}
function setSwapReady(ready) {
swapApi('swap-ready', { ready });
}
// Face Techniques
let jackTech = '', queenTech = '', kingTech = '';
let assigningFace = false;
let faceError = '';
// True while revising a previously-assigned J/Q/K layout, so we offer Cancel.
let revisingFace = false;
let faceSnapshot = {};
async function assignFaceTechniques() {
assigningFace = true;
faceError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/assign-face-techniques`, 'POST', {
jack: jackTech,
queen: queenTech,
king: kingTech
});
revisingFace = false;
} catch(e) {
faceError = e.message;
} finally {
assigningFace = false;
}
}
async function reviseFaceTechniques() {
faceSnapshot = {
jack: state.player.tech_jack,
queen: state.player.tech_queen,
king: state.player.tech_king
};
jackTech = state.player.tech_jack;
queenTech = state.player.tech_queen;
kingTech = state.player.tech_king;
revisingFace = true;
faceError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unassign-face-techniques`, 'POST');
} catch(e) {
faceError = e.message;
revisingFace = false;
}
}
// Throw away edits and re-assign the J/Q/K layout as it was before Revise.
async function cancelReviseFaceTechniques() {
assigningFace = true;
faceError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/assign-face-techniques`, 'POST', {
jack: faceSnapshot.jack,
queen: faceSnapshot.queen,
king: faceSnapshot.king
});
revisingFace = false;
} catch(e) {
faceError = e.message;
} finally {
assigningFace = false;
}
}
// Submit delegated answers — one draft per (player, question) so editing one
// task never bleeds into another.
let inboxAnswers = {};
async function submitDelegatedAnswer(type, targetId) {
const answer = (inboxAnswers[`${targetId}:${type}`] || '').trim();
if (!answer) return;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
delete inboxAnswers[`${targetId}:${type}`];
inboxAnswers = inboxAnswers;
} catch(e) {
console.error(e);
}
}
// Check completion status
$: phase = state.game.phase;
$: createdTechniques = state.player.created_techniques ? JSON.parse(state.player.created_techniques) : [];
$: heldTechniques = state.player.held_techniques ? JSON.parse(state.player.held_techniques) : [];
$: swappedTechniques = state.player.swapped_techniques ? JSON.parse(state.player.swapped_techniques) : [];
$: ownHeldCount = heldTechniques.filter(t => t.creator_id === state.player.id).length;
$: incomingOffers = state.players.filter(p => p.id !== state.player.id && p.swap_offer_to_id === state.player.id);
$: swapTargets = state.players.filter(p => p.id !== state.player.id && !p.needs_reroll);
$: offerTechCreator = (heldTechniques.find(t => t.text === offerTech) || {}).creator_id;
$: offerInvalid = !offerTech || !offerTargetId || offerTechCreator === offerTargetId;
$: basicComplete = !!(state.player.avatar_look && state.player.avatar_smell && state.player.first_words && state.player.good_at_math);
$: delegateComplete = !!(state.player.other_like_from_player_id && state.player.other_hate_from_player_id);
$: techniquesComplete = !!(state.player.tech_jack && state.player.tech_queen && state.player.tech_king);
// Dev Mode (toggled by the creator from the corner menu) reveals the
// Suggest buttons; in real games players write their own material.
$: devMode = !!state.game.dev_mode;
</script>
<div class="character-creation-view">
<div class="view-header text-center">
<h2>Character Sheet Creation</h2>
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
</div>
<!-- MAIN PANEL: Grid for Layout -->
<div class="creation-grid max-w-5xl mx-auto">
<!-- SECTION 1: Rat Records (Basic Info) -->
<div class="card glass-panel section-basic">
<h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3>
{#if basicComplete && !editingBasic}
<!-- Saved View -->
<div class="read-only-sheet">
<div class="record-line"><strong>Look:</strong> {state.player.avatar_look}</div>
<div class="record-line"><strong>Smell:</strong> {state.player.avatar_smell}</div>
<div class="record-line"><strong>First Words:</strong> "{state.player.first_words}"</div>
<div class="record-line"><strong>Good at Math?</strong> {state.player.good_at_math}</div>
</div>
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
{:else}
<!-- Form View -->
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
<div class="form-group">
<label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row">
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
<div class="input-row">
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="first_words">What were your first words after transforming?</label>
<div class="input-row">
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row">
<input type="text" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
</div>
</div>
<div class="input-row mt-4">
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
{#if basicComplete}
<button type="button" class="btn btn-secondary" disabled={savingBasic} on:click={cancelReviseBasic}>Cancel</button>
{/if}
</div>
</form>
{/if}
</div>
<!-- SECTION 2: Delegated Questions (Like/Hate) -->
<div class="card glass-panel section-delegations">
<h3>II. Crew Delegations {delegateComplete ? '✅' : '❌'}</h3>
<p class="section-desc">Other players decide what their rats like and hate about your character. Crewmates have been randomly assigned to answer for you.</p>
<div class="delegation-list">
{#if delegateComplete}
<div class="delegation-box glass-panel">
<h4>What does another rat LIKE about you?</h4>
<div class="answer-box">
{state.player.other_like ? `"${state.player.other_like}"` : '(Waiting for answer...)'}
</div>
<div class="author-label"> {getPlayerName(state.player.other_like_from_player_id)}</div>
</div>
<div class="delegation-box glass-panel">
<h4>What does another rat HATE about you?</h4>
<div class="answer-box">
{state.player.other_hate ? `"${state.player.other_hate}"` : '(Waiting for answer...)'}
</div>
<div class="author-label"> {getPlayerName(state.player.other_hate_from_player_id)}</div>
</div>
{:else}
<p class="mb-4 text-sm text-muted">Assigning crewmates to write your relationships...</p>
{/if}
</div>
</div>
<!-- SECTION 3: Answer Delegations for Others -->
<div class="card glass-panel section-inbox" style="grid-column: span 2;">
<h3>III. Your Inbox (Task List)</h3>
<p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p>
<div class="inbox-list">
{#each state.players as p}
{#if p.other_like_from_player_id === state.player.id}
<div class="inbox-item">
<label>
What do you LIKE about {displayName(p)}?
{#if p.other_like}<span class="text-success">(submitted: "{p.other_like}" — you can revise it)</span>{/if}
</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button>
</div>
</div>
{/if}
{#if p.other_hate_from_player_id === state.player.id}
<div class="inbox-item">
<label>
What do you HATE about {displayName(p)}?
{#if p.other_hate}<span class="text-success">(submitted: "{p.other_hate}" — you can revise it)</span>{/if}
</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button>
</div>
</div>
{/if}
{/each}
{#if state.players.filter(p => p.other_like_from_player_id === state.player.id || p.other_hate_from_player_id === state.player.id).length === 0}
<p class="inbox-empty-message text-muted italic">No pending tasks! Wait for other players to assign you tasks.</p>
{/if}
</div>
</div>
<!-- SECTION 4: Techniques -->
<div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;">
<h3>IV. Secret Techniques {techniquesComplete ? '✅' : '❌'}</h3>
{#if state.player.needs_reroll}
<p class="text-muted italic">You're spectating for now — you'll create your Pi-Rat with the crew between scenes.</p>
{:else if phase === 'character_creation' && createdTechniques.length === 0}
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
{#if techError}
<div class="alert alert-danger">{techError}</div>
{/if}
<form on:submit|preventDefault={submitTechniques} id="tech-form">
<div class="form-group">
<label for="tech1">Technique #1:</label>
<div class="input-row">
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech1 = await getTechniqueSuggestion([tech2, tech3])}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="tech2">Technique #2:</label>
<div class="input-row">
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech2 = await getTechniqueSuggestion([tech1, tech3])}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="tech3">Technique #3:</label>
<div class="input-row">
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button>{/if}
</div>
</div>
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
{#if revisingTechs}
<button type="button" class="btn btn-secondary btn-full mt-4" disabled={savingTechs} on:click={cancelReviseTechniques}>Cancel keep my original techniques</button>
{/if}
</form>
{:else if phase === 'character_creation'}
<div class="submitted-techniques text-center mt-4">
{#if techError}
<div class="alert alert-danger">{techError}</div>
{/if}
<div class="tech-chip">#1: {createdTechniques[0]}</div>
<div class="tech-chip">#2: {createdTechniques[1]}</div>
<div class="tech-chip">#3: {createdTechniques[2]}</div>
<p class="waiting-box margin-top flex justify-center mt-4">
<span class="spinner-small"></span>
Techniques submitted! Once everyone submits, you'll trade them all away in blind swaps.
</p>
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseTechniques}>✏️ Revise Techniques</button>
</div>
{:else if phase === 'swap_techniques'}
<p class="section-desc">
Trade away all 3 techniques you created by offering blind swaps to your crewmates.
They only learn that you offered a swap never which technique is on the table!
You can't offer a technique to the rat who wrote it.
</p>
{#if swapError}
<div class="alert alert-danger">{swapError}</div>
{/if}
<h4>Your techniques in hand</h4>
<div class="submitted-techniques mt-4">
{#each heldTechniques as t}
<div class="tech-chip">
{t.text}
{#if t.creator_id === state.player.id}
<span class="text-warning"> yours, swap it away!</span>
{:else}
<span class="text-success"> from {getPlayerName(t.creator_id)}</span>
{/if}
</div>
{/each}
</div>
{#if state.player.swap_offer_to_id}
<div class="delegation-box glass-panel mt-4">
<p>
You offered <strong>"{state.player.swap_offer_technique}"</strong> to
{getPlayerName(state.player.swap_offer_to_id)}. Waiting for their answer...
</p>
<button class="btn btn-secondary mt-4" disabled={swapBusy} on:click={cancelOffer}>Withdraw Offer</button>
</div>
{:else if !state.player.is_ready}
<div class="delegation-box glass-panel mt-4">
<h4>Offer a swap</h4>
<div class="input-row">
<select class="input-field" bind:value={offerTech}>
<option value="" disabled>Pick a technique to give...</option>
{#each heldTechniques as t}
<option value={t.text}>{t.text}</option>
{/each}
</select>
<select class="input-field" bind:value={offerTargetId}>
<option value="" disabled>Pick a crewmate...</option>
{#each swapTargets as p}
<option value={p.id} disabled={offerTechCreator === p.id}>
{displayName(p)}{offerTechCreator === p.id ? ' (wrote it!)' : ''}
</option>
{/each}
</select>
<button class="btn btn-primary" disabled={swapBusy || offerInvalid} on:click={offerSwap}>Offer Swap</button>
</div>
</div>
{/if}
{#each incomingOffers as offerer (offerer.id)}
<div class="delegation-box glass-panel mt-4">
<h4>📨 {displayName(offerer)} offered you a mystery technique!</h4>
<p class="text-sm text-muted">Choose one of your techniques to trade back (not one they wrote):</p>
<div class="input-row">
<select class="input-field" bind:value={acceptChoice[offerer.id]}>
<option value="" disabled selected>Pick a technique to give back...</option>
{#each heldTechniques as t}
<option value={t.text} disabled={t.creator_id === offerer.id}>
{t.text}{t.creator_id === offerer.id ? ' (they wrote it!)' : ''}
</option>
{/each}
</select>
<button class="btn btn-primary" disabled={swapBusy || !acceptChoice[offerer.id]} on:click={() => respondOffer(offerer.id, true)}>Accept</button>
<button class="btn btn-secondary" disabled={swapBusy} on:click={() => respondOffer(offerer.id, false)}>Decline</button>
</div>
</div>
{/each}
<div class="text-center mt-6">
{#if state.player.is_ready}
<p class="waiting-box flex justify-center">
<span class="spinner-small"></span>
Done swapping! Waiting for the rest of the crew...
</p>
<button class="btn btn-secondary mt-4" disabled={swapBusy} on:click={() => setSwapReady(false)}>Wait, I want to keep swapping</button>
{:else if ownHeldCount > 0}
<p class="text-muted italic">Swap away {ownHeldCount} more of your own technique{ownHeldCount === 1 ? '' : 's'} before you can ready up.</p>
{:else}
<button class="btn btn-primary btn-large" disabled={swapBusy || !!state.player.swap_offer_to_id} on:click={() => setSwapReady(true)}>I'm done swapping</button>
{/if}
</div>
{:else if !techniquesComplete}
<div class="max-w-4xl mx-auto space-y-6">
<p>Drag and drop your assigned techniques onto your Face Cards:</p>
{#if faceError}
<div class="alert alert-danger">{faceError}</div>
{/if}
<FaceCardAssigner techniques={swappedTechniques} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} />
<div class="mt-6">
<button
class="btn btn-primary btn-large w-full"
disabled={assigningFace || !jackTech || !queenTech || !kingTech}
on:click={assignFaceTechniques}
>
{assigningFace ? 'Assigning...' : 'Assign to Cards'}
</button>
{#if revisingFace}
<button
class="btn btn-secondary w-full mt-4"
disabled={assigningFace}
on:click={cancelReviseFaceTechniques}
>Cancel keep my original assignment</button>
{/if}
</div>
</div>
{:else}
<div class="assigned-techs text-center mt-4">
{#if faceError}
<div class="alert alert-danger">{faceError}</div>
{/if}
<div class="tech-assignment-pill"><span class="card-rank">J</span> {state.player.tech_jack}</div>
<div class="tech-assignment-pill"><span class="card-rank">Q</span> {state.player.tech_queen}</div>
<div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div>
<p class="waiting-box margin-top flex justify-center mt-4">
<span class="spinner-small"></span>
Ready! Waiting for other players to finish J/Q/K assignment...
</p>
{#if phase === 'assign_techniques'}
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseFaceTechniques}>✏️ Revise Assignment</button>
{/if}
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,112 @@
<script>
import AboutModal from './AboutModal.svelte';
import { extraMenuLinks } from '../lib/menu';
import { theme, toggleTheme } from '../lib/theme';
let open = false;
let showAbout = false;
$: themeLink = $theme === 'dark'
? { label: '☀️ Light Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the light theme' }
: { label: '🌙 Dark Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the dark theme' };
$: baseLinks = [
{ label: '📖 Rules', href: '/rules', external: true, title: 'Open the rulebook in a new tab' },
themeLink,
{ label: ' About', action: () => (showAbout = true), title: 'Version and changelog' },
];
$: links = [...$extraMenuLinks, ...baseLinks];
function handleWindowClick(event) {
if (open && !event.target.closest('.corner-menu')) {
open = false;
}
}
</script>
<svelte:window on:click={handleWindowClick} />
<div class="corner-menu">
<button class="menu-toggle" class:open on:click={() => (open = !open)} title="Menu">
☰ Menu
</button>
{#if open}
<nav class="menu-dropdown">
{#each links as link}
{#if link.action}
<button
class="menu-item"
title={link.title}
on:click={() => { if (!link.keepOpen) open = false; link.action(); }}
>{link.label}</button>
{:else}
<a
class="menu-item"
href={link.href}
target={link.external ? '_blank' : undefined}
rel={link.external ? 'noopener' : undefined}
title={link.title}
on:click={() => (open = false)}
>{link.label}</a>
{/if}
{/each}
</nav>
{/if}
</div>
{#if showAbout}
<AboutModal on:close={() => (showAbout = false)} />
{/if}
<style>
.corner-menu {
position: fixed;
top: 12px;
right: 16px;
z-index: 1001;
text-align: right;
}
.menu-toggle {
padding: 6px 14px;
border-radius: 16px;
background: color-mix(in srgb, var(--bg-deep) 92%, transparent);
border: 1px solid var(--edge-accent);
color: var(--accent);
font-size: 0.85rem;
font-weight: 700;
font-family: var(--font-heading);
cursor: pointer;
}
.menu-toggle:hover, .menu-toggle.open {
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
}
.menu-dropdown {
margin-top: 6px;
display: flex;
flex-direction: column;
background: color-mix(in srgb, var(--bg-deep) 96%, transparent);
border: 1px solid var(--edge-accent);
border-radius: var(--radius-md);
overflow: hidden;
}
.menu-item {
padding: 8px 16px;
color: var(--accent);
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
font-family: var(--font-heading);
text-align: right;
white-space: nowrap;
background: none;
border: none;
cursor: pointer;
}
.menu-item:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.menu-item + .menu-item {
border-top: 1px solid var(--edge-soft);
}
</style>

View File

@@ -0,0 +1,97 @@
<script>
import { displayName, crewLabel } from '../lib/cards';
import CharacterSheet from './scene/CharacterSheet.svelte';
export let state;
let openTargetId = null;
$: captainId = state.game.captain_player_id;
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
function iconFor(p) {
if (p.role === 'deep') return '🌊';
if (p.is_ghost) return '👻';
if (p.is_dead) return '💀';
return '🐀';
}
function safeArray(value) {
try { return JSON.parse(value || '[]'); } catch { return []; }
}
function statusFor(p) {
const phase = state.game.phase;
if (phase === 'lobby') {
if (p.is_creator) return 'Creator';
if (p.is_admin) return 'Admin';
return 'Crew member';
}
if (phase === 'character_creation') {
if (p.needs_reroll) return 'Spectating';
return safeArray(p.created_techniques).length === 3 ? 'Techniques submitted' : 'Writing sheet';
}
if (phase === 'swap_techniques') {
if (p.is_ready) return 'Done swapping';
const ownLeft = safeArray(p.held_techniques).filter(t => t.creator_id === p.id).length;
return `Swapping · ${ownLeft} own left`;
}
if (phase === 'assign_techniques') return p.tech_jack ? 'Ready' : 'Assigning face cards';
if (phase === 'scene_setup') {
if (p.needs_reroll) return 'Spectating';
if (p.role === 'deep') return 'The Deep';
if (p.role === 'pirat') return `Pi-Rat · Rank ${p.rank}`;
return 'Choosing a role…';
}
if (phase === 'between_scenes') {
const voted = (state.votes || []).some(v => v.voter_player_id === p.id);
const canVote = state.players.some(candidate =>
candidate.id !== p.id && candidate.role !== 'deep' && !candidate.is_dead && !candidate.needs_reroll
);
return `Rank ${p.rank} · ${voted ? 'Nomination done' : canVote ? 'Nominating…' : 'No vote'}`;
}
if (phase === 'deep_upkeep') return `Rank ${p.rank} · ${p.is_ready ? 'Ready' : 'Finishing upkeep…'}`;
if (phase === 'recruit_creation') return p.needs_reroll ? 'Creating a new Pi-Rat' : `Rank ${p.rank} · Helping recruits`;
if (phase === 'ended') {
const story = p.completed_personal_3 ? 'Story complete' : 'Still sailing';
return `Rank ${p.rank} · ${story}`;
}
return `Rank ${p.rank}`;
}
</script>
<aside class="crew-column phase-crew-column" aria-label="Crew roster">
<div class="crew-bubbles">
{#each crew as p (p.id)}
{#if state.game.phase === 'lobby'}
<div
class="crew-bubble is-static"
class:is-you={p.id === state.player.id}
>
<span class="bubble-icon">{iconFor(p)}</span>
<span class="bubble-name">{displayName(p)}</span>
<span class="bubble-meta">{statusFor(p)}</span>
</div>
{:else}
<button
class="crew-bubble"
class:is-you={p.id === state.player.id}
class:is-deep={p.role === 'deep'}
class:is-dead={p.is_dead}
class:is-ghost={p.is_ghost}
title="View {p.name}'s character sheet"
on:click={() => openTargetId = p.id}
>
<span class="bubble-icon">{iconFor(p)}</span>
<span class="bubble-name">{crewLabel(p, captainId)}</span>
<span class="bubble-meta">{statusFor(p)}</span>
</button>
{/if}
{/each}
</div>
</aside>
{#if openTarget}
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
{/if}

View File

@@ -0,0 +1,515 @@
<script>
import { tick, onMount, createEventDispatcher } from 'svelte';
import { apiRequest } from '../lib/api';
const dispatch = createEventDispatcher();
export let state;
// Inline mode pins the log open as a column (scene phase) instead of the
// floating, collapsible corner panel used in every other phase.
export let inline = false;
const PAGE_SIZE = 50;
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
const TOP_LOAD_THRESHOLD = 60; // px from the top that triggers loading older events
const KIND_ICONS = {
join: '👋',
phase: '🧭',
scene: '🎬',
captain: '🏴‍☠️',
challenge: '⚔️',
card: '🃏',
tax: '💰',
objective: '🎯',
vote: '🗳️',
obstacle: '🌊',
joker: '🤡',
victory: '🎉',
warning: '⚠️',
rank: '🎖️',
info: '📜',
};
let open = false;
// The content is visible whenever the floating log is open OR it's pinned inline.
$: shown = open || inline;
let logEl;
let events = []; // ascending by timestamp; accumulated across polls + history loads
let seenIds = new Set();
let haveMore = true;
let loadingOlder = false;
let atBottom = true;
let error = '';
let timelineVersion = null;
// Toast that briefly previews a freshly-arrived event while the log is
// collapsed, then animates down into the Event Log button.
let toast = null; // { id, message, kind }
let initialized = false; // suppresses a toast flood on the first state load
// 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);
// The poll only carries the newest PAGE_SIZE events, so accumulate them here —
// 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;
let reset = false;
if (timelineVersion !== null && version !== timelineVersion) {
events = [];
seenIds = new Set();
reset = true;
}
timelineVersion = version;
if (events.length === 0) {
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
}
let added = false;
let newest = null;
for (const e of polled) {
if (!seenIds.has(e.id)) {
seenIds.add(e.id);
events.push(e);
added = true;
newest = e;
}
}
if (added) {
events = events;
if (shown && atBottom) {
await tick();
scrollToBottom();
}
// Toast only for genuinely new events (not the initial seed or a
// rollback reseed) and only while the floating log is closed.
if (initialized && !reset && !shown && newest) {
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
}
}
initialized = true;
}
// An open/inline log makes the preview toast redundant.
$: if (shown) toast = null;
onMount(() => {
if (inline) scrollToBottom();
});
function clearToast(id) {
if (toast && toast.id === id) toast = null;
}
function scrollToBottom() {
if (logEl) logEl.scrollTop = logEl.scrollHeight;
atBottom = true;
}
function onScroll() {
if (!logEl) return;
atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight <= BOTTOM_TOLERANCE;
if (logEl.scrollTop <= TOP_LOAD_THRESHOLD) loadOlder();
}
async function loadOlder() {
if (loadingOlder || !haveMore || events.length === 0) return;
loadingOlder = true;
try {
const oldest = events[0].timestamp;
const data = await apiRequest(`/game/${state.game.id}/events?before=${oldest}&limit=${PAGE_SIZE}`);
haveMore = data.have_more;
const fresh = data.events.filter(e => !seenIds.has(e.id));
for (const e of fresh) seenIds.add(e.id);
if (fresh.length) {
// Prepending grows the content above the viewport; restore the
// player's position so the log doesn't visually jump.
const prevHeight = logEl.scrollHeight;
const prevTop = logEl.scrollTop;
events = [...fresh, ...events];
await tick();
logEl.scrollTop = prevTop + (logEl.scrollHeight - prevHeight);
}
} catch (e) {
// Transient fetch failure; scrolling again retries.
} finally {
loadingOlder = false;
}
}
async function toggleOpen() {
open = !open;
if (open) {
await tick();
scrollToBottom();
}
}
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 {shown ? 'open' : ''} {inline ? 'inline' : ''}">
{#if toast && !shown}
{#key toast.id}
<button
class="event-toast log-kind-{toast.kind}"
title="Open the Event Log"
on:click={toggleOpen}
on:animationend={() => clearToast(toast.id)}
>
<span class="log-icon">{KIND_ICONS[toast.kind] || KIND_ICONS.info}</span>
<span class="toast-message">{toast.message}</span>
</button>
{/key}
{/if}
{#if inline}
<div class="log-header">
📜 Event Log
<button class="log-hide-btn" title="Collapse the event log" on:click={() => dispatch('collapse')}>✕</button>
</div>
{:else}
<button class="toggle-log-btn" on:click={toggleOpen}>
{open ? '⬇️ Hide Log' : '📜 Event Log'}
</button>
{/if}
{#if shown}
{#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">
{#if loadingOlder}
<span class="log-top-note">Hauling up older entries…</span>
{:else if haveMore}
<button class="load-older-btn" on:click={loadOlder}>⬆️ Load earlier events</button>
{:else}
<span class="log-top-note">⚓ The story begins here</span>
{/if}
</div>
{#each events as event (event.id)}
{@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}
<div class="log-empty">No events yet.</div>
{/if}
</div>
{#if !atBottom}
<button class="jump-to-bottom" on:click={scrollToBottom}>⬇️ Latest</button>
{/if}
{/if}
</div>
<style>
.floating-event-log {
position: fixed;
bottom: 20px;
right: 20px;
width: 380px;
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
z-index: 1000;
display: flex;
flex-direction: column;
max-height: 60vh;
backdrop-filter: blur(10px);
}
.floating-event-log:not(.open) {
width: auto;
}
/* Inline mode: pinned as the scene's third column instead of floating. The
top offset matches .dashboard-container's top padding (clears the fixed
corner menu) so the panel doesn't shift as the page scrolls; the height
fills to ~1rem above the viewport bottom, so the panel never runs past
the screen and its .log-content scrolls internally instead. */
.floating-event-log.inline {
position: sticky;
top: 3.5rem;
right: auto;
bottom: auto;
width: 100%;
max-height: calc(100vh - 4.5rem);
}
@media (max-width: 1100px) {
.floating-event-log.inline {
position: static;
max-height: 60vh;
}
}
.log-header {
position: relative;
padding: 10px;
text-align: center;
font-weight: bold;
font-family: var(--font-heading);
border-bottom: 1px solid var(--edge-soft);
color: var(--text);
}
.log-hide-btn {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
background: transparent;
border: none;
color: var(--text-muted);
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 2px 7px;
border-radius: var(--radius-sm);
}
.log-hide-btn:hover {
background: color-mix(in srgb, var(--text) 12%, transparent);
color: var(--text);
}
.toggle-log-btn {
background: transparent;
color: var(--text);
border: none;
padding: 10px;
text-align: center;
cursor: pointer;
font-weight: bold;
border-radius: var(--radius-md);
border-bottom: 1px solid var(--edge-soft);
font-family: var(--font-heading);
}
.toggle-log-btn:hover {
background: color-mix(in srgb, var(--text) 10%, transparent);
}
.log-content {
overflow-y: auto;
padding: 10px;
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
scrollbar-width: thin;
}
.log-top {
text-align: center;
padding: 2px 0 6px;
}
.log-top-note {
font-size: 0.75rem;
color: var(--text-muted);
font-style: italic;
}
.load-older-btn {
background: color-mix(in srgb, var(--text) 6%, transparent);
border: 1px solid var(--edge);
color: var(--text-muted);
font-size: 0.75rem;
padding: 4px 12px;
border-radius: 12px;
cursor: pointer;
}
.load-older-btn:hover {
background: color-mix(in srgb, var(--text) 12%, transparent);
color: var(--text);
}
.log-entry {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 8px;
align-items: baseline;
padding: 6px 8px;
background: color-mix(in srgb, var(--text) 3%, transparent);
border-left: 3px solid var(--edge);
border-radius: 0 6px 6px 0;
font-size: 0.85rem;
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;
}
.log-message {
overflow-wrap: anywhere;
}
.log-time {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
opacity: 0.8;
}
.log-empty {
padding: 10px;
color: var(--text-muted);
font-style: italic;
}
.jump-to-bottom {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
background: var(--accent);
color: var(--text-inverse);
border: none;
border-radius: 16px;
padding: 5px 14px;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
box-shadow: var(--shadow-soft);
}
.jump-to-bottom:hover {
filter: brightness(1.1);
}
/* Toast preview of a fresh event, anchored above the button */
.event-toast {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
width: 320px;
max-width: 80vw;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
padding: 10px 12px;
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
border: 1px solid var(--edge);
border-left: 4px solid var(--accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
backdrop-filter: blur(10px);
color: var(--text);
font-family: var(--font-body);
cursor: pointer;
transform-origin: bottom right;
animation: event-toast-into-button 3.4s ease-in forwards;
}
.event-toast .toast-message {
flex: 1;
font-size: 0.85rem;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
@keyframes event-toast-into-button {
0% { opacity: 0; transform: translateY(-6px) scale(0.96); }
8% { opacity: 1; transform: translateY(0) scale(1); }
75% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(40px) scale(0.4); }
}
@media (prefers-reduced-motion: reduce) {
.event-toast { animation-duration: 2.5s; animation-timing-function: linear; }
}
/* Accent colors by event kind */
.log-kind-challenge { border-left-color: var(--danger); }
.log-kind-card { border-left-color: var(--deep); }
.log-kind-captain,
.log-kind-tax,
.log-kind-rank,
.log-kind-victory { border-left-color: var(--accent); }
.log-kind-scene,
.log-kind-phase { border-left-color: var(--pirat); }
.log-kind-joker,
.log-kind-warning { border-left-color: var(--warning); }
.log-kind-obstacle { border-left-color: var(--deep); }
.log-kind-join,
.log-kind-vote,
.log-kind-objective { border-left-color: var(--mystic); }
.log-kind-victory {
background: color-mix(in srgb, var(--accent) 9%, transparent);
color: var(--accent);
}
</style>

View File

@@ -0,0 +1,79 @@
<script>
// Drag-and-drop assignment of 3 techniques onto the J/Q/K face cards.
// Used during initial character creation and between-scenes recruit creation.
export let techniques = [];
export let jack = '';
export let queen = '';
export let king = '';
const slots = [
{ key: 'jack', letter: 'J', title: 'Jack' },
{ key: 'queen', letter: 'Q', title: 'Queen' },
{ key: 'king', letter: 'K', title: 'King' },
];
$: values = { jack, queen, king };
$: assigned = [jack, queen, king];
function set(key, val) {
if (key === 'jack') jack = val;
else if (key === 'queen') queen = val;
else king = val;
}
function handleDrop(e, key) {
const tech = e.dataTransfer.getData('text/plain');
if (!tech) return;
for (const s of slots) {
if (s.key !== key && values[s.key] === tech) set(s.key, '');
}
set(key, tech);
}
</script>
<div class="available-techniques-container">
<h4>Available Techniques</h4>
<p class="instruction-help">Drag a technique onto a card slot below to assign it.</p>
<div class="techniques-drag-pool">
{#each techniques as tech}
<div
draggable={!assigned.includes(tech)}
on:dragstart={(e) => e.dataTransfer.setData('text/plain', tech)}
class="draggable-tech-chip {assigned.includes(tech) ? 'assigned-hidden' : ''}"
>
<span class="drag-icon">⋮⋮</span> {tech}
</div>
{/each}
</div>
</div>
<div class="face-cards-slots-grid">
{#each slots as s}
<div class="face-card-slot-wrapper">
<div class="face-card-slot {values[s.key] ? 'has-assignment' : ''}"
on:dragover|preventDefault
on:drop={(e) => handleDrop(e, s.key)}>
<div class="card-bg-letter">{s.letter}</div>
<div class="card-slot-header">
<span class="rank">{s.letter}</span>
</div>
<div class="card-slot-body">
<div class="card-title">{s.title}</div>
<div class="drop-zone-placeholder">Drop Technique Here</div>
<div class="assigned-tech-content">
{#if values[s.key]}
<div class="assigned-tech-chip">
{values[s.key]}
<span class="remove-tech-btn" on:click={() => set(s.key, '')}>×</span>
</div>
{/if}
</div>
</div>
<div class="card-slot-footer">
<span></span>
<span class="rank">{s.letter}</span>
</div>
</div>
</div>
{/each}
</div>

View File

@@ -0,0 +1,31 @@
<script>
export let state;
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
</script>
<div class="gameover-container">
<div class="card glass-panel gameover-card">
<h1>🎉 The Story Has Ended!</h1>
<p class="section-desc" style="font-size: 1.1rem;">
The Pi-Rats of {state.game.crew_name || 'the crew'} party til they pass out in a pile.
Tell one last tale of how your Pi-Rat is remembered!
</p>
<div class="sheet-group">
<h3 class="text-accent">Crew Objectives</h3>
<div class="objectives-checklist">
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_1} disabled> <span>Steal a Ship</span></label>
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_2} disabled> <span>Choose a Captain</span></label>
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_3} disabled> <span>Commit Piracy</span></label>
</div>
{#if crewDone}
<p class="success-text">A complete pirate legend! ⭐</p>
{:else}
<p class="info-text">Some deeds were left undone... a tale for the next crew.</p>
{/if}
</div>
<p class="info-text" style="margin-top: 2rem;">Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!</p>
</div>
</div>

View File

@@ -0,0 +1,88 @@
<script>
import { apiRequest } from '../lib/api';
export let state;
let descInput = '';
let error = '';
let submitting = false;
async function submitDescription() {
if (!descInput.trim() || submitting) return;
error = '';
submitting = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-gat-description`, 'POST', {
description: descInput.trim()
});
descInput = '';
} catch (e) {
error = e.message;
} finally {
submitting = false;
}
}
function onKeydown(e) {
if (e.key === 'Enter') submitDescription();
}
</script>
{#if state.player.needs_gat_description}
<div class="modal-backdrop">
<div class="modal-box glass-panel">
<h3>🔫 You Got a Gat!</h3>
<p class="info-text">
Every Pi-Rat's Gat is one of a kind. What does yours look like?
</p>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
<input
type="text"
bind:value={descInput}
class="input-field"
placeholder="e.g. A pearl-handled flintlock that smells of cheese"
on:keydown={onKeydown}
autofocus
>
<button
class="btn btn-gold btn-full"
on:click={submitDescription}
disabled={!descInput.trim() || submitting}
>
Claim Gat
</button>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
backdrop-filter: blur(6px);
}
.modal-box {
max-width: 440px;
width: 100%;
padding: 1.75rem;
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
text-align: center;
}
.modal-box h3 {
margin-top: 0;
}
.modal-box .input-field {
width: 100%;
margin: 0.75rem 0;
}
</style>

View File

@@ -0,0 +1,124 @@
<script>
import { apiRequest } from '../lib/api';
export let state;
let starting = false;
let copiedLink = null;
let copiedTimer = null;
function copyLink(which, value) {
navigator.clipboard.writeText(value);
copiedLink = which;
clearTimeout(copiedTimer);
copiedTimer = setTimeout(() => { copiedLink = null; }, 2000);
}
async function startGame() {
starting = true;
try {
await apiRequest(`/game/${state.game.id}/lobby/start`, 'POST');
// The polling will pick up the phase change automatically
} catch (err) {
console.error("Failed to start game:", err);
starting = false;
}
}
$: teaching = state.game.teaching_deep_player_id === state.player.id;
$: teachingTakenByOther = state.game.teaching_deep_player_id && !teaching;
async function toggleTeaching() {
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/teaching-mode`, 'POST', {
enabled: !teaching
});
} catch (err) {
console.error("Failed to toggle teaching mode:", err);
}
}
</script>
<div class="lobby-view text-center">
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
<div class="links-box glass-panel">
{#if state.player.is_admin}
<div class="link-item lobby-join-code-item">
<button
type="button"
class="admin-join-code"
class:copied={copiedLink === 'code'}
aria-label="Copy join code {state.game.join_code}"
on:click={() => copyLink('code', state.game.join_code)}
>
<span class="admin-join-code-label">Join Code</span>
<strong>{state.game.join_code}</strong>
<span class="admin-join-code-hint">{copiedLink === 'code' ? '✓ Copied!' : 'Click to copy'}</span>
</button>
</div>
{/if}
<div class="link-item">
<h4>Share Join Link:</h4>
<div class="copy-container">
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/join" class="copy-input" id="join-link-copy">
<button on:click={() => copyLink('join', document.getElementById('join-link-copy').value)} class="btn btn-secondary btn-small">
{copiedLink === 'join' ? '✓ Copied' : 'Copy'}
</button>
</div>
</div>
{#if state.player.is_admin}
<div class="link-item admin-link-item">
<h4 class="gold-text">⚠️ Admin Magic Link:</h4>
<p class="info-text">Save this admin link! You can use it to recover character sheets if another player loses their connection or link.</p>
<div class="copy-container">
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/admin" class="copy-input admin-input" id="admin-link-copy">
<button on:click={() => copyLink('admin', document.getElementById('admin-link-copy').value)} class="btn btn-gold btn-small">
{copiedLink === 'admin' ? '✓ Copied' : 'Copy Admin Link'}
</button>
</div>
<div class="mt-4">
<a href="/#/game/{state.game.id}/admin" class="text-sm text-muted underline">Access Admin Panel directly</a>
</div>
</div>
{/if}
</div>
{#if state.player.is_admin}
<div class="teaching-box glass-panel">
<label class="checkbox-label" class:disabled={teachingTakenByOther}>
<input type="checkbox" checked={teaching} disabled={teachingTakenByOther} on:change={toggleTeaching}>
<span>📚 <strong>Teaching mode:</strong> I'll be the Deep for the first scene</span>
</label>
<p class="info-text">
{#if teachingTakenByOther}
Another admin has already volunteered to teach as the Deep.
{:else}
Seeds you as the Rank-3 player who must play the Deep in scene 1, so you can run the table for new crews. Otherwise it's assigned at random.
{/if}
</p>
</div>
{/if}
<div class="lobby-action">
{#if state.player.is_admin}
{#if state.players.length >= 3 || state.game.dev_mode}
<button on:click={startGame}
disabled={starting}
class="btn btn-primary btn-large glow-effect">
{starting ? 'Starting...' : 'Start Character Creation'}
</button>
{:else}
<button class="btn btn-primary btn-large" disabled>Waiting for players... ({state.players.length}/3 minimum)</button>
{/if}
{:else}
<div class="waiting-indicator">
<div class="spinner-small"></div>
<p>Waiting for Captain to cast the spell...</p>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,88 @@
<script>
import { apiRequest } from '../lib/api';
export let state;
let newNameInput = '';
let error = '';
let submitting = false;
async function submitNewName() {
if (!newNameInput.trim() || submitting) return;
error = '';
submitting = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
new_name: newNameInput.trim()
});
newNameInput = '';
} catch (e) {
error = e.message;
} finally {
submitting = false;
}
}
function onKeydown(e) {
if (e.key === 'Enter') submitNewName();
}
</script>
{#if state.player.needs_name}
<div class="modal-backdrop">
<div class="modal-box glass-panel">
<h3>🏴‍☠️ You Earned a Name!</h3>
<p class="info-text">
You completed your second objective and ranked up. No more going by your smell — what is your new Pirate Name?
</p>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
<input
type="text"
bind:value={newNameInput}
class="input-field"
placeholder="Enter your Pirate Name..."
on:keydown={onKeydown}
autofocus
>
<button
class="btn btn-gold btn-full"
on:click={submitNewName}
disabled={!newNameInput.trim() || submitting}
>
Claim Name
</button>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
backdrop-filter: blur(6px);
}
.modal-box {
max-width: 440px;
width: 100%;
padding: 1.75rem;
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
text-align: center;
}
.modal-box h3 {
margin-top: 0;
}
.modal-box .input-field {
width: 100%;
margin: 0.75rem 0;
}
</style>

View File

@@ -0,0 +1,104 @@
<script>
import { apiRequest } from '../lib/api';
import { displayName } from '../lib/cards';
export let state;
let error = '';
let submitting = false;
$: me = state.player;
// Mirror the backend's is_eligible_nominee guard: another living, in-play Pi-Rat.
$: eligible = (state.players || []).filter(
(p) => p.id !== me.id && p.role !== 'deep' && !p.is_dead && !p.needs_reroll
);
async function grant(targetId) {
if (submitting) return;
error = '';
submitting = true;
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/bonus-rank-up`, 'POST', {
target_player_id: targetId || ''
});
} catch (e) {
error = e.message;
} finally {
submitting = false;
}
}
</script>
{#if me.needs_rank_3_bonus}
<div class="modal-backdrop">
<div class="modal-box glass-panel">
<h3>⭐ Your Story is Complete!</h3>
<p class="info-text">
You completed your final Personal Objective and earned your rest. Choose a
crewmate to carry on stronger — they gain <strong>1 Rank</strong>.
</p>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
{#if eligible.length}
<div class="bonus-choices">
{#each eligible as p (p.id)}
<button
class="btn btn-gold btn-full"
on:click={() => grant(p.id)}
disabled={submitting}
>
{displayName(p)}
<span class="rank-hint">Rank {p.rank}{p.rank + 1}</span>
</button>
{/each}
</div>
{:else}
<p class="info-text">No crewmate is eligible to receive the Rank right now.</p>
<button
class="btn btn-secondary btn-full"
on:click={() => grant('')}
disabled={submitting}
>
Forfeit the Rank Bonus
</button>
{/if}
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
backdrop-filter: blur(6px);
}
.modal-box {
max-width: 440px;
width: 100%;
padding: 1.75rem;
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
text-align: center;
}
.modal-box h3 {
margin-top: 0;
}
.bonus-choices {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
.rank-hint {
font-size: 0.85em;
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,315 @@
<script>
import { apiRequest } from '../lib/api';
import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards';
import FaceCardAssigner from './FaceCardAssigner.svelte';
export let state;
function getPlayerName(id) {
if (!id) return 'None';
const p = state.players.find(x => x.id === id);
return p ? displayName(p) : 'Unknown';
}
function incomingTechs(p) {
return p.incoming_techniques ? JSON.parse(p.incoming_techniques) : [];
}
// Dev Mode (toggled by the creator from the corner menu) reveals the
// Suggest buttons; in real games players write their own material.
$: devMode = !!state.game.dev_mode;
$: me = state.player;
$: recruits = state.players.filter(p => p.needs_reroll);
$: iAmRecruit = me.needs_reroll;
// --- My recruit sheet (only if I'm a pending recruit) ---
let basicDetails = {
avatar_look: state.player.avatar_look || '',
avatar_smell: state.player.avatar_smell || '',
first_words: state.player.first_words || '',
good_at_math: state.player.good_at_math || ''
};
let savingBasic = false;
let editingBasic = false;
async function saveBasicDetails() {
savingBasic = true;
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/save-basic`, 'POST', basicDetails);
editingBasic = false;
} catch(e) {
console.error(e);
} finally {
savingBasic = false;
}
}
function reviseBasicDetails() {
basicDetails = {
avatar_look: me.avatar_look || '',
avatar_smell: me.avatar_smell || '',
first_words: me.first_words || '',
good_at_math: me.good_at_math || ''
};
editingBasic = true;
}
// Discard any edits made since clicking Revise and return to the saved view.
function cancelReviseBasic() {
editingBasic = false;
}
$: basicComplete = !!(me.avatar_look && me.avatar_smell && me.first_words && me.good_at_math);
$: likeDone = !me.other_like_from_player_id || !!me.other_like;
$: hateDone = !me.other_hate_from_player_id || !!me.other_hate;
$: myTechs = incomingTechs(me);
$: myTechTexts = myTechs.map(s => s.text);
$: techsDone = myTechTexts.length === 3 && myTechTexts.every(t => t);
// J/Q/K assignment + finalize
let jackTech = '', queenTech = '', kingTech = '';
let finalizing = false;
let finalizeError = '';
async function finalizeRecruit() {
finalizing = true;
finalizeError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/finalize-recruit`, 'POST', {
jack: jackTech, queen: queenTech, king: kingTech
});
} catch(e) {
finalizeError = e.message;
} finally {
finalizing = false;
}
}
// --- Helping other recruits ---
// Like/Hate answers (same endpoint as character creation)
let inboxAnswers = {};
async function submitDelegatedAnswer(type, targetId) {
const answer = (inboxAnswers[`${targetId}:${type}`] || '').trim();
if (!answer) return;
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
delete inboxAnswers[`${targetId}:${type}`];
inboxAnswers = inboxAnswers;
} catch(e) {
console.error(e);
}
}
// Technique contributions: one draft per (recruit, slot)
let techDrafts = {};
let techErrors = {};
async function contributeTechnique(recruitId, slot) {
const text = (techDrafts[`${recruitId}:${slot}`] || '').trim();
if (!text) return;
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/contribute-technique`, 'POST', {
recruit_id: recruitId, slot, text
});
delete techDrafts[`${recruitId}:${slot}`];
techDrafts = techDrafts;
delete techErrors[`${recruitId}:${slot}`];
techErrors = techErrors;
} catch(e) {
techErrors[`${recruitId}:${slot}`] = e.message;
techErrors = techErrors;
}
}
async function suggestTechnique(recruitId, slot) {
techDrafts[`${recruitId}:${slot}`] = await getTechniqueSuggestion(Object.values(techDrafts));
techDrafts = techDrafts;
}
// My outstanding tasks for other recruits
$: myTasks = recruits.filter(p => p.id !== me.id).map(p => ({
recruit: p,
like: p.other_like_from_player_id === me.id,
hate: p.other_hate_from_player_id === me.id,
techSlots: incomingTechs(p).map((s, i) => ({ ...s, slot: i })).filter(s => s.from_id === me.id)
})).filter(t => t.like || t.hate || t.techSlots.length > 0);
</script>
<div class="recruit-phase-view">
<div class="view-header text-center">
<h2>🐀 New Recruits</h2>
<p>Fresh rats are washing aboard! Recruits fill in their Rat Records while the crew answers their Like/Hate questions and writes their Secret Techniques.</p>
</div>
<div class="creation-grid max-w-5xl mx-auto">
{#if iAmRecruit}
<!-- MY NEW RECRUIT SHEET -->
<div class="card glass-panel section-basic">
<h3>I. Your New Rat Records {basicComplete ? '✅' : '❌'}</h3>
{#if basicComplete && !editingBasic}
<div class="read-only-sheet">
<div class="record-line"><strong>Look:</strong> {me.avatar_look}</div>
<div class="record-line"><strong>Smell:</strong> {me.avatar_smell}</div>
<div class="record-line"><strong>First Words:</strong> "{me.first_words}"</div>
<div class="record-line"><strong>Good at Math?</strong> {me.good_at_math}</div>
</div>
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
{:else}
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
<div class="form-group">
<label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row">
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like? (Determines name)</label>
<div class="input-row">
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="first_words">What were your first words after transforming?</label>
<div class="input-row">
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row">
<input type="text" id="good_at_math" placeholder="e.g. Thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
</div>
</div>
<div class="input-row mt-4">
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
{#if basicComplete}
<button type="button" class="btn btn-secondary" disabled={savingBasic} on:click={cancelReviseBasic}>Cancel</button>
{/if}
</div>
</form>
{/if}
</div>
<div class="card glass-panel section-delegations">
<h3>II. Crew Delegations {likeDone && hateDone ? '✅' : '❌'}</h3>
<p class="section-desc">Crewmates decide what their rats like and hate about your new recruit.</p>
<div class="delegation-list">
<div class="delegation-box glass-panel">
<h4>What does another rat LIKE about you?</h4>
<div class="answer-box">
{me.other_like ? `"${me.other_like}"` : '(Waiting for answer...)'}
</div>
<div class="author-label"> {getPlayerName(me.other_like_from_player_id)}</div>
</div>
<div class="delegation-box glass-panel">
<h4>What does another rat HATE about you?</h4>
<div class="answer-box">
{me.other_hate ? `"${me.other_hate}"` : '(Waiting for answer...)'}
</div>
<div class="author-label"> {getPlayerName(me.other_hate_from_player_id)}</div>
</div>
</div>
</div>
<div class="card glass-panel section-techniques" style="grid-column: span 2;">
<h3>III. Your New Secret Techniques {techsDone ? '✅' : '❌'}</h3>
{#if !techsDone}
<p class="section-desc">Your crewmates are writing 3 fresh techniques for you:</p>
<div class="submitted-techniques text-center mt-4">
{#each myTechs as slot, i}
<div class="tech-chip">#{i + 1}: {slot.text ? slot.text : `Waiting for ${getPlayerName(slot.from_id)}...`}</div>
{/each}
</div>
{:else}
<div class="max-w-4xl mx-auto space-y-6">
<p>Drag and drop your new techniques onto your Face Cards:</p>
{#if finalizeError}
<div class="alert alert-danger">{finalizeError}</div>
{/if}
<FaceCardAssigner techniques={myTechTexts} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} />
<div class="mt-6">
<button
class="btn btn-primary btn-large w-full glow-effect"
disabled={finalizing || !basicComplete || !likeDone || !hateDone || !jackTech || !queenTech || !kingTech}
on:click={finalizeRecruit}
>
{finalizing ? 'Joining the Crew...' : '🐀 Join the Crew!'}
</button>
{#if !basicComplete || !likeDone || !hateDone}
<p class="info-text text-center" style="margin-top: 0.5rem;">Finish your Rat Records and wait for your Like/Hate answers before joining.</p>
{/if}
</div>
</div>
{/if}
</div>
{/if}
<!-- TASKS FOR OTHER RECRUITS -->
<div class="card glass-panel section-inbox" style="grid-column: span 2;">
<h3>{iAmRecruit ? 'IV.' : 'I.'} Your Inbox (Help the Recruits)</h3>
<p class="section-desc">Answer questions and write Secret Techniques for the fresh recruits. Be creative and have fun with it!</p>
<div class="inbox-list">
{#each myTasks as task (task.recruit.id)}
{#if task.like}
<div class="inbox-item">
<label>
What do you LIKE about {displayName(task.recruit)}?
{#if task.recruit.other_like}<span class="text-success">(submitted: "{task.recruit.other_like}" — you can revise it)</span>{/if}
</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:like`]} placeholder="e.g. They always share their cheese"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:like`] = getSuggestion('like')}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', task.recruit.id)}>Submit</button>
</div>
</div>
{/if}
{#if task.hate}
<div class="inbox-item">
<label>
What do you HATE about {displayName(task.recruit)}?
{#if task.recruit.other_hate}<span class="text-success">(submitted: "{task.recruit.other_hate}" — you can revise it)</span>{/if}
</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:hate`]} placeholder="e.g. They snore too loudly"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', task.recruit.id)}>Submit</button>
</div>
</div>
{/if}
{#each task.techSlots as slot}
<div class="inbox-item">
<label>
Write a Secret Technique for {displayName(task.recruit)}
{#if slot.text}<span class="text-success">(submitted: "{slot.text}" — you can revise it)</span>{/if}
</label>
{#if techErrors[`${task.recruit.id}:${slot.slot}`]}
<div class="alert alert-danger">{techErrors[`${task.recruit.id}:${slot.slot}`]}</div>
{/if}
<div class="input-row">
<input type="text" class="input-field" bind:value={techDrafts[`${task.recruit.id}:${slot.slot}`]} placeholder="e.g. Parabolic boarding bounce"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggestTechnique(task.recruit.id, slot.slot)}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(techDrafts[`${task.recruit.id}:${slot.slot}`] || '').trim()} on:click={() => contributeTechnique(task.recruit.id, slot.slot)}>Submit</button>
</div>
</div>
{/each}
{/each}
{#if myTasks.length === 0}
<p class="inbox-empty-message text-muted italic">
{#if iAmRecruit}
No tasks your crewmates are hard at work on your new rat.
{:else}
Nothing left to write. Waiting for the recruits to finish their sheets...
{/if}
</p>
{/if}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,79 @@
<script>
import Card from './Card.svelte';
import ChallengePanel from './scene/ChallengePanel.svelte';
import ObstacleBoard from './scene/ObstacleBoard.svelte';
import CrewColumn from './scene/CrewColumn.svelte';
import EventLog from './EventLog.svelte';
export let state;
// The inline Event Log can be toggled from a fixed corner button to declutter.
let logOpen = true;
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
$: isDeep = state.player.role === 'deep';
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
// The Challenge area is shown when something is happening there, or to the Deep
// (who calls Challenges and ends the scene from it).
$: showChallengeArea = isDeep || openChallenges.length > 0;
</script>
<div class="scene-view-layout" class:log-collapsed={!logOpen} id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
<!-- LEFT COLUMN: The Crew -->
<CrewColumn {state} />
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
<div class="table-column">
{#if !isDeep}
<div class="card glass-panel hand-card">
<span class="hand-label">🃏 Your Hand</span>
<div class="hand-flex">
{#each hand as card}
<Card {card} techs={playerTechs}
draggable={true}
on:dragstart={(e) => {
e.dataTransfer.setData('text/plain', card);
e.currentTarget.classList.add("dragging");
}}
on:dragend={(e) => {
e.currentTarget.classList.remove("dragging");
}} />
{:else}
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
{/each}
</div>
</div>
{/if}
{#if showChallengeArea}
<div class="card glass-panel challenge-area-card">
<ChallengePanel {state} />
</div>
{/if}
<div class="card glass-panel obstacle-list-card">
<div class="card-header">
<h3>🌊 The Obstacle List</h3>
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
</div>
<ObstacleBoard {state} />
</div>
</div>
<!-- RIGHT COLUMN: The Event Log (collapsible to a corner button) -->
{#if logOpen}
<div class="log-column">
<EventLog {state} inline on:collapse={() => (logOpen = false)} />
</div>
{/if}
</div>
<button
class="log-reopen-btn"
title={logOpen ? 'Hide the event log' : 'Show the event log'}
on:click={() => (logOpen = !logOpen)}
>
{logOpen ? '✕ Close Log' : '📜 Event Log'}
</button>

View File

@@ -0,0 +1,134 @@
<script>
import { onMount } from 'svelte';
import { apiRequest } from '../lib/api';
export let state;
let settingRole = false;
let starting = false;
let error = '';
let allowedRoles = ['pirat', 'deep'];
onMount(async () => {
try {
const res = await apiRequest(`/game/${state.game.id}/player/${state.player.id}/allowed-roles`, 'GET');
allowedRoles = res.allowed_roles;
} catch(e) {
console.error("Failed to load allowed roles", e);
}
});
async function setRole(role) {
settingRole = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-role`, 'POST', { role });
} catch(e) {
console.error(e);
} finally {
settingRole = false;
}
}
async function startScene() {
starting = true;
error = '';
try {
await apiRequest(`/game/${state.game.id}/scene/start`, 'POST');
} catch(e) {
error = e.message;
starting = false;
}
}
$: allRolesAssigned = state.players.every(p => p.role !== null || p.needs_reroll);
$: deepPlayer = state.players.find(p => p.role === 'deep');
$: piratPlayer = state.players.find(p => p.role === 'pirat');
$: enoughPlayers = state.players.length >= 3 || state.game.dev_mode;
$: canStart = enoughPlayers && allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_admin || state.player.role === 'deep');
</script>
<div class="scene-setup-view text-center">
<h2>Scene Setup (Scene #{state.game.current_scene_number})</h2>
<p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p>
{#if state.game.current_scene_number === 1}
<div class="notice-banner accent">
<h4>🚢 First Scene Framing</h4>
<p class="info-text" style="margin: 0; font-size: 0.95rem;">
The first scene must establish that the Pi-Rats are <strong>on a ship they can steal</strong> (or have an immediate opportunity to steal one). <strong>Don't start far from water!</strong>
{#if state.player.role === 'deep'}
<br/><span class="text-accent">You're playing the Deep — once the scene starts, describe where the crew is and what ship is ripe for the taking before calling any Challenges.</span>
{/if}
</p>
</div>
{/if}
<div class="setup-grid single">
<!-- Role Selection Card -->
<div class="card glass-panel role-selection-card">
<h3>Select Your Role</h3>
{#if state.player.needs_reroll}
<div class="notice-banner accent">
<p class="info-text" style="margin: 0;">🐀 You don't have a Pi-Rat yet! You'll spectate this scene and create your recruit with the crew between scenes.</p>
</div>
{/if}
<div class="role-buttons">
<button on:click={() => setRole('pirat')}
disabled={settingRole || !allowedRoles.includes('pirat')}
class="btn btn-large role-btn pirat-btn {state.player.role === 'pirat' ? 'active' : ''}">
🐀 Play Pi-Rat
</button>
<button on:click={() => setRole('deep')}
disabled={settingRole || !allowedRoles.includes('deep')}
class="btn btn-large role-btn deep-btn {state.player.role === 'deep' ? 'active' : ''}">
🌊 Play the Deep
</button>
</div>
<div class="role-restrictions mt-4">
{#if !allowedRoles.includes('pirat') && !state.player.needs_reroll}
{#if state.game.current_scene_number === 1}
<p class="text-danger text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p>
{:else}
<p class="text-danger text-sm italic">You are the only eligible player for The Deep this scene!</p>
{/if}
{/if}
{#if !allowedRoles.includes('deep') && !state.player.needs_reroll}
{#if state.game.current_scene_number === 1}
<p class="text-danger text-sm italic">You start at Rank 1, so you must play your Pi-Rat for the first scene!</p>
{:else}
<p class="text-danger text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
{/if}
{/if}
</div>
</div>
</div>
<div class="action-box margin-top">
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
{#if canStart}
<button on:click={startScene}
disabled={starting}
class="btn btn-primary btn-large glow-effect">
{starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'}
</button>
{:else if !enoughPlayers}
<p class="text-danger italic mt-4">You need at least 3 players to start a scene — share the join link!</p>
{:else if !allRolesAssigned}
<p class="text-muted italic mt-4">Waiting for all players to choose a role...</p>
{:else if !deepPlayer}
<p class="text-danger italic mt-4">Someone must play as The Deep!</p>
{:else if !piratPlayer}
<p class="text-danger italic mt-4">Someone must play as a Pi-Rat!</p>
{:else}
<p class="text-muted italic mt-4">Waiting for The Deep or Creator to start the scene...</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,360 @@
<script>
import { apiRequest } from '../lib/api';
import { displayName } from '../lib/cards';
import Card from './Card.svelte';
export let state;
let voting = false;
let readying = false;
let confirming = false;
let selectedVoteId = '';
// Helpers
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
// Winner of the rank-up vote, surfaced on the post-voting (deep_upkeep) screen.
$: rankUpWinner = state.game.last_rank_up_player_id
? state.players.find(p => p.id === state.game.last_rank_up_player_id)
: null;
// A nominee must be another living, in-play Pi-Rat: not the voter, not a
// previous-scene Deep, and not dead / awaiting-recruit (they'd reset to
// Rank 1 anyway). Mirrors crud_upkeep.is_eligible_nominee.
function eligibleNomineesFor(voter) {
return state.players.filter(p =>
p.id !== voter.id && p.role !== 'deep' && !p.is_dead && !p.needs_reroll
);
}
$: myNominees = eligibleNomineesFor(state.player);
$: hasVoted = !!state.votes.find(v => v.voter_player_id === state.player.id);
// With no eligible crewmates, the voter skips voting entirely (no deadlock).
$: mustVote = myNominees.length > 0;
// Drag & Drop State
let keepCards = [];
let discardCards = [];
let dragNDropInitialized = false;
// Ghost & Re-rolling fate choice
async function becomeGhost() {
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST');
} catch(e) {
console.error(e);
}
}
async function chooseReRoll() {
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/choose-reroll`, 'POST');
} catch(e) {
console.error(e);
}
}
async function finishGame() {
if (!confirm('End the story for everyone? Best saved for when every player has played the Deep, each Pi-Rat has completed a Personal Objective, and all 3 Crew Objectives are done.')) return;
try {
await apiRequest(`/game/${state.game.id}/finish`, 'POST');
} catch(e) {
console.error(e);
}
}
$: {
if (state && state.game.phase === 'deep_upkeep') {
if (!dragNDropInitialized) {
keepCards = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
discardCards = [];
dragNDropInitialized = true;
}
} else {
dragNDropInitialized = false;
}
}
let draggedCardCode = null;
function handleDragStart(e, card) {
draggedCardCode = card;
e.dataTransfer.setData("text/plain", card);
}
function handleDragOver(e) {
e.preventDefault();
}
function handleDropToKeep(e) {
e.preventDefault();
const card = e.dataTransfer.getData("text/plain") || draggedCardCode;
if (card && !keepCards.includes(card)) {
discardCards = discardCards.filter(c => c !== card);
keepCards = [...keepCards, card];
}
}
function handleDropToDiscard(e) {
e.preventDefault();
const card = e.dataTransfer.getData("text/plain") || draggedCardCode;
if (card && !discardCards.includes(card)) {
keepCards = keepCards.filter(c => c !== card);
discardCards = [...discardCards, card];
}
}
function moveToDiscard(card) {
keepCards = keepCards.filter(c => c !== card);
if (!discardCards.includes(card)) {
discardCards = [...discardCards, card];
}
}
function moveToKeep(card) {
discardCards = discardCards.filter(c => c !== card);
if (!keepCards.includes(card)) {
keepCards = [...keepCards, card];
}
}
// Mirrors the backend hand size table: highest rank -> 4, lowest -> 2, middle -> 3, Captain +1.
// Deep players are ranked against all players.
$: maxHandSize = (() => {
const ranks = state.players.map(p => p.rank);
let base;
if (state.player.rank >= Math.max(...ranks)) base = 4;
else if (state.player.rank <= Math.min(...ranks)) base = 2;
else base = 3;
return base + (state.game.captain_player_id === state.player.id ? 1 : 0);
})();
$: isOverLimit = keepCards.length > maxHandSize;
async function submitVote() {
if (!selectedVoteId) return;
voting = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-vote`, 'POST', {
nominated_id: selectedVoteId
});
} catch(e) {
console.error(e);
} finally {
voting = false;
}
}
async function setReady() {
readying = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/ready-next`, 'POST');
} catch(e) {
console.error(e);
} finally {
readying = false;
}
}
async function confirmRefresh() {
confirming = true;
try {
let discards = JSON.stringify(discardCards);
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/confirm-refresh`, 'POST', {
discard_cards: discards
});
} catch(e) {
console.error(e);
} finally {
confirming = false;
}
}
</script>
<div class="between-scenes-view text-center">
<h2>Between Scenes (Scene #{state.game.current_scene_number} Concluded)</h2>
<p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p>
{#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll}
<!-- Death Fate Panel -->
<div class="glass-panel death-fate-card">
<h2>💀 Your Pi-Rat Has Died!</h2>
<p class="description">
Your story arc is complete! You went out in a blaze of glory (or retired honorably). You must now choose how your journey continues:
</p>
<div class="fate-choices">
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}>
👻 Remain as a Ghost
</button>
<button class="btn btn-primary btn-large glow-effect" on:click={chooseReRoll}>
🐀 Roll a New Character
</button>
</div>
<p class="info-text italic" style="margin-top: 1.5rem;">
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh at Rank 1 — after upkeep, the whole crew will help create them.
</p>
</div>
{:else}
{#if state.player.needs_reroll}
<div class="notice-banner" style="margin-bottom: 1rem;">
<p style="margin: 0;">🐀 A fresh recruit is on the way! Once upkeep wraps up, you and the crew will create your new Pi-Rat together.</p>
</div>
{/if}
<div class="between-grid" class:single={state.game.phase !== 'deep_upkeep'}>
<!-- Ranks and Voting Card -->
<div class="card glass-panel voting-card">
<h3>1. Pirate Ranking (Voting)</h3>
<p class="section-desc">Every player (including the Deep) nominates one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
{#if state.game.phase === 'deep_upkeep'}
<div class="voted-confirmation-box glass-panel">
{#if rankUpWinner}
<p class="success-text">🏆 {displayName(rankUpWinner)} won the vote and ranked up to Rank {rankUpWinner.rank}!</p>
{:else}
<p class="success-text">✔️ Voting complete! No one ranked up this time.</p>
{/if}
</div>
{:else if hasVoted}
<div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
</div>
{:else if !mustVote}
<div class="voted-confirmation-box glass-panel">
<p class="info-text">No living crewmates left to nominate — you skip voting this round. Go ahead and ready up.</p>
</div>
{:else}
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
<div class="form-group inline-group">
<select class="select-field" bind:value={selectedVoteId} required>
<option value="">Nominate crewmate...</option>
{#each myNominees as p}
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each}
</select>
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button>
</div>
</form>
{/if}
</div>
<!-- Resting Deep Player Card — only the actual hand refresh, during deep_upkeep -->
{#if state.game.phase === 'deep_upkeep'}
<div class="card glass-panel deep-rest-card">
<h3>2. Resting Deep Upkeep</h3>
{#if state.player.role === "deep"}
<div class="deep-rest-panel glass-panel">
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p>
{#if state.player.is_ready}
<!-- Confirmation: the refresh went through; show the freshly-drawn hand. -->
<div class="voted-confirmation-box glass-panel" style="margin-top: 1rem;">
<p class="success-text">✔️ Hand refreshed! You're holding {hand.length} card{hand.length === 1 ? '' : 's'} for the next scene.</p>
</div>
<div class="upkeep-flex" style="justify-content: center; margin-top: 1rem;">
{#each hand as card}
<Card {card} />
{:else}
<p class="empty-text text-center w-full">No cards in hand.</p>
{/each}
</div>
<p class="info-text text-center" style="margin-top: 1rem;">Waiting for the rest of the crew to finish upkeep...</p>
{:else}
<p class="info-text" style="margin-top: 1rem;">
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
</p>
<div class="upkeep-drag-container">
<!-- KEEP HAND ZONE -->
<div class="upkeep-box"
on:dragover={handleDragOver}
on:drop={handleDropToKeep}>
<h4 class="gold-text">Hand (Keep)</h4>
<div class="upkeep-flex">
{#each keepCards as card}
<Card {card} draggable={true} style="cursor: pointer;"
title="Click or drag to discard"
on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToDiscard(card)} />
{:else}
<p class="empty-text text-center w-full">No cards in hand.</p>
{/each}
</div>
</div>
<!-- DISCARD PILE ZONE -->
<div class="upkeep-box"
on:dragover={handleDragOver}
on:drop={handleDropToDiscard}>
<h4 class="gold-text">Discard Pile</h4>
<div class="upkeep-flex">
{#each discardCards as card}
<Card {card} draggable={true} style="cursor: pointer;"
title="Click or drag to keep"
on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToKeep(card)} />
{:else}
<p class="empty-text text-center w-full">Drag cards here to discard them.</p>
{/each}
</div>
</div>
</div>
{#if isOverLimit}
<div class="notice-banner danger" style="margin: 1rem 0; max-width: none;">
<p class="text-danger font-bold" style="margin: 0;">⚠️ Discard Required: You have selected {keepCards.length} cards to keep, which exceeds your maximum hand limit of {maxHandSize}. Please discard at least {keepCards.length - maxHandSize} card(s).</p>
</div>
{/if}
<button class="btn btn-primary mt-4 w-full" on:click={confirmRefresh} disabled={confirming || isOverLimit}>
Confirm Hand Refresh
</button>
{/if}
</div>
{:else}
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
{/if}
</div>
{/if}
</div>
<!-- Next Scene Ready Up -->
<div class="action-box margin-top">
{#if state.game.phase === 'deep_upkeep'}
{#if state.player.role === 'deep' && !state.player.is_ready}
<p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
{:else}
<div class="waiting-box">
<p>Waiting for resting Deep player to refresh their hand...</p>
<div class="spinner-small"></div>
</div>
{/if}
{:else}
{#if mustVote && !hasVoted}
<p class="info-text gold-text" style="margin-bottom:0.5rem;">⚠️ You must nominate a crewmate above before you can ready up.</p>
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
{:else}
{#if state.player.is_ready}
<div class="waiting-box">
<p>Ready! Waiting for other players to ready up...</p>
<div class="spinner-small"></div>
</div>
{:else}
<button on:click={setReady}
disabled={readying}
class="btn btn-primary btn-large glow-effect">
Ready for Next Scene
</button>
{/if}
{/if}
{/if}
{#if state.player.is_admin && state.game.phase === 'between_scenes'}
<div class="end-story-box">
<p class="info-text" style="font-size: 0.85rem;">All good stories end. When the crew agrees the legend is complete:</p>
<button on:click={finishGame} class="btn btn-danger">🏴‍☠️ End the Story</button>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,225 @@
<script>
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltipHtml, obstacleTable } from '../../lib/cards';
import { tooltip } from '../../lib/tooltip';
import ObstacleItem from './ObstacleItem.svelte';
export let state;
let error = '';
let taxTargetId = '';
// Deep: call-a-challenge form (moved here from the old Deep Control Panel)
let showCreate = false;
let challengeTargetId = '';
let stakesSuccess = '';
let stakesFailure = '';
let selectedObstacles = {};
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
$: scenePirats = state.players.filter(p => p.role === 'pirat');
$: isDeep = state.player.role === 'deep';
function playerName(id) {
return lookupName(state.players, id);
}
function taxType(player) {
if (!player.completed_personal_1) return 'Gat';
if (!player.completed_personal_2) return 'Name';
return null;
}
function eligibleTaxTargets() {
const type = taxType(state.player);
if (!type) return [];
return scenePirats.filter(p =>
p.id !== state.player.id && !p.is_dead &&
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
);
}
async function post(path, data = null) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/${path}`, 'POST', data);
return true;
} catch(e) {
error = e.message;
return false;
}
}
const resolveChallenge = (id) => post(`challenge/${id}/resolve`);
const cancelChallenge = (id) => post(`challenge/${id}/cancel`);
const respondTax = (id, accept) => post(`challenge/${id}/tax/respond`, { accept: accept ? 'true' : 'false' });
const pvpDefend = (id, cardCode) => post(`challenge/${id}/pvp/defend`, { card_code: cardCode });
async function requestTax(challengeId) {
if (await post(`challenge/${challengeId}/tax/request`, { target_player_id: taxTargetId })) {
taxTargetId = '';
}
}
async function createChallenge() {
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
if (await post('challenge/create', {
target_player_id: challengeTargetId,
obstacle_ids: JSON.stringify(ids),
stakes_success: stakesSuccess,
stakes_failure: stakesFailure,
})) {
challengeTargetId = '';
stakesSuccess = '';
stakesFailure = '';
selectedObstacles = {};
showCreate = false;
}
}
async function endScene() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
} catch(e) {
error = e.message;
}
}
</script>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
{#each openChallenges as ch}
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
<div class="challenge-item">
<div class="challenge-head">
<h4>
{#if ch.challenge_type === 'pvp'}
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
{:else}
⚔️ The Deep challenges {playerName(ch.target_player_id)}!
{/if}
</h4>
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
<div class="challenge-actions">
<button class="btn btn-primary" on:click={() => resolveChallenge(ch.id)} disabled={ch.tax_state === 'requested'}>Resolve</button>
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
</div>
{/if}
</div>
{#if ch.stakes_success}
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>✅ On success:</strong> {ch.stakes_success}</p>
{/if}
{#if ch.stakes_failure}
<p class="info-text" style="margin: 0.25rem 0 0 0;"><strong>❌ On failure:</strong> {ch.stakes_failure}</p>
{/if}
{#if ch.stakes && !ch.stakes_success && !ch.stakes_failure}
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
{/if}
{#if ch.challenge_type === 'pvp'}
<p class="info-text" style="margin: 0.5rem 0 0 0;">
Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable, true) }}>{getCardDisplay(ch.temp_card)}</strong>{playerName(ch.acting_player_id)} must answer it!
</p>
{:else}
<div class="challenge-obstacles">
{#each chObstacleIds as oid}
{@const obs = state.obstacles.find(o => o.id === oid)}
{#if obs}
<ObstacleItem {state} {obs} embedded />
{:else}
<p class="info-text" style="margin: 0;">An applied Obstacle was discarded.</p>
{/if}
{/each}
</div>
{/if}
<!-- Tax: pending request aimed at me -->
{#if ch.tax_state === 'requested'}
{#if myTaxRequest && myTaxRequest.id === ch.id}
<div class="tax-callout">
<p><strong>{playerName(ch.target_player_id)}</strong> calls a <strong>{ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax</strong> on you: take this Challenge for them!</p>
<p class="info-text" style="font-size: 0.85rem;">Accept: you get one random card from their hand and attempt the Challenge. Refuse: you hand over your {ch.tax_type === 'gat' ? 'Gat' : 'Name'} — they keep it if they succeed!</p>
<div class="challenge-actions">
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
</div>
</div>
{:else}
<p class="info-text gold-text" style="margin-top: 0.5rem;">⏳ Waiting for {playerName(ch.tax_target_id)} to answer the {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax...</p>
{/if}
{/if}
<!-- Tax: option for the challenged player -->
{#if ch.challenge_type === 'deep' && ch.acting_player_id === state.player.id && !ch.tax_state && !state.player.tax_banned && taxType(state.player) && eligibleTaxTargets().length > 0}
<div class="challenge-actions" style="margin-top: 0.75rem;">
<span class="info-text" style="font-size: 0.9rem; margin: 0;">No {taxType(state.player)}? Make it someone else's problem:</span>
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
<option value="">Pick a crewmate...</option>
{#each eligibleTaxTargets() as p}
<option value={p.id}>{displayName(p)}</option>
{/each}
</select>
<button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button>
</div>
{/if}
<!-- PvP: defender picks a card -->
{#if myPvpDefense && myPvpDefense.id === ch.id}
<div style="margin-top: 0.75rem;">
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
<div class="challenge-actions">
{#each hand.filter(c => !isJoker(c)) as card}
<button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable, true) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
{/each}
</div>
</div>
{/if}
</div>
{/each}
<!-- Deep controls: call a Challenge, and end the scene when none is active -->
<!-- The Deep calls Challenges and ends the scene only when none is active. -->
{#if isDeep && openChallenges.length === 0}
<div class="deep-challenge-controls">
<button class="btn {showCreate ? 'btn-secondary' : 'btn-primary'} btn-full" on:click={() => showCreate = !showCreate}>
{showCreate ? '✕ Cancel' : '⚔️ Call a Challenge'}
</button>
{#if showCreate}
<div class="sheet-group margin-top">
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge which Pi-Rat?</option>
{#each scenePirats.filter(p => !p.is_dead) as p}
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each}
</select>
<div style="margin-bottom: 0.5rem;">
{#each state.obstacles as obs}
{@const taken = challengedObstacleIds.has(obs.id)}
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
</label>
{/each}
</div>
<input type="text" class="input-field" placeholder="On success (optional): what they win…" bind:value={stakesSuccess} style="width: 100%; margin-bottom: 0.5rem;">
<input type="text" class="input-field" placeholder="On failure (optional): what it costs them…" bind:value={stakesFailure} style="width: 100%; margin-bottom: 0.5rem;">
<button class="btn btn-primary btn-full" on:click={createChallenge}
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
Call Challenge
</button>
</div>
{/if}
<div class="scene-upkeep-controls text-center">
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
End Scene & Proceed to Ranking
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,213 @@
<script>
import { createEventDispatcher } from 'svelte';
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName, crewLabel, captainName, cardObstacleInfo, pvpObstacleInfo, obstacleTable } from '../../lib/cards';
export let state;
export let target; // the player whose sheet is being viewed
const dispatch = createEventDispatcher();
function close() { dispatch('close'); }
let error = '';
let pvpCard = '';
$: viewer = state.player;
$: isSelf = target.id === viewer.id;
$: isDeep = viewer.role === 'deep';
$: isCaptain = target.id === state.game.captain_player_id;
// The Deep manages captaincy and ticks personal objectives from a Pi-Rat's sheet
// (both moved here from the old Deep Control Panel).
$: canManageCaptain = isDeep && target.role === 'pirat' && !target.is_dead;
$: canManageObjectives = isDeep && target.role === 'pirat';
// The viewer throws one of their OWN cards in a duel.
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
$: canDuel = !isSelf
&& viewer.role === 'pirat' && !viewer.is_dead && !viewer.needs_reroll
&& target.role === 'pirat' && !target.is_dead;
// What the chosen duel card becomes as a temporary Obstacle for the defender
$: pvpCardObstacle = pvpObstacleInfo(pvpCard);
function getPlayerName(id) {
if (!id) return 'a crewmate';
const p = state.players.find(pl => pl.id === id);
return p ? displayName(p) : 'Unknown';
}
async function createPvp() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/challenge/pvp/create`, 'POST', {
defender_id: target.id,
card_code: pvpCard
});
pvpCard = '';
close();
} catch (e) {
error = e.message;
}
}
async function setCaptain(makeCaptain) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/set-captain`, 'POST', {
target_player_id: makeCaptain ? target.id : ''
});
} catch (e) {
error = e.message;
}
}
async function toggleObjective(type) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${target.id}/objective/toggle`, 'POST', { type });
} catch (e) {
error = e.message;
}
}
</script>
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
<div class="modal-backdrop" on:click|self={close}>
<div class="modal-box sheet-modal character-sheet-modal glass-panel">
<button class="modal-close" on:click={close} aria-label="Close">×</button>
<h3>
{#if target.role === 'deep'}🌊{:else if target.is_ghost}👻{:else if target.is_dead}💀{:else}🐀{/if}
{target.id === state.game.captain_player_id ? captainName(target.name) : target.name}'s Character Sheet
</h3>
{#if target.player_name && target.player_name !== target.name}
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
Played by {target.player_name}{#if !target.completed_personal_2}&nbsp;— they'll go by their smell until they earn a Name!{/if}
</p>
{/if}
<div class="character-sheet-details">
<div class="sheet-main-column">
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
{#if isSelf && state.player.needs_reroll}
<div class="prompt-box accent text-center">
<h4>🐀 Recruit Incoming!</h4>
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You don't have a Pi-Rat in this scene. Sit back, enjoy the chaos, and you'll create your recruit with the crew between scenes.</p>
</div>
{/if}
{#if isSelf && state.player.is_dead}
<div class="prompt-box danger text-center">
<h4>💀 You have Died / Retired!</h4>
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">Your story arc is complete. You will choose to become a Ghost or roll a new character during the upkeep phase between scenes.</p>
</div>
{/if}
{#if isSelf && state.player.is_ghost}
<div class="prompt-box ghostly text-center">
<h4>👻 Playing as a Ghost</h4>
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You are in the Ghost World (grayscale view). You cannot interact well with the living but you can still help them resolve challenges!</p>
</div>
{/if}
{#if isSelf && state.player.tax_banned}
<div class="prompt-box danger text-center">
<p class="info-text" style="margin: 0; font-size: 0.9rem;">🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.</p>
</div>
{/if}
<div class="sheet-group">
<h4>Description:</h4>
<p><strong>Look:</strong> {target.avatar_look}</p>
<p><strong>Smell:</strong> {target.avatar_smell}</p>
<p><strong>First Words:</strong> "{target.first_words}"</p>
{#if target.gat_description}
<p><strong>🔫 Gat:</strong> {target.gat_description}</p>
{/if}
</div>
{#if target.other_like || target.other_hate}
<div class="sheet-group margin-top">
<h4>What the Crew Thinks:</h4>
{#if target.other_like}
<p><strong>👍 Likes:</strong> "{target.other_like}" <span class="text-muted">{getPlayerName(target.other_like_from_player_id)}</span></p>
{/if}
{#if target.other_hate}
<p><strong>👎 Hates:</strong> "{target.other_hate}" <span class="text-muted">{getPlayerName(target.other_hate_from_player_id)}</span></p>
{/if}
</div>
{/if}
<!-- Secret techniques are visible only on your own sheet -->
{#if isSelf}
<div class="sheet-group margin-top">
<h4>Assigned Secret Techniques (J/Q/K):</h4>
<ul class="techniques-list-sheet">
<li><strong>Jack (J):</strong> "{target.tech_jack}"</li>
<li><strong>Queen (Q):</strong> "{target.tech_queen}"</li>
<li><strong>King (K):</strong> "{target.tech_king}"</li>
</ul>
</div>
{/if}
<!-- Duel this Pi-Rat (target is fixed to this sheet) -->
{#if canDuel}
<div class="sheet-group margin-top">
<h4>⚔️ Duel {crewLabel(target, state.game.captain_player_id)}</h4>
<p class="info-text" style="font-size: 0.85rem;">Challenge them (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Throw which card?</option>
{#each hand.filter(c => !isJoker(c)) as card}
<option value={card} title={pvpObstacleInfo(card) ? `${pvpObstacleInfo(card).title} ${pvpObstacleInfo(card).description}` : ''}>{getCardDisplay(card)}</option>
{/each}
</select>
{#if pvpCardObstacle}
<p class="info-text" style="font-size: 0.8rem; margin: -0.25rem 0 0.5rem 0;">
As an Obstacle, {getCardDisplay(pvpCard)} is <strong>{pvpCardObstacle.title}</strong>: {pvpCardObstacle.description}
</p>
{/if}
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpCard}>Throw Down!</button>
</div>
{/if}
{#if canManageCaptain}
<div class="sheet-group margin-top">
<h4>🏴‍☠️ Captaincy</h4>
{#if isCaptain}
<p class="info-text" style="font-size: 0.85rem;">{target.name} holds the Captaincy until they step down, lose a duel, die, or the ship is lost.</p>
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(false)}>Remove as Captain</button>
{:else}
<p class="info-text" style="font-size: 0.85rem;">Hand this Pi-Rat the Captaincy (e.g. after a leadership duel or resignation).</p>
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(true)}>Make Captain</button>
{/if}
</div>
{/if}
</div> <!-- end sheet-main-column -->
<div class="sheet-side-column">
<div class="sheet-group">
<h4>Personal Objectives</h4>
{#if canManageObjectives}
<p class="info-text" style="font-size: 0.8rem;">As the Deep, tick these off in sequence (1 → 2 → 3).</p>
{/if}
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" checked={target.completed_personal_1} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_1')}>
<span>1. Gat</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={target.completed_personal_2} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_2')}>
<span>2. Name</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={target.completed_personal_3} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_3')}>
<span>3. Die/Retire</span>
</label>
</div>
</div>
</div> <!-- end sheet-side-column -->
</div>
</div>
</div>

View File

@@ -0,0 +1,115 @@
<script>
import { slide } from 'svelte/transition';
import { apiRequest } from '../../lib/api';
import { crewLabel } from '../../lib/cards';
import { tooltip } from '../../lib/tooltip';
import CharacterSheet from './CharacterSheet.svelte';
export let state;
let openTargetId = null;
let showObjectives = false;
let objError = '';
$: isDeep = state.player.role === 'deep';
// Track which Pi-Rats have faced a Deep Challenge this scene so the Deep can
// see who still needs one before ending the scene.
$: challengedIds = new Set(
(state.challenges || [])
.filter(c => c.challenge_type === 'deep')
.map(c => c.target_player_id)
);
function hasBeenChallenged(p) {
return p.role === 'pirat' && challengedIds.has(p.id);
}
// The Deep ticks Crew Objectives off here (moved from the old Deep panel).
async function toggleCrew(type) {
objError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/objective/toggle`, 'POST', { type });
} catch (e) {
objError = e.message;
}
}
// Pi-Rats first, the Deep last; otherwise keep join order.
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
$: captainId = state.game.captain_player_id;
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
$: crewDone = [state.game.completed_crew_1, state.game.completed_crew_2, state.game.completed_crew_3].filter(Boolean).length;
function handSize(p) {
try { return JSON.parse(p.hand_cards || '[]').length; } catch { return 0; }
}
function roleIcon(p) {
if (p.role === 'deep') return '🌊';
if (p.is_ghost) return '👻';
if (p.is_dead) return '💀';
return '🐀';
}
</script>
<div class="crew-column">
<div class="crew-bubbles">
{#each crew as p (p.id)}
<button
class="crew-bubble"
class:is-you={p.id === state.player.id}
class:is-deep={p.role === 'deep'}
class:is-dead={p.is_dead}
class:is-ghost={p.is_ghost}
title="View {p.name}'s character sheet"
on:click={() => openTargetId = p.id}
>
{#if isDeep && p.role === 'pirat'}
<span
class="challenge-check"
class:done={hasBeenChallenged(p)}
use:tooltip={hasBeenChallenged(p)
? `${p.name} has faced a Challenge this scene.`
: `${p.name} hasn't faced a Challenge yet — the scene can't end until they do (or the Obstacle List is cleared).`}
>{hasBeenChallenged(p) ? '✓' : '○'}</span>
{/if}
<span class="bubble-icon">{roleIcon(p)}</span>
<span class="bubble-name">{crewLabel(p, captainId)}</span>
<span class="bubble-meta">
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
</span>
</button>
{/each}
<!-- Crew Objectives sits at the end of the roster and slides open in place -->
<button
class="crew-objectives-toggle"
aria-expanded={showObjectives}
on:click={() => showObjectives = !showObjectives}
>
<span class="co-label">{showObjectives ? '▾' : '▸'} Crew Objectives</span>
<span class="co-count">{crewDone}/3</span>
</button>
{#if showObjectives}
<div class="crew-objectives-detail" transition:slide>
{#if objError}<div class="alert alert-danger">{objError}</div>{/if}
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_1} disabled={!isDeep} on:change={() => toggleCrew('crew_1')}>
<span>Steal a Ship</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_2} disabled={!isDeep} on:change={() => toggleCrew('crew_2')}>
<span>Choose a Captain</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_3} disabled={!isDeep} on:change={() => toggleCrew('crew_3')}>
<span>Commit Piracy</span>
</label>
{#if isDeep}<p class="info-text" style="font-size: 0.75rem; margin: 0.25rem 0 0;">As the Deep, tick these off as the crew earns them.</p>{/if}
</div>
{/if}
</div>
</div>
{#if openTarget}
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
{/if}

View File

@@ -0,0 +1,35 @@
<script>
import ObstacleItem from './ObstacleItem.svelte';
export let state;
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
// When a Challenge is active, the involved Obstacles move up into the Challenge
// panel; the rest of the list collapses so attention stays on the Challenge.
$: challengeActive = openChallenges.length > 0;
$: looseObstacles = state.obstacles.filter(o => !challengedObstacleIds.has(o.id));
</script>
{#if challengeActive}
{#if looseObstacles.length}
<details class="obstacle-collapse">
<summary>🌊 {looseObstacles.length} other Obstacle{looseObstacles.length === 1 ? '' : 's'} on the list — show</summary>
<div class="obstacles-container">
{#each looseObstacles as obs (obs.id)}
<ObstacleItem {state} {obs} />
{/each}
</div>
</details>
{:else}
<p class="empty-text">All Obstacles are tied up in the Challenge above.</p>
{/if}
{:else}
<div class="obstacles-container">
{#each state.obstacles as obs (obs.id)}
<ObstacleItem {state} {obs} />
{:else}
<p class="empty-text">No active obstacles. The Deep can end the scene!</p>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,112 @@
<script>
import { apiRequest } from '../../lib/api';
import { isJoker, cardValue } from '../../lib/cards';
import { tooltip } from '../../lib/tooltip';
import Card from '../Card.svelte';
export let state;
export let obs;
export let embedded = false; // rendered inside the Challenge panel — skip the "in challenge" flag
// Plain (text-presentation) suit glyphs so they inherit the title's suit color,
// unlike SUIT_EMOJI which renders as fixed-color emoji.
const SUIT_CHAR = { C: '♣', S: '♠', H: '♥', D: '♦' };
const VALUE_HINT = 'The number to beat. Set by the most recent card played on the column — or the original card '
+ 'until one is played. (Color stays fixed to the original.)';
let error = '';
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: column_cards = JSON.parse(obs.played_cards || '[]');
$: active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card;
$: success_count = column_cards.filter(c => c.success).length;
$: is_completed = success_count >= state.players.length;
$: in_challenge = openChallenges.some(c => JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
$: cardCodeLabel = `${cardValue(obs.original_card)}${SUIT_CHAR[obs.suit] || ''}`;
$: is_face_active = ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1));
// A face-card Obstacle is worth the challenged Rat's Rank. When this Obstacle is
// part of an open Challenge we know who that is — return their rank, else null.
$: challengedRank = (() => {
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
if (!ch) return null;
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
})();
async function post(path, data) {
error = '';
try {
await apiRequest(path, 'POST', data);
} catch (e) {
error = e.message;
}
}
const playCard = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-card`, { obstacle_id: obs.id, card_code: cardCode });
const playJoker = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-joker`, { obstacle_id: obs.id, card_code: cardCode });
const clearObstacle = () => post(`/game/${state.game.id}/scene/obstacle/${obs.id}/clear`);
</script>
<div class="obstacle-item suit-{obs.suit.toLowerCase()}"
class:completed={is_completed}
class:in-challenge={in_challenge && !embedded}
data-obstacle-id={obs.id}
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
on:drop={(e) => {
if (is_completed) return;
e.preventDefault();
e.currentTarget.classList.remove("drag-over");
const cardCode = e.dataTransfer.getData('text/plain');
if (cardCode) {
if (isJoker(cardCode)) playJoker(cardCode);
else playCard(cardCode);
}
}}>
{#if error}
<div class="alert alert-danger" style="grid-column: 1 / -1;">{error}</div>
{/if}
<!-- Card column: original at the back, each play stacked in front of it, the
latest fully visible and earlier cards peeking their rank + suit. -->
<div class="obstacle-stack">
<div class="stack-card is-original" use:tooltip={'Original card — fixes the Obstacles color and never changes.'}>
<Card card={obs.original_card} size="medium" />
</div>
{#each column_cards as pc, i (i)}
<div class="stack-card {pc.success ? 'is-success' : 'is-failure'}"
class:is-latest={i === column_cards.length - 1}
use:tooltip={`${pc.player_name} played this — ${pc.success ? 'success' : 'no success'}.`}>
<Card card={pc.card} size="medium" />
</div>
{/each}
</div>
<div class="obstacle-details">
<h4><span class="obs-card-code">{cardCodeLabel}</span>{obs.title} {#if in_challenge && !embedded}<span class="in-challenge-tag">⚔️ In Challenge</span>{/if}</h4>
<p class="desc">{obs.description}</p>
</div>
<!-- Value Display -->
<div class="obstacle-value-display text-center" use:tooltip={VALUE_HINT}>
<span class="val-label">Current Difficulty</span>
<span class="val-number">
{#if is_face_active}
{challengedRank !== null ? `${challengedRank} (Rat's Rank)` : "Challenged Rat's Rank"}
{:else}
{obs.current_value}
{/if}
</span>
</div>
<!-- Successes Tracker -->
<div class="obstacle-successes-display text-center">
<span class="val-label">Successes</span>
<span class="val-number">{success_count} / {state.players.length}</span>
</div>
{#if is_completed && state.player.role === 'deep'}
<div class="clear-obstacle-row text-center">
<button class="btn btn-success btn-full" on:click={clearObstacle}>Clear Obstacle</button>
</div>
{/if}
</div>

25
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,25 @@
export async function apiRequest(endpoint, method = 'GET', data = null) {
const options = {
method,
headers: {},
};
if (data) {
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
options.body = new URLSearchParams(data).toString();
}
const res = await fetch(`/api${endpoint}`, options);
if (!res.ok) {
let msg = res.statusText;
try {
const errBody = await res.json();
if (errBody.error) msg = errBody.error;
else if (errBody.detail) msg = errBody.detail;
} catch(e) {}
const error = new Error(msg);
error.status = res.status;
throw error;
}
return res.json();
}

165
frontend/src/lib/cards.js Normal file
View File

@@ -0,0 +1,165 @@
// Card-code helpers shared across components.
// Codes look like "10D", "KH", "Joker1" (black) / "Joker2" (red).
import { writable } from 'svelte/store';
import { apiRequest } from './api';
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
const SUIT_THEMES = {
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
H: "♥ Hearts — Shoutin' and Singin': social skills, performance, or charisma",
};
// The rulebook's obstacle tables (what each card represents when it surfaces
// as an Obstacle), served by the backend so there's a single source of truth.
// Loaded once at startup; until it arrives tooltips fall back to suit themes.
let OBSTACLE_TABLE = null;
export const obstacleTable = writable(null);
export async function loadObstacleTable(retries = 5) {
// Static rulebook data; retry on transient failures so a single blip on
// first load doesn't silently disable obstacle tooltips until a refresh.
while (!OBSTACLE_TABLE && retries-- > 0) {
try {
OBSTACLE_TABLE = (await apiRequest('/obstacles')).obstacles;
obstacleTable.set(OBSTACLE_TABLE);
} catch (e) {
if (retries <= 0) {
console.error('Failed to load obstacle table', e);
} else {
await new Promise(r => setTimeout(r, 1000));
}
}
}
return OBSTACLE_TABLE;
}
// What a card represents when it surfaces as an Obstacle, or null if unknown
// (Jokers / table not loaded). Pass the obstacleTable store value as `table`.
export function cardObstacleInfo(code, table = OBSTACLE_TABLE) {
if (!code || isJoker(code)) return null;
return table?.[cardSuit(code)]?.[cardValue(code)] || null;
}
// Default tooltip for a card: its suit theme when played, plus what it
// represents as an Obstacle (relevant when challenging another Pi-Rat).
// Pass the obstacleTable store's value as `table` to recompute reactively.
// Plain-string form, kept for aria-label / native fallbacks.
export function cardTooltip(code, table = OBSTACLE_TABLE) {
if (!code) return '';
if (isJoker(code)) {
return '🃏 Joker — played: discards an Obstacle (and its column) outright and draws a replacement.\n'
+ 'As an Obstacle: discarded, but future scenes permanently require one more Obstacle.';
}
const theme = SUIT_THEMES[cardSuit(code)] || '';
const obstacle = table?.[cardSuit(code)]?.[cardValue(code)];
if (!obstacle) return theme;
return `${theme}\nAs an Obstacle: ${obstacle.title}${obstacle.description}`;
}
const FACE_VALUES = ['J', 'Q', 'K'];
function esc(s) {
return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
// Rich (HTML) tooltip describing a card — what it does when *played*, and what
// it means when it surfaces as an Obstacle. Built only from static rulebook
// data (SUIT_THEMES + the obstacle table), so the HTML is trusted. Feed the
// result to the `tooltip` action as `{ html: cardTooltipHtml(...) }`.
export function cardTooltipHtml(code, table = OBSTACLE_TABLE, isPvp = false) {
if (!code) return '';
if (isJoker(code)) {
return '<div class="tt-head"><span class="tt-card tt-joker">🃏 Joker</span></div>'
+ '<div class="tt-row"><span class="tt-label">Played</span>Discard one Obstacle (and its column) outright, then draw a replacement.</div>'
+ '<div class="tt-row"><span class="tt-label">As an Obstacle</span>Discarded — but every following scene needs one more Obstacle.</div>';
}
const suit = cardSuit(code);
const val = cardValue(code);
const isRed = suit === 'H' || suit === 'D';
const colorClass = isRed ? 'tt-red' : 'tt-black';
let html = `<div class="tt-head"><span class="tt-card ${colorClass}">${esc(val)}${SUIT_EMOJI[suit] || esc(suit)}</span>`
+ `<span class="tt-color tt-color-${isRed ? 'red' : 'black'}">${isRed ? 'Red' : 'Black'}</span></div>`;
html += `<div class="tt-theme">${esc(SUIT_THEMES[suit] || '')}</div>`;
if (isPvp) {
const themeStr = SUIT_THEMES[suit] || '';
const splitIdx = themeStr.indexOf(': ');
const title = splitIdx !== -1 ? themeStr.substring(0, splitIdx) : themeStr;
const desc = splitIdx !== -1 ? themeStr.substring(splitIdx + 2) : '';
html += `<div class="tt-row"><span class="tt-label">PvP Obstacle</span><b>${esc(title)}</b> — ${esc(desc)}</div>`;
} else if (FACE_VALUES.includes(val)) {
html += '<div class="tt-row"><span class="tt-label">Played by a Pi-Rat</span>Triggers a Secret Technique — automatic success.</div>';
html += "<div class=\"tt-row\"><span class=\"tt-label\">As an Obstacle</span>Its value equals the challenged Pi-Rat's Rank.</div>";
} else {
const obstacle = table?.[suit]?.[val];
if (obstacle) {
html += `<div class="tt-row"><span class="tt-label">As an Obstacle</span><b>${esc(obstacle.title)}</b> — ${esc(obstacle.description)}</div>`;
}
}
return html;
}
export function pvpObstacleInfo(code) {
if (!code || isJoker(code)) return null;
const suit = cardSuit(code);
const themeStr = SUIT_THEMES[suit];
if (!themeStr) return null;
const splitIdx = themeStr.indexOf(': ');
if (splitIdx === -1) return { title: themeStr, description: '' };
return {
title: themeStr.substring(0, splitIdx),
description: themeStr.substring(splitIdx + 2)
};
}
export function isJoker(code) {
return !!code && code.startsWith('Joker');
}
// "10D" -> "10"
export function cardValue(code) {
return code.slice(0, -1);
}
// "10D" -> "D"
export function cardSuit(code) {
return code.slice(-1);
}
export function getCardDisplay(code) {
if (!code) return '';
if (isJoker(code)) return '🃏 JOKER';
return `${cardValue(code)}${SUIT_EMOJI[cardSuit(code)] || cardSuit(code)}`;
}
// "Recruit Cheese (Tim)" — the Pi-Rat's identity with the human player's lobby
// name in parentheses (omitted while the two are still identical, e.g. in the lobby).
export function displayName(p) {
if (!p) return '???';
if (p.player_name && p.player_name !== p.name) return `${p.name} (${p.player_name})`;
return p.name;
}
export function playerName(players, id) {
return displayName(players.find(p => p.id === id));
}
// The Pi-Rat's name with the "Recruit" honorific swapped for "Captain" (or
// "Captain" prepended once they've earned a real Name). Used to mark the Captain
// in the scene roster without a separate icon.
export function captainName(name) {
if (!name) return 'Captain';
if (name.startsWith('Recruit ')) return 'Captain ' + name.slice('Recruit '.length);
return 'Captain ' + name;
}
// displayName, but the Captain is shown as "Captain <name> (player)".
export function crewLabel(p, captainId) {
if (!p) return '???';
const rat = p.id === captainId ? captainName(p.name) : p.name;
if (p.player_name && p.player_name !== p.name) return `${rat} (${p.player_name})`;
return rat;
}

View File

@@ -0,0 +1,160 @@
// App version and player-facing changelog, surfaced in the ☰ menu → About
// (rendered by AboutModal.svelte).
//
// Maintenance (see AGENTS.md "Versioning & changelog"):
// - Bump VERSION by one on every commit.
// - Add a CHANGELOG entry only when a commit changes something players can
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
export const VERSION = 25;
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
export const CHANGELOG = [
{
version: 25,
date: '2026-07-10',
changes: [
'The Event Log now uses the same pinned column and fixed close/reopen control in every game phase.',
],
},
{
version: 24,
date: '2026-07-10',
changes: [
'The crew roster now stays in the same left-hand column throughout every phase of the game.',
],
},
{
version: 23,
date: '2026-07-10',
changes: [
'Admins can now click the join code to copy it, and the code is available directly from the lobby.',
],
},
{
version: 21,
date: '2026-07-10',
changes: [
'Player lists now use the same easy-to-scan vertical layout in every phase and at every screen size.',
],
},
{
version: 20,
date: '2026-07-10',
changes: [
'The Admin Panel now gives the games join code a large, easy-to-share display.',
],
},
{
version: 19,
date: '2026-07-10',
changes: [
'Games now have short join codes that can be entered on the home page and shared from the Admin Panel.',
],
},
{
version: 18,
date: '2026-07-10',
changes: [
'Expired rejoin entries are now removed automatically when a game has ended or a player is no longer in the crew, with a clear explanation.',
],
},
{
version: 17,
date: '2026-07-10',
changes: [
'Join forms now remember your player name for the next crew while still letting you edit it.',
],
},
{
version: 16,
date: '2026-07-10',
changes: [
'Open and Copy controls in the Admin Panel now have matching sizes.',
],
},
{
version: 15,
date: '2026-07-10',
changes: [
'The Admin Panel now clearly marks Pi-Rats whose character identity has not been chosen yet.',
],
},
{
version: 14,
date: '2026-07-10',
changes: [
'The corner Event Log button now stays put when the log is open, so it can be closed again without moving your mouse.',
],
},
{
version: 13,
date: '2026-07-10',
changes: [
'Character sheets now make better use of the available space on wide screens.',
],
},
{
version: 8,
date: '2026-06-15',
changes: [
'Crew roster bubbles are now legible and stand out from the background in both light and dark mode.',
'A scene can no longer be ended until every Pi-Rat has faced a Challenge (or the Obstacle List is empty). The Deep sees a ✓/○ marker on each Pi-Rat showing who still needs one.',
'Decluttered your Hand panel — dropped the how-to-play blurb for a low-profile label.',
],
},
{
version: 7,
date: '2026-06-15',
changes: [
'During a scene, the Event Log can be collapsed to a corner button to declutter your screen, and reopened from that button.',
'In the three-column layout, the Event Log now stays pinned within the screen and scrolls internally, so its newest entries are always visible.',
],
},
{
version: 6,
date: '2026-06-15',
changes: [
'PvP challenge cards now display the general suit theme instead of specific obstacles in tooltips.',
'Secret Technique tooltips now show the full technique description when played.',
'Personal Objectives on the Character Sheet are now positioned in a side column on wider screens.'
],
},
{
version: 5,
date: '2026-06-15',
changes: [
'Completing a Crew Objective is now announced in the Event Log.',
'Fixed a UI quirk where mousing over personal objectives incorrectly showed an interactive pointer for Pi-Rats who cannot change them.',
],
},
{
version: 4,
date: '2026-06-15',
changes: [
'Tidied the Challenge panel: dropped the redundant “Plays” list and the how-to-play blurb. Each Obstacle now leads with its card (e.g. “K♦”) in the title, colored by suit.',
],
},
{
version: 3,
date: '2026-06-15',
changes: [
'The rulebooks Example of Play now has card diagrams at each step, showing how each Obstacles column grows as the scene unfolds.',
],
},
{
version: 2,
date: '2026-06-15',
changes: [
'Obstacle cards now stack as a single overlapping column — the latest play sits in full view with earlier cards peeking out behind it, matching the rulebooks card layout.',
],
},
{
version: 1,
date: '2026-06-15',
changes: [
'First tracked version — the app now reports its version here in the ☰ menu → About, with a log of player-facing changes.',
'Home screen lists crews you can rejoin above “Muster a New Crew”.',
],
},
];

6
frontend/src/lib/menu.js Normal file
View File

@@ -0,0 +1,6 @@
import { writable } from 'svelte/store';
// Extra entries for the corner menu, set by pages that have context-specific
// links (e.g. Dashboard adds the creator-only Admin link). Each entry is
// { label, href, external?, title? }.
export const extraMenuLinks = writable([]);

View File

@@ -0,0 +1,46 @@
// Tracks the games this browser has joined so the home page can offer a
// "rejoin" menu. Stored in localStorage only — deleting an entry never touches
// the backend. Each entry is keyed by gameId + playerId:
// { gameId, playerId, crewName, playerName, ratName, updatedAt }
const KEY = 'pirats-sessions';
function load() {
try {
const parsed = JSON.parse(localStorage.getItem(KEY));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function persist(list) {
localStorage.setItem(KEY, JSON.stringify(list));
}
function sameSession(a, gameId, playerId) {
return a.gameId === gameId && a.playerId === playerId;
}
// Most-recently-updated first.
export function getSessions() {
return load().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
}
// Insert or merge a session. Only defined fields overwrite existing values, so
// a sparse update (e.g. just a playerName at join time) won't clobber names
// filled in later from the full game state.
export function saveSession(fields) {
const { gameId, playerId } = fields;
if (!gameId || !playerId) return;
const list = load();
const idx = list.findIndex(s => sameSession(s, gameId, playerId));
const incoming = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && v !== null));
const merged = { ...(idx >= 0 ? list[idx] : {}), ...incoming, updatedAt: Date.now() };
if (idx >= 0) list[idx] = merged;
else list.push(merged);
persist(list);
}
export function removeSession(gameId, playerId) {
persist(load().filter(s => !sameSession(s, gameId, playerId)));
}

View File

@@ -1,3 +1,5 @@
import { apiRequest } from './api';
// Auto-generated math-pirate suggestions pool for character creation // Auto-generated math-pirate suggestions pool for character creation
const SUGGESTIONS = { const SUGGESTIONS = {
"look": [ "look": [
@@ -1211,213 +1213,32 @@ const SUGGESTIONS = {
"They stubbornly argues about the slide rule and has the abacus tattoos to prove it.", "They stubbornly argues about the slide rule and has the abacus tattoos to prove it.",
"They accidently misplaces the dry gunpowder bags and has a certificate from the Stowaway Academy.", "They accidently misplaces the dry gunpowder bags and has a certificate from the Stowaway Academy.",
"They frequently drops the slide rule but gets confused by negative numbers." "They frequently drops the slide rule but gets confused by negative numbers."
],
"techniques": [
"Geometric Drift of the Cheese Reef",
"Parabolic boarding leap",
"Fibonacci Drift of the Stormy Sea",
"Geometric Leap of Euler",
"Trigonometric Flurry of the Abacus",
"Prime Number Slash of the Gat",
"Logarithmic Dodge of Carlos",
"Parabolic Flurry of the Gat",
"Logarithmic Slash of Euler",
"Logarithmic Drift of the Gat",
"Hypotenuse Shield of the Coordinate Plane",
"Algebraic Parry of the Stormy Sea",
"Hypotenuse Parry of the Deep",
"Hypotenuse Drift of Shrapnel",
"Binary Vortex of Gauss",
"Geometric Leap of the Deep",
"Trigonometric Drift of Shrapnel",
"Algebraic Slam of the Abacus",
"Hypotenuse Slash of Euler",
"Geometric Vortex of Euler",
"Hypotenuse Leap of the Gat",
"Trigonometric Slam of the Deep",
"Parabolic Shield of the Deep",
"Geometric Slash of the Gat",
"Geometric Slash of Gauss",
"Binary Barrage of the Gat",
"Fibonacci Vortex of Carlos",
"Geometric Slam of the Coordinate Plane",
"Prime Number Slash of the Deep",
"Prime Number Flurry of the Abacus",
"Logarithmic Flurry of Shrapnel",
"Vector Leap of Euler",
"Binary Drift of the Cheese Reef",
"Algebraic Leap of Shrapnel",
"Fibonacci Dodge of the Abacus",
"Vector Leap of the Coordinate Plane",
"Algebraic Leap of Gauss",
"Prime Number Leap of Euler",
"Trigonometric Dodge of the Cheese Reef",
"Fibonacci Barrage of Carlos",
"Geometric Parry of the Gat",
"Binary Shield of the Gat",
"Fibonacci Slash of Euler",
"Trigonometric Flurry of the Coordinate Plane",
"Cheese smell smoke screen",
"Parabolic Barrage of Shrapnel",
"Geometric Dodge of Gauss",
"Trigonometric Drift of the Deep",
"Algebraic Parry of Shrapnel",
"Vector Dodge of the Abacus",
"Algebraic Vortex of Carlos",
"Binary Flurry of the Abacus",
"Algebraic Slam of the Coordinate Plane",
"Algebraic Slam of the Deep",
"Logarithmic Barrage of the Deep",
"Binary Drift of Carlos",
"Logarithmic Flurry of the Abacus",
"Logarithmic Slam of Carlos",
"Parabolic Slash of the Stormy Sea",
"Parabolic Barrage of Carlos",
"Logarithmic Dodge of the Abacus",
"Fibonacci Slam of Gauss",
"Geometric Shield of the Coordinate Plane",
"Trigonometric Barrage of the Abacus",
"Algebraic Flurry of the Coordinate Plane",
"Hypotenuse Barrage of the Gat",
"Vector Vortex of the Gat",
"Geometric Parry of the Cheese Reef",
"Vector Drift of Euler",
"Vector Slash of the Stormy Sea",
"Binary Shield of Euler",
"Hypotenuse Parry of the Abacus",
"Trigonometric Drift of Euler",
"Fibonacci Parry of the Gat",
"Vector Parry of Gauss",
"Hypotenuse Shield of the Deep",
"Hypotenuse Slam of the Gat",
"Trigonometric Dodge of the Gat",
"Fibonacci Barrage of the Deep",
"Vector Dodge of Carlos",
"Algebraic Slam of the Stormy Sea",
"Prime Number Dodge of Shrapnel",
"Vector Slash of the Gat",
"Hypotenuse Flurry of the Gat",
"Fibonacci Flurry of the Gat",
"Logarithmic Leap of the Abacus",
"Fibonacci Slash of Gauss",
"Hypotenuse Parry of Shrapnel",
"Trigonometric Barrage of Carlos",
"Hypotenuse Parry of the Coordinate Plane",
"Trigonometric Shield of the Gat",
"Prime Number Parry of Euler",
"Vector Barrage of Shrapnel",
"Vector Slam of the Gat",
"Vector Flurry of the Abacus",
"Binary Drift of Gauss",
"Prime Number Slam of Euler",
"Prime Number Leap of Carlos",
"Algebraic Barrage of the Stormy Sea",
"Geometric Barrage of the Gat",
"Logarithmic Leap of the Deep",
"Logarithmic Barrage of the Gat",
"Angle of attack swipe",
"Trigonometric Parry of the Cheese Reef",
"Fibonacci Slash of the Coordinate Plane",
"Hypotenuse Shield of the Gat",
"Vector Shield of Gauss",
"Trigonometric Shield of Gauss",
"Vector Slash of the Abacus",
"Binary Shield of the Cheese Reef",
"Parabolic Parry of the Deep",
"Geometric Leap of Gauss",
"Logarithmic Shield of the Deep",
"Logarithmic Drift of Shrapnel",
"Trigonometric Flurry of Carlos",
"Logarithmic Slash of the Cheese Reef",
"Fibonacci Slam of the Abacus",
"Vector Parry of the Gat",
"Geometric Shield of the Deep",
"Logarithmic Flurry of Gauss",
"Hypotenuse Barrage of Euler",
"Hypotenuse Vortex of the Cheese Reef",
"Hypotenuse Slash of Gauss",
"Vector Parry of the Stormy Sea",
"Logarithmic Slam of the Stormy Sea",
"Hypotenuse Dodge of the Abacus",
"Geometric Leap of the Stormy Sea",
"Prime Number Shield of the Coordinate Plane",
"Binary Barrage of Shrapnel",
"Hypotenuse Shield of Euler",
"Geometric Drift of the Abacus",
"Prime Number Drift of Shrapnel",
"Logarithmic Vortex of the Abacus",
"Vector Slam of the Abacus",
"Hypotenuse Flurry of the Abacus",
"Trigonometric Slam of Euler",
"Parabolic Slam of Euler",
"Vector Shield of Carlos",
"Trigonometric Dodge of the Deep",
"Fibonacci Vortex of the Abacus",
"Geometric Shield of the Cheese Reef",
"Algebraic Drift of Gauss",
"Trigonometric Slash of the Cheese Reef",
"Parabolic Shield of Shrapnel",
"Fibonacci Shield of Shrapnel",
"Hypotenuse Dodge of Shrapnel",
"Vector Slash of the Coordinate Plane",
"Binary Vortex of the Cheese Reef",
"Geometric Slash of Shrapnel",
"Fibonacci Slash of the Cheese Reef",
"Logarithmic Vortex of the Gat",
"Algebraic Flurry of Euler",
"Logarithmic Barrage of the Abacus",
"Logarithmic Slash of the Stormy Sea",
"Vector Drift of the Gat",
"Trigonometric Shield of Euler",
"Trigonometric Shield of the Deep",
"Trigonometric Leap of Gauss",
"Prime Number Shield of the Deep",
"Algebraic Barrage of the Coordinate Plane",
"Vector Flurry of the Gat",
"Hypotenuse Slam of the Coordinate Plane",
"Geometric Vortex of the Gat",
"Binary Slam of the Gat",
"Binary Slam of the Cheese Reef",
"Parabolic Drift of Shrapnel",
"Prime Number Drift of the Stormy Sea",
"Logarithmic Parry of the Cheese Reef",
"Parabolic Leap of the Abacus",
"Binary Dodge of Shrapnel",
"Parabolic Barrage of the Abacus",
"Parabolic Vortex of the Coordinate Plane",
"Prime Number Dodge of Carlos",
"Fibonacci Barrage of the Abacus",
"Vector Barrage of the Coordinate Plane",
"Prime Number Drift of the Abacus",
"Algebraic Slash of the Gat",
"Binary Drift of the Coordinate Plane",
"Hypotenuse Flurry of the Stormy Sea",
"Trigonometric Slam of the Stormy Sea",
"Hypotenuse Vortex of Euler",
"Fibonacci Leap of the Cheese Reef",
"Fibonacci Parry of the Abacus",
"Trigonometric Slash of the Coordinate Plane",
"Parabolic Shield of Carlos",
"Trigonometric Leap of the Coordinate Plane",
"Trigonometric Leap of Carlos",
"Vector Parry of Euler",
"Hypotenuse Barrage of the Coordinate Plane",
"Binary Parry of Euler",
"Geometric Flurry of Euler",
"Parabolic Dodge of the Cheese Reef",
"Geometric Leap of the Abacus",
"Binary Leap of the Gat",
"Binary Barrage of the Deep",
"Fibonacci Shield of the Coordinate Plane",
"Fibonacci Drift of the Deep",
"Trigonometric Shield of Shrapnel",
"Parabolic Slash of Euler",
"Hypotenuse Parry of the Gat"
] ]
}; };
function getSuggestion(type) { function pickFrom(arr, exclude = []) {
const taken = new Set(exclude.map(s => s.trim().toLowerCase()));
const available = arr.filter(s => !taken.has(s.trim().toLowerCase()));
const pool = available.length > 0 ? available : arr;
return pool[Math.floor(Math.random() * pool.length)];
}
export function getSuggestion(type, exclude = []) {
const arr = SUGGESTIONS[type]; const arr = SUGGESTIONS[type];
if (!arr) return ""; if (!arr) return "";
return arr[Math.floor(Math.random() * arr.length)]; return pickFrom(arr, exclude);
}
// Secret Pirate Technique names live in the backend (it also uses them to equip
// re-rolled recruits); fetch the pool once and pick from it client-side.
let techniquePoolPromise = null;
function loadTechniquePool() {
if (!techniquePoolPromise) {
techniquePoolPromise = apiRequest('/suggestions/techniques').then(d => d.techniques);
}
return techniquePoolPromise;
}
export async function getTechniqueSuggestion(exclude = []) {
return pickFrom(await loadTechniquePool(), exclude);
} }

23
frontend/src/lib/theme.js Normal file
View File

@@ -0,0 +1,23 @@
import { writable } from 'svelte/store';
// Light/dark theme preference, shared across games via localStorage (the
// rulebook page reads the same key). index.html applies the saved theme
// before first paint; this store keeps the <html> attribute and storage in
// sync when the user toggles from the corner menu.
const STORAGE_KEY = 'pirats-theme';
function savedTheme() {
const value = localStorage.getItem(STORAGE_KEY);
return value === 'dark' ? 'dark' : 'light';
}
export const theme = writable(savedTheme());
theme.subscribe((value) => {
document.documentElement.dataset.theme = value;
localStorage.setItem(STORAGE_KEY, value);
});
export function toggleTheme() {
theme.update((value) => (value === 'dark' ? 'light' : 'dark'));
}

View File

@@ -0,0 +1,9 @@
// Sets document.title to a " · "-joined set of page specifics followed by the
// app name, so tabs and browser history entries are easy to tell apart.
// Falsy parts are dropped; with no parts the title is just the app name.
const BASE = 'Rats with Gats';
export function setPageTitle(...parts) {
const prefix = parts.filter(Boolean).join(' · ');
document.title = prefix ? `${prefix}${BASE}` : BASE;
}

View File

@@ -0,0 +1,84 @@
// A styled hover/focus tooltip that replaces the native `title` attribute.
//
// use:tooltip={"plain text"} -> rendered as text
// use:tooltip={{ html: "<b>rich</b>" }} -> rendered as HTML (trusted, static only)
//
// Only ever pass trusted, static HTML (e.g. built from the rulebook tables in
// lib/cards.js); never interpolate raw player input into the `html` form.
//
// The floating element is appended to <body> and positioned with fixed
// coordinates so it escapes any overflow:hidden / transformed ancestors
// (cards, modals, scrolling columns).
function contentEmpty(content) {
if (!content) return true;
if (typeof content === 'object') return !content.html;
return String(content).trim() === '';
}
export function tooltip(node, content) {
let current = content;
let tip = null;
function show() {
if (tip || contentEmpty(current)) return;
tip = document.createElement('div');
tip.className = 'tooltip-pop';
tip.setAttribute('role', 'tooltip');
if (typeof current === 'object' && current.html != null) {
tip.innerHTML = current.html;
} else {
tip.textContent = String(current);
}
document.body.appendChild(tip);
position();
requestAnimationFrame(() => tip && tip.classList.add('visible'));
}
function position() {
if (!tip) return;
const r = node.getBoundingClientRect();
const t = tip.getBoundingClientRect();
const margin = 8;
// Prefer above the target; flip below when there isn't room.
let top = r.top - t.height - margin;
if (top < margin) top = r.bottom + margin;
if (top + t.height > window.innerHeight - margin) {
top = Math.max(margin, window.innerHeight - t.height - margin);
}
let left = r.left + r.width / 2 - t.width / 2;
left = Math.max(margin, Math.min(left, window.innerWidth - t.width - margin));
tip.style.top = `${top}px`;
tip.style.left = `${left}px`;
}
function hide() {
if (tip) {
tip.remove();
tip = null;
}
}
node.addEventListener('mouseenter', show);
node.addEventListener('mouseleave', hide);
node.addEventListener('focus', show);
node.addEventListener('blur', hide);
return {
update(newContent) {
current = newContent;
if (tip) {
// Refresh the content of an already-visible tip in place.
hide();
show();
}
},
destroy() {
hide();
node.removeEventListener('mouseenter', show);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('focus', show);
node.removeEventListener('blur', hide);
},
};
}

22
frontend/src/main.js Normal file
View File

@@ -0,0 +1,22 @@
import { mount } from 'svelte'
// Self-hosted fonts (bundled by Vite — no external font CDN at runtime)
import '@fontsource/pirata-one'
import '@fontsource/alegreya-sc/500.css'
import '@fontsource/alegreya-sc/700.css'
import '@fontsource/alegreya-sans/400.css'
import '@fontsource/alegreya-sans/500.css'
import '@fontsource/alegreya-sans/700.css'
import './app.css'
import App from './App.svelte'
import { loadObstacleTable } from './lib/cards'
// Warm the obstacle-table cache so card tooltips can describe cards as Obstacles
loadObstacleTable()
const app = mount(App, {
target: document.getElementById('app'),
})
export default app

View File

@@ -0,0 +1,234 @@
<script>
import { onMount } from 'svelte';
import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title';
import { getSessions } from '../lib/sessions';
export let params = {};
let gameId = params.id;
let adminKey = '';
// Return to this browser's own Pi-Rat dashboard for the game (the most
// recently used session), not the public join screen. Falls back to join
// if this browser has no saved session for the game.
$: mySession = getSessions().find(s => s.gameId === gameId);
$: backLink = mySession
? `/#/game/${gameId}/player/${mySession.playerId}`
: `/#/game/${gameId}/join`;
$: backLabel = mySession ? '← Back to Game' : 'Back to Game Join Page';
let data = null;
let error = '';
let copiedPlayerId = null;
let copiedTimer = null;
let copiedInvite = false;
let copiedInviteTimer = null;
let copiedJoinCode = false;
let copiedJoinCodeTimer = null;
$: setPageTitle('Admin', data?.game?.crew_name);
function rejoinLink(playerId) {
return `${window.location.origin}/#/game/${gameId}/player/${playerId}`;
}
function inviteLink() {
return `${window.location.origin}/#/game/${gameId}/join`;
}
function copyRejoinLink(playerId) {
navigator.clipboard.writeText(rejoinLink(playerId));
copiedPlayerId = playerId;
clearTimeout(copiedTimer);
copiedTimer = setTimeout(() => { copiedPlayerId = null; }, 2000);
}
function copyInviteLink() {
navigator.clipboard.writeText(inviteLink());
copiedInvite = true;
clearTimeout(copiedInviteTimer);
copiedInviteTimer = setTimeout(() => { copiedInvite = false; }, 2000);
}
function copyJoinCode() {
navigator.clipboard.writeText(data.game.join_code);
copiedJoinCode = true;
clearTimeout(copiedJoinCodeTimer);
copiedJoinCodeTimer = setTimeout(() => { copiedJoinCode = false; }, 2000);
}
onMount(() => {
// Try to load admin_key from local storage
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
if (adminKey) {
loadAdminData();
}
});
async function loadAdminData() {
if (!adminKey) return;
try {
data = await apiRequest(`/game/${gameId}/admin?key=${encodeURIComponent(adminKey)}`);
error = '';
} catch (err) {
error = err.message;
data = null;
}
}
async function setAdmin(playerId, makeAdmin) {
try {
await apiRequest(`/game/${gameId}/admin/set-admin`, 'POST', {
key: adminKey,
target_player_id: playerId,
is_admin: makeAdmin,
});
await loadAdminData();
} catch (err) {
error = err.message;
}
}
// The last remaining Admin can't be kicked, so the table is never locked out.
$: adminCount = data ? data.players.filter((p) => p.is_admin).length : 0;
function canKick(p) {
return !(p.is_admin && adminCount <= 1);
}
async function kickPlayer(p) {
const who = p.player_name || p.name;
if (!confirm(`Kick ${who} from the game? Their cards go to the discard and they'll have to rejoin. This can't be undone.`)) return;
try {
await apiRequest(`/game/${gameId}/admin/kick`, 'POST', {
key: adminKey,
target_player_id: p.id,
});
await loadAdminData();
} catch (err) {
error = err.message;
}
}
</script>
<div class="admin-page">
<h1>Admin Panel</h1>
{#if !data}
<div class="card">
<h2 class="mb-4">Enter Admin Key</h2>
<form on:submit|preventDefault={loadAdminData} class="flex gap-4">
<input type="text" bind:value={adminKey} class="input-field flex-grow" placeholder="Admin Key" required/>
<button type="submit" class="btn btn-primary">Login</button>
</form>
{#if error}
<div class="alert alert-danger mt-4">{error}</div>
{/if}
</div>
{:else}
<div class="card">
<div class="admin-summary">
<div class="admin-summary-details">
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
<p><strong>Phase:</strong> {data.game.phase}</p>
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
</div>
<button
type="button"
class="admin-join-code"
class:copied={copiedJoinCode}
aria-label="Copy join code {data.game.join_code}"
on:click={copyJoinCode}
>
<span class="admin-join-code-label">Join Code</span>
<strong>{data.game.join_code}</strong>
<span class="admin-join-code-hint">{copiedJoinCode ? '✓ Copied!' : 'Click to copy'}</span>
</button>
</div>
{#if error}
<div class="alert alert-danger mt-4">{error}</div>
{/if}
<h3 class="mt-8 mb-4">Invite Link</h3>
<p class="info-text">Share this link to let new players join the crew.</p>
<div class="link-copy-action">
<input type="text" readonly value={inviteLink()} class="input-field flex-grow" />
<a href={inviteLink()} class="btn btn-secondary btn-small">Open</a>
<button on:click={copyInviteLink} class="btn btn-secondary btn-small">
{copiedInvite ? '✓ Copied' : 'Copy'}
</button>
</div>
<h3 class="mt-8 mb-4">Players</h3>
<table class="admin-table">
<thead>
<tr>
<th>Pi-Rat</th>
<th>Player</th>
<th>Role</th>
<th>Status</th>
<th>Rank</th>
<th>Admin</th>
<th>Re-join Link</th>
<th>Kick</th>
</tr>
</thead>
<tbody>
{#each data.players as p}
<tr>
<td>
{#if p.name && p.name !== p.player_name}
{p.name}
{:else}
<span class="text-muted">Not chosen yet</span>
{/if}
</td>
<td>{p.player_name || p.name}</td>
<td>
{#if p.role === 'deep'} 🌊 Deep
{:else if p.role === 'pirat'} 🐀 Pi-Rat
{:else} None
{/if}
</td>
<td>
{#if p.is_ghost} 👻 Ghost
{:else if p.is_dead} 💀 Dead
{:else} Alive
{/if}
</td>
<td>{p.rank}</td>
<td>
{#if p.is_creator}
🗝 Creator
{:else if p.is_admin}
<button on:click={() => setAdmin(p.id, false)} class="btn btn-secondary btn-small">Revoke Admin</button>
{:else}
<button on:click={() => setAdmin(p.id, true)} class="btn btn-secondary btn-small">Grant Admin</button>
{/if}
</td>
<td>
<div class="link-copy-action">
<a href={rejoinLink(p.id)} class="btn btn-secondary btn-small">Open</a>
<button on:click={() => copyRejoinLink(p.id)} class="btn btn-secondary btn-small">
{copiedPlayerId === p.id ? '✓ Copied' : 'Copy'}
</button>
</div>
</td>
<td>
{#if canKick(p)}
<button on:click={() => kickPlayer(p)} class="btn btn-danger btn-small">Kick</button>
{:else}
<span class="text-muted"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<div class="mt-8">
<a href={backLink} class="btn btn-secondary">{backLabel}</a>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,284 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api';
import { extraMenuLinks } from '../lib/menu';
import { getSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards';
import { setPageTitle } from '../lib/title';
import { saveSession, removeSession } from '../lib/sessions';
import LobbyPhase from '../components/LobbyPhase.svelte';
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
import ScenePhase from '../components/ScenePhase.svelte';
import UpkeepPhase from '../components/UpkeepPhase.svelte';
import RecruitPhase from '../components/RecruitPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.svelte';
import CrewSidebar from '../components/CrewSidebar.svelte';
import EventLog from '../components/EventLog.svelte';
import NameModal from '../components/NameModal.svelte';
import GatModal from '../components/GatModal.svelte';
import RankBonusModal from '../components/RankBonusModal.svelte';
export let params = {};
let gameId = params.id;
let playerId = params.pid;
let state = null;
let error = '';
let intervalId;
let ws = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
let destroyed = false;
let staleSession = false;
// Non-scene phases use the same pinned Event Log column and fixed corner
// toggle that ScenePhase owns for the main play screen.
let phaseLogOpen = true;
function discardStaleSession(message) {
staleSession = true;
removeSession(gameId, playerId);
state = null;
error = message;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) ws.close();
}
async function fetchState() {
if (staleSession) return;
try {
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
// Preserve the Game Over screen for players who were already present
// when play ended, but discard stale home-screen rejoin attempts.
if (!state && data.game.phase === 'ended') {
discardStaleSession('This game has ended and can no longer be rejoined.');
return;
}
state = data;
error = '';
} catch (err) {
if (!state && err.status === 404) {
discardStaleSession('This saved game can no longer be rejoined. You may have been removed from the crew.');
} else {
error = err.message;
}
}
}
// The server pushes a ping over this socket whenever the game changes;
// each ping triggers a refetch of the state blob.
function connectSocket() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/api/game/${gameId}/ws`);
ws.onopen = () => {
reconnectDelay = 1000;
fetchState(); // catch up on anything missed while disconnected
};
ws.onmessage = () => fetchState();
ws.onclose = () => {
if (destroyed || staleSession) return;
reconnectTimer = setTimeout(connectSocket, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
};
}
onMount(() => {
fetchState();
connectSocket();
// Slow fallback poll in case a push is ever missed
intervalId = setInterval(fetchState, 30000);
});
// Creator-only corner menu entries: Dev Mode toggle (all phases), the
// Skip Character Creation shortcut (Dev Mode only), and the Admin link.
async function toggleDevMode() {
try {
await apiRequest(`/game/${gameId}/player/${playerId}/dev-mode`, 'POST', {
enabled: !state.game.dev_mode
});
} catch (err) {
error = err.message;
}
}
async function skipCharacterCreation() {
// Suggested values for everyone's free-text fields; the backend only
// applies them to fields players haven't filled in themselves.
const fills = {};
for (const p of state.players) {
fills[p.id] = {
avatar_look: getSuggestion('look'),
avatar_smell: getSuggestion('smell'),
first_words: getSuggestion('first_words'),
good_at_math: getSuggestion('good_at_math'),
like: getSuggestion('like'),
hate: getSuggestion('hate'),
};
}
try {
await apiRequest(`/game/${gameId}/player/${playerId}/skip-character-creation`, 'POST', {
fills: JSON.stringify(fills)
});
} catch (err) {
error = err.message;
}
}
// Admin escape hatch: hand out every technique randomly and auto-assign J/Q/K
async function autoAssignTechniques() {
try {
await apiRequest(`/game/${gameId}/player/${playerId}/auto-assign-techniques`, 'POST');
} catch (err) {
error = err.message;
}
}
// Voluntarily remove yourself from the game (cards go to the discard)
async function leaveGame() {
if (!confirm("Leave this game? Your cards go to the discard and you'll need a new invite to rejoin. This can't be undone.")) return;
try {
await apiRequest(`/game/${gameId}/player/${playerId}/leave`, 'POST');
removeSession(gameId, playerId);
push('/');
} catch (err) {
error = err.message;
}
}
// Distinguish tabs/history by the Pi-Rat (and crew) this dashboard is for
$: setPageTitle(state ? displayName(state.player) : null, state?.game?.crew_name);
// Keep this browser's saved session up to date so the home page can rejoin
// with current crew/rat names (the rat name changes when a Name is earned)
$: if (state?.game && state?.player) {
saveSession({
gameId,
playerId,
crewName: state.game.crew_name,
playerName: state.player.player_name,
ratName: state.player.name,
});
}
// Admins get the admin key in their state blob; stash it so the Admin page works for them too
$: if (state?.player?.is_admin && state?.game?.admin_key) {
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);
}
$: {
const items = [];
if (state?.player?.is_admin) {
items.push({
label: `🧪 Dev Mode: ${state.game.dev_mode ? 'On' : 'Off'}`,
action: toggleDevMode,
title: 'Toggle Dev Mode testing shortcuts for the whole table',
});
if (state.game.dev_mode && ['character_creation', 'swap_techniques', 'assign_techniques'].includes(state.game.phase)) {
items.push({
label: '⏭️ Skip Character Creation',
action: skipCharacterCreation,
title: 'Auto-fill anything unfinished for all players and jump to scene setup',
});
}
if (state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques') {
items.push({
label: '🎲 Auto-Assign Techniques',
action: autoAssignTechniques,
title: 'Randomly distribute all techniques to eligible players and assign them to J/Q/K',
});
}
items.push({ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' });
}
if (state?.player) {
items.push({
label: '🚪 Leave Game',
action: leaveGame,
title: 'Remove yourself from this game (your cards go to the discard)',
});
}
extraMenuLinks.set(items);
}
onDestroy(() => {
destroyed = true;
if (intervalId) clearInterval(intervalId);
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) ws.close();
extraMenuLinks.set([]);
});
</script>
{#if error}
<div class="alert alert-danger" style="margin: 20px;">
Error connecting to game: {error}
{#if staleSession}
<div class="mt-4">
<a href="#/" class="btn btn-secondary btn-small">Back to Home</a>
</div>
{/if}
</div>
{/if}
{#if state}
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
{#if state.game.phase === 'scene'}
<ScenePhase {state} />
{:else}
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
<CrewSidebar {state} />
<main class="phase-main-column">
{#if state.game.phase === 'lobby'}
<LobbyPhase {state} />
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
<CharacterCreationPhase {state} />
{:else if state.game.phase === 'scene_setup'}
<SceneSetupPhase {state} />
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
<UpkeepPhase {state} />
{:else if state.game.phase === 'recruit_creation'}
<RecruitPhase {state} />
{:else if state.game.phase === 'ended'}
<GameOverPhase {state} />
{:else}
<div class="card p-4">Unknown phase: {state.game.phase}</div>
{/if}
</main>
{#if phaseLogOpen}
<div class="log-column">
<EventLog {state} inline on:collapse={() => (phaseLogOpen = false)} />
</div>
{/if}
</div>
{/if}
</div>
{#if state.game.phase !== 'scene'}
<button
class="log-reopen-btn"
title={phaseLogOpen ? 'Hide the event log' : 'Show the event log'}
on:click={() => (phaseLogOpen = !phaseLogOpen)}
>
{phaseLogOpen ? '✕ Close Log' : '📜 Event Log'}
</button>
{/if}
<NameModal {state} />
<GatModal {state} />
<RankBonusModal {state} />
{:else if !error}
<div class="loading-container">
<p>Loading game state...</p>
</div>
{/if}
<style>
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
color: white;
font-size: 1.5rem;
}
</style>

View File

@@ -0,0 +1,245 @@
<script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title';
import { getSessions, saveSession, removeSession } from '../lib/sessions';
let sessions = [];
onMount(() => {
setPageTitle();
sessions = getSessions();
});
let crewName = '';
let error = '';
let creating = false;
let joinCode = '';
let joinCodeError = '';
let joiningByCode = false;
function sessionRat(s) {
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
return s.ratName || s.playerName || 'Unknown rat';
}
// Delete is a soft, undoable removal: drop it from localStorage right away
// (so a refresh finalizes it) but keep the greyed row visible with an Undo.
function deleteSession(s) {
removeSession(s.gameId, s.playerId);
s.deleted = true;
sessions = sessions;
}
function undoDelete(s) {
saveSession({
gameId: s.gameId, playerId: s.playerId,
crewName: s.crewName, playerName: s.playerName, ratName: s.ratName,
});
s.deleted = false;
sessions = sessions;
}
async function createGame() {
if (!crewName.trim()) return;
creating = true;
error = '';
try {
const data = await apiRequest('/game', 'POST', { crew_name: crewName });
if (data.admin_key) {
localStorage.setItem(`admin_key_${data.id}`, data.admin_key);
}
push(`/game/${data.id}/join?creator=true`);
} catch (err) {
error = err.message;
creating = false;
}
}
function updateJoinCode(event) {
joinCode = event.currentTarget.value
.toUpperCase()
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, '')
.slice(0, 5);
}
async function joinByCode() {
if (joinCode.length !== 5) return;
joiningByCode = true;
joinCodeError = '';
try {
const data = await apiRequest(`/game/code/${joinCode}`);
push(`/game/${data.id}/join`);
} catch (err) {
joinCodeError = err.message;
joiningByCode = false;
}
}
</script>
<div class="welcome-container">
<header class="welcome-hero">
<p class="hero-overline">A Remote Play Companion for</p>
<h1 class="wordmark">Rats <span class="amp">with</span> Gats</h1>
<p class="subtitle">Stowaway rats, transformed by math magic, out to steal a ship and commit some piracy.</p>
<div class="hero-flourish" aria-hidden="true">🐀 ⚓ 🐀</div>
</header>
{#if sessions.length > 0}
<div class="glass-panel creation-card saved-sessions">
<h2>Rejoin a Crew</h2>
<p class="info-text" style="margin-bottom: 1rem;">Games you've joined from this browser. Removing one only forgets it here — it stays in the game.</p>
<ul class="session-list">
{#each sessions as s (s.gameId + s.playerId)}
<li class="session-row {s.deleted ? 'is-deleted' : ''}">
<div class="session-info">
<span class="session-crew">{s.crewName || 'Unnamed Crew'}</span>
<span class="session-rat text-muted">{sessionRat(s)}</span>
</div>
{#if s.deleted}
<div class="session-actions">
<span class="text-muted italic">Removed</span>
<button class="btn btn-secondary btn-small" on:click={() => undoDelete(s)}>Undo</button>
</div>
{:else}
<div class="session-actions">
<a href="#/game/{s.gameId}/player/{s.playerId}" class="btn btn-primary btn-small">Rejoin</a>
<button class="btn btn-secondary btn-small" title="Forget this session" on:click={() => deleteSession(s)}>✕</button>
</div>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
<div class="glass-panel creation-card join-code-card">
<h2>Join a Crew</h2>
<p class="info-text">Enter the five-character code shared by your games Admin.</p>
<form class="join-code-form" on:submit|preventDefault={joinByCode}>
<label for="joinCode">Join Code</label>
<div class="join-code-action">
<input
type="text"
id="joinCode"
value={joinCode}
on:input={updateJoinCode}
required
minlength="5"
maxlength="5"
placeholder="ABCDE"
class="input-field input-large join-code-input"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
/>
<button type="submit" class="btn btn-primary btn-large" disabled={joiningByCode || joinCode.length !== 5}>
{joiningByCode ? 'Joining...' : 'Join'}
</button>
</div>
</form>
{#if joinCodeError}
<div class="alert alert-danger mt-4">{joinCodeError}</div>
{/if}
</div>
<div class="glass-panel creation-card">
<h2>Muster a New Crew</h2>
<form on:submit|preventDefault={createGame}>
<div class="form-group">
<label for="crewName">Crew Name</label>
<input
type="text"
id="crewName"
bind:value={crewName}
required
placeholder="e.g. The Salty Dogs"
class="input-field input-large"
autocomplete="off"
/>
</div>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
<button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
{creating ? 'Creating...' : 'Create Game'}
</button>
</form>
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
</div>
<div class="welcome-links">
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
</div>
</div>
<style>
/* Space the Rejoin box off the Muster box below it. */
.saved-sessions {
margin-bottom: 1.75rem;
}
.join-code-card {
margin-bottom: 1.75rem;
}
.join-code-form {
margin-top: 1rem;
}
.join-code-action {
display: flex;
gap: 0.75rem;
align-items: stretch;
}
.join-code-input {
flex: 1;
min-width: 0;
font-family: ui-monospace, monospace;
font-weight: 700;
letter-spacing: 0.25em;
text-transform: uppercase;
}
.session-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.session-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.6rem 0.85rem;
background: var(--well);
border: 1px solid var(--edge-soft);
border-radius: var(--radius-md);
}
.session-row.is-deleted {
opacity: 0.5;
}
.session-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.session-crew {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-rat {
font-size: 0.85rem;
}
.session-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,82 @@
<script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title';
import { saveSession } from '../lib/sessions';
export let params = {};
const PLAYER_NAME_KEY = 'pirats-player-name';
let gameId = params.id;
let playerName = '';
let error = '';
let joining = false;
onMount(() => {
setPageTitle('Join the Crew');
playerName = localStorage.getItem(PLAYER_NAME_KEY) || '';
});
// We can extract ?creator=true from $querystring if needed,
// though the backend determines creator based on if they are the first player.
// For UI purposes, we could show "You are the creator" if we want.
async function joinGame() {
if (!playerName.trim()) return;
joining = true;
error = '';
try {
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
localStorage.setItem(PLAYER_NAME_KEY, playerName.trim());
// Remember this session so the home page can offer a rejoin link;
// the dashboard enriches it with crew/rat names once state loads.
saveSession({ gameId, playerId: data.id, playerName });
push(`/game/${gameId}/player/${data.id}`);
} catch (err) {
error = err.message;
joining = false;
}
}
</script>
<div class="welcome-container">
<header class="welcome-hero">
<p class="hero-overline">Rats with Gats</p>
<h1 class="wordmark">Join the Crew</h1>
</header>
<div class="glass-panel creation-card">
<h2>Enter your name</h2>
<p class="info-text" style="margin-bottom: 1rem;">
This is <strong>your</strong> name as a player, not your Pi-Rat's. Your Pi-Rat will be known
by their smell (e.g. "Recruit Gunpowder") until they earn a proper Name in play.
</p>
<form on:submit|preventDefault={joinGame}>
<div class="form-group">
<label for="playerName">Your Name</label>
<input
type="text"
id="playerName"
bind:value={playerName}
required
class="input-field input-large"
autocomplete="off"
autofocus
/>
</div>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
<button type="submit" class="btn btn-primary btn-large btn-full mt-4" disabled={joining}>
{joining ? 'Joining...' : 'Join Game'}
</button>
</form>
</div>
<div class="welcome-links">
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
</div>
</div>

View File

@@ -0,0 +1,2 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}

20
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
ws: true,
},
'/rules': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
}
}
}
})

View File

@@ -9,10 +9,11 @@ description = "Rats with Gats Remote Play web application"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"alembic",
"fastapi", "fastapi",
"sqlmodel", "sqlmodel",
"uvicorn", "uvicorn",
"jinja2", "websockets",
"python-multipart", "python-multipart",
] ]
@@ -28,7 +29,7 @@ pirats = "pirats.main:main"
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
pirats = ["static/**/*", "templates/**/*.html", "templates/*.html"] pirats = ["static/**/*", "rules.html", "migrations/**/*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = ["src"]

View File

@@ -1,5 +1,5 @@
import random import random
from typing import Dict, Any, List, Tuple from typing import Dict, Any, List
SUITS = { SUITS = {
"C": {"symbol": "", "name": "Clubs", "color": "black", "narration": "Shootin' and Stabbin' (violence/combat)"}, "C": {"symbol": "", "name": "Clubs", "color": "black", "narration": "Shootin' and Stabbin' (violence/combat)"},
@@ -74,28 +74,209 @@ OBSTACLES_DATA = {
} }
} }
# 20 funny Secret Pirate Technique suggestions for when players need ideas # Secret Pirate Technique suggestions: served to the frontend's Suggest buttons
# via GET /api/suggestions/techniques and used to equip re-rolled recruits.
TECHNIQUE_SUGGESTIONS = [ TECHNIQUE_SUGGESTIONS = [
"I missed on purpose", "I meant to do that",
"Carlos will figure it out", "The floor was always a trap",
"Never trust a hatless captain", "Carlos owes me one",
"I'm not left handed", "Barry already disarmed it",
"The Albatross is always watching", "Jaspar took the blame",
"I've got a bomb", "Greg always comes back",
"Divide by Pi", "We left Greg on purpose",
"Cheese-scented smoke screen", "The seagulls work for me",
"Look behind you, a three-headed parrot!", "My whiskers told me so",
"Parabolic boarding leap", "I was behind you the whole time",
"The Pocket Cannon", "I am wearing two eyepatches",
"Aggressive Math-whining", "Nobody suspects the small one",
"Unspeakable Tail Swish", "I licked it, so it's mine",
"Bandana Whip", "The grog made me do it",
"Double-barrelled cheese blaster", "I know a guy",
"Mathematical Scurvy cure", "The guy knows another guy",
"I was hiding under the carpet", "I greased the entire ship",
"Batten down my feelings", "I hid the cannonball in my cheeks",
"Wait, is that cheese?", "I taught the parrot everything it knows",
"Sudden pirate flip" "Hold my grog",
"Watch this",
"I counted to Pi",
"Pi is exactly three when I'm in a hurry",
"I cast Pie, again",
"I memorized the wrong map on purpose",
"I sneezed at exactly the right moment",
"I read about this in a book I ate",
"I learned this from a ghost",
"The barrel was empty on purpose",
"This is my emotional support cannon",
"That wasn't even my final form",
"I've been holding my breath since Tuesday",
"It's a rental",
"The ship likes me better",
"My cousin is a cannonball",
"My scars spell out a map",
"My other gat is a crossbow",
"I prepared this speech in advance",
"I read the rulebook upside down",
"Technically, that's legal",
"Objection!",
"It's called a heist now",
"You wouldn't hit a rat with glasses",
"These aren't even my real glasses",
"I have the high ground",
"You activated my trap card",
"It was me all along",
"Behold, a distraction",
"We meet again, stairs",
"I've seen this in a dream",
"The prophecy said nothing about Tuesdays",
"Catch!",
"Think fast",
"Look behind you",
"No, seriously, look behind you",
"This one goes to eleven",
"Critical squeak",
"Nothing up my sleeves but more sleeves",
"The anchor was a decoy",
"Six rats in a coat",
"The Deep blinked first",
"The Squid owed me a favor",
"The Albatross vouches for me",
"The Mermaid pinky-swore",
"It worked in the bathtub",
"We rehearsed this exactly once",
"The smell did the negotiating",
"Carry me, I'm dramatic",
"It's only stealing if you get caught",
"Borrowed permanently",
"Never trust a dry deck",
"Never trust a barefoot vampire",
"Never duel before lunch",
"Never sing the second verse",
"Never play cards with a Goblin",
"Never wake a sleeping Bean Whale",
"Never bite the hand that feeds you cheese",
"Never bring a sword to a math fight",
"A rat always has an exit",
"Two exits, actually",
"A wet rat fears no rain",
"A captain never looks up",
"A good plan smells like cheese",
"The loudest rat is rarely the problem",
"Dead men file no complaints",
"The best sword is a longer sword",
"If you can't tie a knot, tie a lot",
"When in doubt, scream and leap",
"Every storm is someone's fault",
"All maps lead to treasure if you're stubborn enough",
"The sea forgives nothing but forgets everything",
"Steal the boat first, the hearts will follow",
"You miss every shot you don't blame on the wind",
"There's no such thing as too much rope",
"It is always squid o'clock somewhere",
"Fortune favors the furry",
"Don't count your doubloons until they're stolen",
"Cheese before glory",
"Glory before breakfast",
"Gravity is a suggestion",
"Rule one: there are no rules",
"Rule two: see rule one",
"The knot unties itself if you believe",
"Smile until they get nervous",
"Just keep nodding",
"Pretend it's Tuesday",
"Speak softly and carry a loud gat",
"Improvise, adapt, overboard",
"Cry first, ask questions later",
"The tide pays its debts",
"Apologize mid-swing",
"Bite first, monologue later",
"Lose dramatically, win quietly",
"Fight like the ghosts are watching",
"Trip over the right thing",
"Be the cannonball",
"Always fall on the soft pirate",
"Flee now, gloat later",
"Dramatic timing is a weapon",
"Cheese knows the way home",
"Trust the mustache",
"The plank is a state of mind",
"Every plank is a trapdoor if you stomp hard enough",
"The pointy end goes in the other guy",
"An apology and a gat beats an apology",
"It's not a bribe, it's a gift",
"The fourth wall is load-bearing",
"Vampires can't resist counting spilled rice",
"Distract them with interpretive dance",
"Sing the shanty of unreasonable confidence",
"The Cheddar Gambit",
"The Gouda Guillotine",
"The Brie Breach",
"The Camembert Cannonade",
"The Reverse Plank Walk",
"The Drunken Compass Defense",
"The Whisker Feint",
"The Triple Barrel Roll",
"The Stowaway Shuffle",
"The Bilge Rat Backflip",
"The Captain's Distraction Waltz",
"The Forbidden Knot",
"The Unforgivable Cannonball",
"The Polite Mutiny",
"The Reverse Mutiny",
"The Squeak of a Thousand Regrets",
"The Tail-First Retreat",
"The Upside-Down Boarding Party",
"The Invisible Rope Trick",
"The Two-Hat Bamboozle",
"The Ghost Pepper Defense",
"The Barnacle Embrace",
"The Crow's Nest Cannonball",
"The Rigging Tango",
"The Anchor Yo-Yo",
"The Powder Keg Lullaby",
"The Mermaid's IOU",
"The Goblin Refund Policy",
"The Fifth Ace",
"The Sixth Ace",
"The Emergency Wedding",
"The Slow-Motion Walk Away",
"The Borrowed Thunder",
"The Cheese Wheel of Fortune",
"The Last Rat Standing",
"The First Rat Running",
"The Old Switcheroo",
"The Old Double Switcheroo",
"The Long Con (Short Version)",
"The Accidental Masterpiece",
"The Sympathy Limp",
"The Decoy Funeral",
"The Surprise Encore",
"The Overly Specific Alibi",
"The Group Hug Ambush",
"The Cannonball Curveball",
"The Soup of Truth",
"The Hat Trick (With Actual Hats)",
"The Thousand-Yard Squint",
"The Completely Legal Salvage",
"Carry the one",
"Round down, shoot up",
"Divide and run away",
"Subtract yourself from the situation",
"Multiply by chaos",
"Add insult to injury",
"The remainder goes in my pocket",
"Long division, short fuse",
"Triangulate, then detonate",
"Roll for cheese",
"Yo ho ho and a barrel of math",
"Ask the cook, he's seen worse",
"Barry has a boat for this",
"Tell Jaspar I said hello",
"The first mate signs my alibis",
"Piss Whiskers takes the watch",
"We voted, you lost",
"The cheese was a lie",
"One more verse won't hurt",
"Abandon ship, but stylishly"
] ]
def get_fresh_deck() -> List[str]: def get_fresh_deck() -> List[str]:

View File

@@ -1,811 +1,7 @@
import json # Facade for backward compatibility and centralized access
import random from .crud_base import *
from typing import List, Dict, Any, Optional, Tuple from .crud_character import *
from sqlmodel import Session, select from .crud_scene import *
from .models import Game, Player, Obstacle, Challenge, Vote from .crud_challenge import *
from . import cards from .crud_upkeep import *
from .crud_rollback import *
# --- Card and Hand Utilities ---
def get_player_hand(player: Player) -> List[str]:
return json.loads(player.hand_cards)
def set_player_hand(player: Player, hand: List[str]):
player.hand_cards = json.dumps(hand)
def get_game_deck(game: Game) -> List[str]:
return json.loads(game.deck_cards)
def set_game_deck(game: Game, deck: List[str]):
game.deck_cards = json.dumps(deck)
def calculate_max_hand_size(player: Player, players_in_scene: List[Player]) -> int:
"""
Calculates a player's maximum hand size based on their Rank relative to others in the scene,
and Captain privileges (+1).
Rank Hand sizes:
- Highest Rank Pi-Rat(s): max 4 cards
- Middle Rank Pi-Rat(s): max 3 cards
- Lowest Rank Pi-Rat(s): max 2 cards
- Captain gets +1
If all players have the same Rank, they are all 'middle' and get 3 cards.
"""
if player.role != "pirat":
# Deep players still have hands (for when they transition), let's say they have size based on Rank
# but don't participate in the Pi-Rat ranking calculations.
# Deep player max hand size is 3 by default, or equal to Rank. Let's make it equal to Rank + 1.
return player.rank + 1
pi_rats = [p for p in players_in_scene if p.role == "pirat"]
if not pi_rats:
return 3
ranks = [p.rank for p in pi_rats]
max_rank = max(ranks)
min_rank = min(ranks)
# Base size calculation
if len(set(ranks)) == 1:
# All have the same rank
base_size = 3
else:
if player.rank == max_rank:
base_size = 4
elif player.rank == min_rank:
base_size = 2
else:
base_size = 3
# Captain check (highest rank Pi-Rat is de facto Captain)
# The rulebook: 'Initially, the highest-Ranked Pi-Rat becomes the de facto Captain.
# Captain Privileges: The Captain has their Hand size increased by 1, regardless of their Rank.'
# If there's a tie for highest rank, we'll designate the first one in alphabetical/ID order.
highest_ranked_pirats = [p for p in pi_rats if p.rank == max_rank]
highest_ranked_pirats.sort(key=lambda p: p.id)
is_captain = len(highest_ranked_pirats) > 0 and highest_ranked_pirats[0].id == player.id
if is_captain:
return base_size + 1
return base_size
def is_player_captain(player: Player, players_in_scene: List[Player]) -> bool:
"""Helper to check if a player is currently the captain in the scene."""
if player.role != "pirat":
return False
pi_rats = [p for p in players_in_scene if p.role == "pirat"]
if not pi_rats:
return False
max_rank = max(p.rank for p in pi_rats)
highest_ranked = [p for p in pi_rats if p.rank == max_rank]
highest_ranked.sort(key=lambda p: p.id)
return len(highest_ranked) > 0 and highest_ranked[0].id == player.id
def reshuffle_discard_pile(db: Session, game: Game):
"""
Gathers all cards not currently in any player's hand and not active on the table,
adds them back to the deck, and shuffles them.
"""
# 1. Get all cards in players' hands
all_hand_cards = set()
for player in game.players:
all_hand_cards.update(get_player_hand(player))
# 2. Get active cards on the table
# We must keep the original_card of active obstacles, and the top card in their played_cards list
active_table_cards = set()
for obs in game.obstacles:
active_table_cards.add(obs.original_card)
played = json.loads(obs.played_cards)
if played:
active_table_cards.add(played[-1]["card"])
# 3. The full deck of 54 cards
full_deck = []
for suit in cards.SUITS.keys():
for val in cards.VALUES:
full_deck.append(f"{val}{suit}")
full_deck.extend(["Joker1", "Joker2"])
# 4. Remaining cards to shuffle
remaining_cards = [c for c in full_deck if c not in all_hand_cards and c not in active_table_cards]
random.shuffle(remaining_cards)
set_game_deck(game, remaining_cards)
db.add(game)
db.commit()
def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -> List[str]:
"""Draws count cards from the deck for a player, reshuffling if necessary."""
deck = get_game_deck(game)
hand = get_player_hand(player)
drawn = []
for _ in range(count):
if not deck:
# Reshuffle discard pile
reshuffle_discard_pile(db, game)
deck = get_game_deck(game)
if not deck:
# If still empty (all 54 cards are in hands or active), we can't draw
break
card = deck.pop(0)
hand.append(card)
drawn.append(card)
set_game_deck(game, deck)
set_player_hand(player, hand)
db.add(game)
db.add(player)
db.commit()
return drawn
# --- CRUD Operations ---
def create_game(db: Session) -> Game:
deck = cards.get_fresh_deck()
game = Game(
deck_cards=json.dumps(deck),
phase="lobby",
current_scene_number=1,
extra_obstacles=0
)
db.add(game)
db.commit()
db.refresh(game)
return game
def get_game(db: Session, game_id: str) -> Optional[Game]:
return db.get(Game, game_id)
def get_player(db: Session, player_id: str) -> Optional[Player]:
return db.get(Player, player_id)
def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player:
player = Player(
game_id=game_id,
name=name,
is_creator=is_creator,
rank=2, # default starting rank (will be set properly when starting character creation)
hand_cards="[]",
is_ready=False
)
db.add(player)
db.commit()
db.refresh(player)
return player
# --- Character Creation Operations ---
def delegate_question(db: Session, player_id: str, question_type: str, target_player_id: str):
player = get_player(db, player_id)
if not player:
return
if question_type == "like":
player.other_like_from_player_id = target_player_id
player.other_like = "" # Clear old answer
elif question_type == "hate":
player.other_hate_from_player_id = target_player_id
player.other_hate = "" # Clear old answer
db.add(player)
db.commit()
def submit_delegated_answer(db: Session, target_player_id: str, question_type: str, answer: str, from_player_id: str):
player = get_player(db, target_player_id)
if not player:
return
if question_type == "like" and player.other_like_from_player_id == from_player_id:
player.other_like = answer
elif question_type == "hate" and player.other_hate_from_player_id == from_player_id:
player.other_hate = answer
db.add(player)
db.commit()
def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3: str):
player = get_player(db, player_id)
if not player:
return
player.created_techniques = json.dumps([tech1, tech2, tech3])
db.add(player)
db.commit()
# Check if all players have submitted techniques. If so, trigger the swap!
game = get_game(db, player.game_id)
if not game:
return
all_submitted = True
for p in game.players:
techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs):
all_submitted = False
break
if all_submitted:
trigger_technique_swap(db, game)
def trigger_technique_swap(db: Session, game: Game):
"""
Shuffles and distributes 3 techniques created by OTHER players to each player.
"""
players = game.players
if not players:
return
# Build a pool of all techniques with their creators
pool = []
for p in players:
techs = json.loads(p.created_techniques)
for t in techs:
pool.append({"text": t, "creator_id": p.id})
# Swap algorithm with simple retry backtracking
# We want to assign 3 techniques from the pool to each player, ensuring creator_id != player.id.
# For a small number of players, random shuffling with retries is highly efficient.
success = False
for attempt in range(200):
random.shuffle(pool)
assignments = {p.id: [] for p in players}
temp_pool = pool.copy()
# Greedy assignment
failed = False
for p in players:
valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id]
if len(valid_for_p) < 3:
# If only 1-2 players are playing, it might be impossible to assign 3 non-owned techniques.
# In that case, let's allow assigning their own if there are no other options (e.g. 1 player).
if len(players) < 3:
valid_for_p = temp_pool.copy()
else:
failed = True
break
# Take 3
chosen = valid_for_p[:3]
for c in chosen:
assignments[p.id].append(c["text"])
temp_pool.remove(c)
if not failed:
# Successfully matched! Save assignments.
for p in players:
p.swapped_techniques = json.dumps(assignments[p.id])
p.is_ready = False # Reset ready flag for J/Q/K assignment step
db.add(p)
game.phase = "swap_techniques"
db.add(game)
db.commit()
success = True
break
if not success:
# Fallback in case of failure (e.g., edge cases, single player testing): just distribute whatever
for p in players:
# Just give them the first 3 from the pool
p.swapped_techniques = json.dumps([t["text"] for t in pool[:3]])
p.is_ready = False
db.add(p)
game.phase = "swap_techniques"
db.add(game)
db.commit()
def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
player = get_player(db, player_id)
if not player:
return
player.tech_jack = jack
player.tech_queen = queen
player.tech_king = king
player.is_ready = True
db.add(player)
db.commit()
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
game = get_game(db, player.game_id)
if not game:
return
if all(p.is_ready for p in game.players):
# 1. Randomly assign starting ranks
players_list = list(game.players)
random.shuffle(players_list)
# "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene."
# "Randomly choose one Player to start the game at Rank 1. This Player must play their Pi-Rat during the first scene."
# "All remaining Players start at Rank 2. These Players may choose to play either during the first scene."
if len(players_list) >= 2:
players_list[0].rank = 3
players_list[0].role = "deep"
players_list[0].previous_role = "deep"
players_list[1].rank = 1
players_list[1].role = "pirat"
players_list[1].previous_role = "pirat"
for p in players_list[2:]:
p.rank = 2
p.role = None
p.previous_role = None
else:
# Edge case for 1 player testing
players_list[0].rank = 3
players_list[0].role = "pirat"
players_list[0].previous_role = "pirat"
# Reset ready status for scene setup
for p in game.players:
p.is_ready = False
db.add(p)
game.phase = "scene_setup"
db.add(game)
db.commit()
# --- Scene Setup Operations ---
def update_scene_role(db: Session, player_id: str, role: str):
player = get_player(db, player_id)
if not player:
return
player.role = role
db.add(player)
db.commit()
def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
"""
Validates that:
1. At least 1 Pi-Rat and 1 Deep player are selected.
2. Any player who was Deep in the previous scene must play Pi-Rat in this scene.
If valid, starts the scene: shuffles deck, draws obstacles, draws starting hands, resets ready flags.
"""
game = get_game(db, game_id)
if not game:
return False, "Game not found."
players = game.players
# 1. Count roles
pirats_count = sum(1 for p in players if p.role == "pirat")
deeps_count = sum(1 for p in players if p.role == "deep")
if pirats_count < 1:
return False, "You need at least one Pi-Rat player to play."
if deeps_count < 1:
return False, "You need at least one Deep player."
# 2. Check previous Deep players
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
# Note: Only enforce if this is scene 2+, and we have enough players to make a rotation.
if game.current_scene_number > 1 and len(players) >= 3:
for p in players:
if p.previous_role == "deep" and p.role == "deep":
return False, f"{p.name} was Deep in the last scene and must play a Pi-Rat in this scene."
# Everything is valid! Start the scene.
# A. Reset obstacles and challenges
for obs in game.obstacles:
db.delete(obs)
for chal in game.challenges:
db.delete(chal)
for vote in game.votes:
db.delete(vote)
db.commit()
# B. Set previous_role to current role, reset is_ready
for p in players:
p.previous_role = p.role
p.is_ready = False
db.add(p)
# C. Shuffle remaining deck (cards not in player hands)
all_hand_cards = set()
for p in players:
all_hand_cards.update(get_player_hand(p))
full_deck = []
for suit in cards.SUITS.keys():
for val in cards.VALUES:
full_deck.append(f"{val}{suit}")
full_deck.extend(["Joker1", "Joker2"])
deck = [c for c in full_deck if c not in all_hand_cards]
random.shuffle(deck)
# D. Draw Obstacles: At least D + 1 obstacles, plus any extra_obstacles from Jokers
num_obstacles_to_draw = deeps_count + 1 + game.extra_obstacles
# Reset extra_obstacles for the next scene
game.extra_obstacles = 0
# Draw obstacles, handling Jokers
drawn_obstacles = []
while len(drawn_obstacles) < num_obstacles_to_draw:
if not deck:
break
card = deck.pop(0)
parsed = cards.parse_card(card)
if parsed["is_joker"]:
# Joker drawn as Obstacle:
# "Immediately discard Jokers when they are drawn this way, then increase the total number of Obstacles the Players must have on the Obstacle list during the following Scene by one"
game.extra_obstacles += 1
# Joker is discarded, we don't add it to the active obstacles, and we draw again.
continue
info = cards.get_obstacle_info(card)
obstacle = Obstacle(
game_id=game.id,
original_card=card,
suit=info["suit"],
title=info["title"],
description=info["description"],
current_value=info["initial_value"],
played_cards="[]"
)
db.add(obstacle)
drawn_obstacles.append(obstacle)
# E. Top up player hands to their maximum size
for p in players:
max_size = calculate_max_hand_size(p, players)
current_hand = get_player_hand(p)
if len(current_hand) < max_size:
diff = max_size - len(current_hand)
# Draw diff cards
for _ in range(diff):
if not deck:
break
card = deck.pop(0)
current_hand.append(card)
p.hand_cards = json.dumps(current_hand)
db.add(p)
game.deck_cards = json.dumps(deck)
game.phase = "scene"
db.add(game)
db.commit()
return True, "Scene started successfully!"
# --- Scene Gameplay Operations ---
def create_challenge(db: Session, game_id: str, title: str, description: str, obstacle_ids: List[str]) -> Challenge:
challenge = Challenge(
game_id=game_id,
title=title,
description=description,
applied_obstacle_ids=json.dumps(obstacle_ids),
is_active=True
)
db.add(challenge)
db.commit()
db.refresh(challenge)
return challenge
def play_card_on_obstacle(
db: Session,
player_id: str,
obstacle_id: str,
card_code: str,
challenge_id: str
) -> Tuple[bool, str, Dict[str, Any]]:
"""
Plays a card from player's hand against an active obstacle.
Updates the obstacle value, checks matching suit color to draw back,
and returns resolution results.
"""
player = get_player(db, player_id)
obstacle = db.get(Obstacle, obstacle_id)
game = get_game(db, player.game_id)
if not player or not obstacle or not game:
return False, "Game entities not found.", {}
hand = get_player_hand(player)
if card_code not in hand:
return False, "Card not in hand.", {}
# Remove from hand
hand.remove(card_code)
set_player_hand(player, hand)
# Parse card and obstacle
played_parsed = cards.parse_card(card_code)
orig_obs_parsed = cards.parse_card(obstacle.original_card)
# 1. Determine Success/Failure
success = False
details = ""
is_technique = False
tech_name = ""
# Value rules:
# A. Face Cards (Jack, Queen, King) played by Pi-Rat are Secret Pirate Techniques: Automatic Success!
if played_parsed["value"] in ["J", "Q", "K"] and player.role == "pirat":
success = True
is_technique = True
if played_parsed["value"] == "J":
tech_name = player.tech_jack or "Jack Secret Technique"
elif played_parsed["value"] == "Q":
tech_name = player.tech_queen or "Queen Secret Technique"
elif played_parsed["value"] == "K":
tech_name = player.tech_king or "King Secret Technique"
details = f"Used Secret Pirate Technique: '{tech_name}'!"
# B. Jokers played by Pi-Rat:
# "Discard one Obstacle from the Obstacle List entirely... and draw a new Obstacle card from the deck"
elif played_parsed["is_joker"]:
success = True
details = "Played a Joker! This obstacle is discarded, and a new one is drawn."
# We will handle the actual obstacle replacement below!
else:
# Regular card play vs Obstacle value.
# What is the Obstacle's current value?
# If the obstacle is J, Q, K: its value is the Rank of the challenged player.
# Otherwise it is the obstacle's current_value (numerical).
obs_val_card = cards.parse_card(obstacle.original_card)
played_on_obs = json.loads(obstacle.played_cards)
if played_on_obs:
obs_val_card = cards.parse_card(played_on_obs[-1]["card"])
if obs_val_card["value"] in ["J", "Q", "K"]:
target_value = player.rank
obs_detail = f"Rank {player.rank} (Face Card Obstacle)"
else:
target_value = obstacle.current_value
obs_detail = str(target_value)
# What is the played card's value?
played_val = played_parsed["numeric_value"]
# Apply privileges:
# Gat Privileges: +1 to Black cards (Clubs/Spades) if player has completed Personal Objective 1
# Name Privileges: +1 to Red cards (Hearts/Diamonds) if player has completed Personal Objective 2
privilege_bonus = 0
if player.completed_personal_1 and played_parsed["suit"] in ["C", "S"]:
privilege_bonus = 1
if player.completed_personal_2 and played_parsed["suit"] in ["H", "D"]:
privilege_bonus = 1
final_played_value = played_val + privilege_bonus
# Compare
if final_played_value > target_value:
success = True
details = f"Success! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
else:
success = False
details = f"Failure! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
# 2. Card Draw (Step 6 of Resolving Challenges)
# "draw 1 card and add it to their Hand for each card they played that matched the suit color (red or black) of the original Obstacle card"
drew_card = None
if not played_parsed["is_joker"]:
if played_parsed["color"] == orig_obs_parsed["color"]:
# Match! Draw 1 card
drawn = draw_cards_for_player(db, game, player, 1)
if drawn:
drew_card = drawn[0]
details += f" (Drew back {cards.parse_card(drew_card)['display']} due to matching suit colors!)"
# 3. Update Obstacle Table & Played Card list
played_list = json.loads(obstacle.played_cards)
played_list.append({
"card": card_code,
"player_id": player.id,
"player_name": player.name,
"success": success,
"details": details
})
obstacle.played_cards = json.dumps(played_list)
# The last played card (if not Joker) becomes the obstacle's new value
if not played_parsed["is_joker"]:
obstacle.current_value = played_parsed["numeric_value"]
db.add(obstacle)
else:
# It's a Joker! We replace the obstacle.
# Discard this obstacle and draw a new one
db.delete(obstacle)
db.commit()
# Draw replacement obstacle from deck
deck = get_game_deck(game)
new_obs = None
while deck:
new_card = deck.pop(0)
new_parsed = cards.parse_card(new_card)
if new_parsed["is_joker"]:
# Joker drawn: increment next scene's obstacles and discard it
game.extra_obstacles += 1
continue
info = cards.get_obstacle_info(new_card)
new_obs = Obstacle(
game_id=game.id,
original_card=new_card,
suit=info["suit"],
title=info["title"],
description=info["description"],
current_value=info["initial_value"],
played_cards="[]"
)
db.add(new_obs)
break
game.deck_cards = json.dumps(deck)
db.add(game)
db.commit()
db.add(player)
db.commit()
# 4. Deactivate the challenge (since a card was played against one of its obstacles)
# Note: If the challenge had multiple obstacles, this single play resolves it.
# In a more advanced UI we could track individual obstacle resolutions, but to keep the flow moving,
# resolving the active challenge is clean. Let's mark the challenge as resolved by this player.
challenge = db.get(Challenge, challenge_id)
if challenge:
challenge.is_active = False
challenge.resolved_by_player_id = player.id
db.add(challenge)
db.commit()
res_dict = {
"success": success,
"details": details,
"is_technique": is_technique,
"tech_name": tech_name,
"drew_card": drew_card,
"is_joker": played_parsed["is_joker"]
}
return True, "Card played successfully.", res_dict
def toggle_objective(db: Session, player_id: str, obj_type: str, status: bool):
"""Toggles Personal or Crew objectives and adjusts Ranks accordingly."""
player = get_player(db, player_id)
if not player:
return
# Check if a rank modification is needed
# Completing Personal Objective 1 (Get a Gat): Gain 1 Rank
# Completing Personal Objective 2 (Earn a Name): Gain 1 Rank
# Completing Personal Objective 3: Rank up another player (handled manually or via choices)
rank_diff = 0
if obj_type == "personal_1":
if status and not player.completed_personal_1:
rank_diff = 1
elif not status and player.completed_personal_1:
rank_diff = -1
player.completed_personal_1 = status
elif obj_type == "personal_2":
if status and not player.completed_personal_2:
rank_diff = 1
elif not status and player.completed_personal_2:
rank_diff = -1
player.completed_personal_2 = status
elif obj_type == "personal_3":
player.completed_personal_3 = status
# Note: rulebook says completing 3rd Personal Objective lets you 'Choose another Pi-Rat to gain 1 Rank'.
# We can handle this through a specific UI trigger in the "Between Scenes" phase!
player.rank += rank_diff
# Rank floor/ceiling logic (usually rank ranges from 1 to 5, let's keep it 1 to 10)
player.rank = max(1, player.rank)
db.add(player)
db.commit()
# --- Between Scenes Operations ---
def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str):
# Remove any existing vote by this voter for this game
existing = db.exec(
select(Vote).where(Vote.game_id == game_id, Vote.voter_player_id == voter_id)
).all()
for v in existing:
db.delete(v)
db.commit()
vote = Vote(
game_id=game_id,
voter_player_id=voter_id,
nominated_player_id=nominated_id
)
db.add(vote)
db.commit()
def end_scene_and_transition(db: Session, game_id: str):
"""Moves game phase to between_scenes."""
game = get_game(db, game_id)
if not game:
return
game.phase = "between_scenes"
# Clear ready flags for players
for p in game.players:
p.is_ready = False
db.add(p)
# Remove active challenges
for chal in game.challenges:
db.delete(chal)
db.add(game)
db.commit()
def process_between_scenes_votes(db: Session, game: Game):
"""
Analyzes votes, ranks up the winner, and processes any Rank 3 Objective triggers.
Then sets players who were Deep to discard and redraw.
"""
votes = game.votes
if not votes:
return
# Count votes
counts = {}
for v in votes:
counts[v.nominated_player_id] = counts.get(v.nominated_player_id, 0) + 1
if counts:
max_votes = max(counts.values())
winners = [pid for pid, val in counts.items() if val == max_votes]
# If there is a clear winner (or random in case of tie)
if winners:
winner_id = random.choice(winners)
winner = get_player(db, winner_id)
if winner:
winner.rank += 1
db.add(winner)
# Clear all votes
for v in votes:
db.delete(v)
db.commit()
def redraw_deep_players_hands(db: Session, game: Game):
"""
Step 3 of Between Scenes: Deep players from the previous scene can discard and redraw.
To make it simple in the UI, we'll let them click a button to redraw or automatically redraw
their hands to their maximum size. Let's empty their hand, shuffle them into the deck,
and draw back up to their new max hand size!
"""
for p in game.players:
if p.previous_role == "deep":
# Return hand to deck
hand = get_player_hand(p)
deck = get_game_deck(game)
deck.extend(hand)
random.shuffle(deck)
set_game_deck(game, deck)
set_player_hand(p, [])
# Recalculate max hand size and draw back up
max_size = calculate_max_hand_size(p, game.players)
draw_cards_for_player(db, game, p, max_size)
def proceed_to_next_scene_setup(db: Session, game_id: str):
game = get_game(db, game_id)
if not game:
return
# Process votes & redraw hands
process_between_scenes_votes(db, game)
redraw_deep_players_hands(db, game)
# Increment scene number
game.current_scene_number += 1
game.phase = "scene_setup"
# Reset role options for everyone
for p in game.players:
p.is_ready = False
p.role = None
db.add(p)
db.add(game)
db.commit()

347
src/pirats/crud_base.py Normal file
View File

@@ -0,0 +1,347 @@
import json
import logging
import os
import random
from typing import Any, Dict, List, Optional
from sqlmodel import Session, select
from .models import Game, Player, GameEvent, new_join_code
from . import cards
logger = logging.getLogger(__name__)
FACE_VALUES = ("J", "Q", "K")
# --- Card and Hand Utilities ---
def get_player_hand(player: Player) -> List[str]:
return json.loads(player.hand_cards)
def set_player_hand(player: Player, hand: List[str]):
player.hand_cards = json.dumps(hand)
def get_game_deck(game: Game) -> List[str]:
return json.loads(game.deck_cards)
def set_game_deck(game: Game, deck: List[str]):
game.deck_cards = json.dumps(deck)
def technique_for_face_card(player: Player, value: str) -> str:
"""The Secret Pirate Technique a player has assigned to a face card value (J/Q/K)."""
return {
"J": player.tech_jack or "Jack Secret Technique",
"Q": player.tech_queen or "Queen Secret Technique",
"K": player.tech_king or "King Secret Technique",
}[value]
def evaluate_card_play(
player: Player,
card_code: str,
obstacle_card_code: str,
obstacle_value: int,
acting_rank: int,
) -> Dict[str, Any]:
"""
Shared Challenge resolution rules for one (non-Joker) card played by a Pi-Rat
(callers must have already rejected Jokers and Deep players):
- J/Q/K triggers the player's assigned Secret Pirate Technique: automatic success.
- A face card acting as the Obstacle is worth the challenged Pi-Rat's Rank
(acting_rank); any other Obstacle card is worth obstacle_value.
- Gat privilege: Black cards +1. Name privilege: Red cards +1.
Returns {"success", "details", "is_technique", "tech_name"}.
"""
played = cards.parse_card(card_code)
if played["value"] in FACE_VALUES:
tech_name = technique_for_face_card(player, played["value"])
return {
"success": True,
"details": f"Used Secret Pirate Technique: '{tech_name}'!",
"is_technique": True,
"tech_name": tech_name,
}
if cards.parse_card(obstacle_card_code)["value"] in FACE_VALUES:
target_value = acting_rank
obs_detail = f"Rank {acting_rank} (Face Card Obstacle)"
else:
target_value = obstacle_value
obs_detail = str(target_value)
privilege_bonus = 0
if player.completed_personal_1 and played["suit"] in ("C", "S"):
privilege_bonus += 1
if player.completed_personal_2 and played["suit"] in ("H", "D"):
privilege_bonus += 1
final_value = played["numeric_value"] + privilege_bonus
success = final_value > target_value
outcome = "Success" if success else "Failure"
return {
"success": success,
"details": f"{outcome}! Played {played['display']} ({final_value}) vs Obstacle value {obs_detail}.",
"is_technique": False,
"tech_name": "",
}
def calculate_max_hand_size(player: Player, players_in_scene: List[Player], captain_id: Optional[str] = None) -> int:
"""
Calculates a player's maximum hand size based on their Rank relative to others,
plus Captain privileges (+1, regardless of Rank).
Rank Hand sizes:
- Highest Rank Pi-Rat(s): max 4 cards
- Middle Rank Pi-Rat(s): max 3 cards
- Lowest Rank Pi-Rat(s): max 2 cards
If everyone shares a Rank, they are all 'highest' and get 4 cards.
Pi-Rats are ranked against the Pi-Rats in the scene; Deep players (whose hand size
only matters for the between-scenes redraw) are ranked against all players.
"""
if player.role == "deep":
pool = players_in_scene
else:
pool = [p for p in players_in_scene if p.role != "deep"]
if not pool:
pool = [player]
ranks = [p.rank for p in pool]
if player.rank >= max(ranks):
base_size = 4
elif player.rank <= min(ranks):
base_size = 2
else:
base_size = 3
if captain_id is not None and player.id == captain_id:
return base_size + 1
return base_size
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict:
"""Snapshot every player's max hand size, for detecting increases after a Rank/Captain change."""
return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
def apply_hand_increases(db: Session, game: Game, old_maxes: dict):
"""
'Anytime your Hand size increases, you immediately draw a card.'
Compares current max hand sizes against a snapshot and draws the difference
for any player whose max increased. (Decreases never force a discard.)
"""
for p in game.players:
new_max = calculate_max_hand_size(p, game.players, game.captain_player_id)
old_max = old_maxes.get(p.id, new_max)
if new_max > old_max:
draw_cards_for_player(db, game, p, new_max - old_max)
def change_player_rank(db: Session, game: Game, player: Player, delta: int):
"""Changes a player's Rank and grants immediate draws for any resulting hand size increases."""
old_maxes = capture_hand_maxes(game.players, game.captain_player_id)
player.rank = max(1, player.rank + delta)
db.add(player)
db.commit()
apply_hand_increases(db, game, old_maxes)
def is_eligible_nominee(voter, nominee) -> bool:
"""A rank-up nominee must be another living, in-play Pi-Rat. Excludes the
voter, previous-scene Deep players, and dead / awaiting-recruit players
(ranking them up is wasted — recruits reset to Rank 1). Shared by the
between-scenes rank vote and the 3rd-objective story bonus."""
return (
nominee.id != voter.id
and nominee.role != "deep"
and not nominee.is_dead
and not nominee.needs_reroll
)
def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""):
"""Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
if game.captain_player_id == player_id:
return
old_maxes = capture_hand_maxes(game.players, game.captain_player_id)
game.captain_player_id = player_id
db.add(game)
db.commit()
apply_hand_increases(db, game, old_maxes)
if player_id:
new_captain = db.get(Player, player_id)
if new_captain:
msg = f"{new_captain.name} is now the Captain!"
if reason:
msg += f" ({reason})"
add_game_event(db, game.id, msg, kind="captain")
elif reason:
add_game_event(db, game.id, f"The Captain's position is vacant. ({reason})", kind="captain")
def reshuffle_discard_pile(db: Session, game: Game):
"""
Gathers all cards not currently in any player's hand and not active on the table,
adds them back to the deck, and shuffles them.
"""
# 1. Get all cards in players' hands
all_hand_cards = set()
for player in game.players:
all_hand_cards.update(get_player_hand(player))
# 2. Get active cards on the table
# Obstacle cards and their whole played-card columns stay on the table until the Obstacle is discarded
active_table_cards = set()
for obs in game.obstacles:
active_table_cards.add(obs.original_card)
played = json.loads(obs.played_cards)
for entry in played:
active_table_cards.add(entry["card"])
# A PvP challenger's temporary Obstacle is on the table until the duel resolves
for challenge in game.challenges:
if challenge.status == "open" and challenge.temp_card:
active_table_cards.add(challenge.temp_card)
# 3. The full deck of 54 cards
full_deck = []
for suit in cards.SUITS.keys():
for val in cards.VALUES:
full_deck.append(f"{val}{suit}")
full_deck.extend(["Joker1", "Joker2"])
# 4. Remaining cards to shuffle
remaining_cards = [c for c in full_deck if c not in all_hand_cards and c not in active_table_cards]
random.shuffle(remaining_cards)
set_game_deck(game, remaining_cards)
db.add(game)
db.commit()
def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -> List[str]:
"""Draws count cards from the deck for a player, reshuffling if necessary."""
deck = get_game_deck(game)
hand = get_player_hand(player)
drawn = []
for _ in range(count):
if not deck:
# Reshuffle discard pile
reshuffle_discard_pile(db, game)
deck = get_game_deck(game)
if not deck:
# If still empty (all 54 cards are in hands or active), we can't draw
break
card = deck.pop(0)
hand.append(card)
drawn.append(card)
set_game_deck(game, deck)
set_player_hand(player, hand)
db.add(game)
db.add(player)
db.commit()
return drawn
# --- CRUD Operations ---
def dev_mode_default() -> bool:
"""
Initial Dev Mode state for new games, from $PIRATS_DEV_MODE. Unset means a
local checkout, where defaulting on is convenient for testing; production
deployments (the Nix service) always set it, "false" by default.
"""
val = os.environ.get("PIRATS_DEV_MODE")
if val is None:
return True
return val.strip().lower() in ("1", "true", "yes", "on")
MIN_PLAYERS = 3
def has_min_players(game: Game) -> bool:
"""The crew needs at least MIN_PLAYERS to start a game or a new scene; Dev Mode bypasses this for testing."""
return game.dev_mode or len(game.players) >= MIN_PLAYERS
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
deck = cards.get_fresh_deck()
join_code = ""
for _ in range(100):
candidate = new_join_code()
if not db.exec(select(Game).where(Game.join_code == candidate)).first():
join_code = candidate
break
if not join_code:
raise RuntimeError("Could not allocate a unique join code")
game = Game(
join_code=join_code,
deck_cards=json.dumps(deck),
phase="lobby",
current_scene_number=1,
extra_obstacles=0,
crew_name=crew_name,
dev_mode=dev_mode_default()
)
db.add(game)
db.commit()
db.refresh(game)
logger.info("Game %s created (crew_name=%r, dev_mode=%s)", game.id, crew_name, game.dev_mode)
return game
def get_game(db: Session, game_id: str) -> Optional[Game]:
return db.get(Game, game_id)
def get_game_by_join_code(db: Session, join_code: str) -> Optional[Game]:
return db.exec(select(Game).where(Game.join_code == join_code)).first()
def get_player(db: Session, player_id: str) -> Optional[Player]:
return db.get(Player, player_id)
def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player:
game = db.get(Game, game_id)
# Players who join after initial character creation create their Pi-Rat in
# the between-scenes recruit_creation phase; until then they spectate.
needs_reroll = bool(game and game.phase not in ("lobby", "character_creation"))
player = Player(
game_id=game_id,
name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
player_name=name,
is_creator=is_creator,
is_admin=is_creator, # the creator starts as an Admin; more can be granted from the admin panel
rank=2, # default starting rank (will be set properly when starting character creation)
hand_cards="[]",
is_ready=False,
role=None,
needs_reroll=needs_reroll
)
db.add(player)
db.commit()
db.refresh(player)
if game:
db.refresh(game)
if game.phase == "character_creation":
# Late joiner to creation: hand out their Like/Hate questions
from .crud_character import auto_delegate_all
auto_delegate_all(db, game)
elif game.phase == "recruit_creation":
# Recruit creation is already underway; join it immediately
from .crud_character import activate_recruit
activate_recruit(db, game, player)
msg = f"{name} joined the crew!"
if needs_reroll and game and game.phase != "recruit_creation":
msg += " They'll create their Pi-Rat with the crew between scenes."
add_game_event(db, game_id, msg, kind="join")
logger.info(
"Player %s (%r) joined game %s (phase=%s, creator=%s)",
player.id, name, game_id, game.phase if game else "?", is_creator,
)
return player
def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
event = GameEvent(game_id=game_id, message=message, kind=kind)
db.add(event)
db.commit()
def get_events_page(db: Session, game_id: str, before: Optional[float] = None, limit: int = 50):
"""Returns the newest `limit` events older than `before` (newest overall if None),
in ascending timestamp order, plus a flag for whether older events remain."""
query = select(GameEvent).where(GameEvent.game_id == game_id)
if before is not None:
query = query.where(GameEvent.timestamp < before)
query = query.order_by(GameEvent.timestamp.desc()).limit(limit + 1)
events = db.exec(query).all()
has_more = len(events) > limit
return list(reversed(events[:limit])), has_more

View File

@@ -0,0 +1,494 @@
import json
import random
from typing import Tuple, Dict, Any, List, Optional
from sqlmodel import Session
from .models import Game, Player, Obstacle, Challenge
from . import cards
from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand,
draw_cards_for_player, add_game_event, change_player_rank, evaluate_card_play
)
from .crud_scene import resolve_card_against_obstacle
from .crud_character import recruit_name
# --- Helpers ---
def get_challenge(db: Session, challenge_id: str) -> Optional[Challenge]:
return db.get(Challenge, challenge_id)
def find_open_challenge_for_obstacle(game: Game, obstacle_id: str) -> Optional[Challenge]:
for challenge in game.challenges:
if challenge.status == "open" and obstacle_id in json.loads(challenge.obstacle_ids):
return challenge
return None
def _apply_captain_tax(db: Session, game: Game, acting_player: Player):
"""Captains lose 1 Rank each time they personally fail a Challenge."""
if game.captain_player_id == acting_player.id and acting_player.rank > 1:
change_player_rank(db, game, acting_player, -1)
add_game_event(db, game.id, f"Captain Tax! {acting_player.name} failed a Challenge and drops to Rank {acting_player.rank}.", kind="rank")
# --- Deep Challenges ---
def create_challenge(
db: Session,
game_id: str,
deep_player_id: str,
target_player_id: str,
obstacle_ids: List[str],
stakes_success: str = "",
stakes_failure: str = ""
) -> Tuple[bool, str]:
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat."""
game = get_game(db, game_id)
deep_player = get_player(db, deep_player_id)
target = get_player(db, target_player_id)
if not game or not deep_player or not target:
return False, "Game entities not found."
if deep_player.role != "deep":
return False, "Only Deep Players can call for Challenges."
if target.role == "deep":
return False, "The Deep cannot challenge itself. Pick a Pi-Rat!"
if target.is_dead:
return False, f"{target.name} is dead. The Deep has no quarrel with the deceased."
if target.needs_reroll:
return False, f"{target.name} has no Pi-Rat in this scene."
if not obstacle_ids:
return False, "Apply at least one Obstacle to the Challenge."
valid_ids = {o.id for o in game.obstacles}
for oid in obstacle_ids:
if oid not in valid_ids:
return False, "Obstacle not found."
existing = find_open_challenge_for_obstacle(game, oid)
if existing:
return False, "One of those Obstacles is already part of an open Challenge."
for challenge in game.challenges:
if challenge.status == "open" and challenge.target_player_id == target_player_id:
return False, f"{target.name} is already facing an open Challenge. Resolve it first."
challenge = Challenge(
game_id=game.id,
challenge_type="deep",
target_player_id=target.id,
acting_player_id=target.id,
challenger_player_id=deep_player.id,
obstacle_ids=json.dumps(obstacle_ids),
stakes_success=stakes_success.strip(),
stakes_failure=stakes_failure.strip(),
)
db.add(challenge)
db.commit()
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids)
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
success_text = stakes_success.strip()
failure_text = stakes_failure.strip()
if success_text:
msg += f" On success: {success_text}"
if failure_text:
msg += f" On failure: {failure_text}"
add_game_event(db, game.id, msg, kind="challenge")
return True, "Challenge called!"
def play_challenge_card(
db: Session,
player_id: str,
obstacle_id: str,
card_code: str
) -> Tuple[bool, str, Dict[str, Any]]:
"""
Plays a card from a Pi-Rat's hand against an Obstacle that is part of an open
Challenge. The challenged Pi-Rat (or whoever takes it over via a Gat/Name Tax)
draws back on suit-color matches; assisting Pi-Rats play but never draw.
"""
player = get_player(db, player_id)
obstacle = db.get(Obstacle, obstacle_id)
if not player or not obstacle:
return False, "Game entities not found.", {}
game = get_game(db, player.game_id)
if not game:
return False, "Game not found.", {}
if player.role == "deep":
return False, "The Deep applies Obstacles; it does not play cards against them.", {}
challenge = find_open_challenge_for_obstacle(game, obstacle_id)
if not challenge:
return False, "This Obstacle is not part of an open Challenge. The Deep must call a Challenge first.", {}
if challenge.tax_state == "requested":
return False, "A Gat/Name Tax is pending. Wait for an answer before playing cards.", {}
# An Obstacle beaten as many times as there are players is spent
played_list = json.loads(obstacle.played_cards)
current_successes = sum(1 for c in played_list if c.get("success") is True)
if current_successes >= len(game.players):
return False, "This obstacle is already completed.", {}
hand = get_player_hand(player)
if card_code not in hand:
return False, "Card not in hand.", {}
if cards.parse_card(card_code)["is_joker"]:
return False, "Jokers discard Obstacles outright; play it directly on the Obstacle instead.", {}
hand.remove(card_code)
set_player_hand(player, hand)
db.add(player)
acting = get_player(db, challenge.acting_player_id) or player
result = resolve_card_against_obstacle(db, game, player, obstacle, card_code, acting_rank=acting.rank)
# Record the play on the Challenge
plays = json.loads(challenge.plays)
plays.append({
"obstacle_id": obstacle_id,
"card": card_code,
"player_id": player.id,
"player_name": player.name,
"success": result["success"],
"details": result["details"]
})
challenge.plays = json.dumps(plays)
db.add(challenge)
db.commit()
# Step 6: Draw back on suit-color match — only the attempting Pi-Rat draws.
drew_card = None
if player.id == challenge.acting_player_id:
if cards.match_suit_color(card_code, obstacle.original_card):
drawn = draw_cards_for_player(db, game, player, 1)
if drawn:
drew_card = drawn[0]
result["details"] += " (Drew a card back due to matching suit colors!)"
else:
result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)"
role_note = "" if player.id == challenge.acting_player_id else " (assist)"
add_game_event(db, game.id, f"{player.name} played {cards.parse_card(card_code)['display']} on '{obstacle.title}'{role_note}. {result['details']}", kind="card")
result["drew_card"] = drew_card
result["is_joker"] = False
return True, "Card played successfully.", result
def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple[bool, str]:
"""
The Deep resolves the Challenge: it succeeds if at least one played card beat an
Obstacle. Applies the Captain Tax and settles any refused Gat/Name Tax.
"""
challenge = get_challenge(db, challenge_id)
resolver = get_player(db, resolver_id)
if not challenge or not resolver:
return False, "Challenge not found."
if challenge.status != "open":
return False, "This Challenge is already resolved."
if resolver.role != "deep":
return False, "Only Deep Players resolve Challenges."
if challenge.tax_state == "requested":
return False, "A Gat/Name Tax is pending. Wait for an answer before resolving."
game = get_game(db, challenge.game_id)
acting = get_player(db, challenge.acting_player_id)
target = get_player(db, challenge.target_player_id)
if not game or not acting or not target:
return False, "Game entities not found."
plays = json.loads(challenge.plays)
success = any(p.get("success") for p in plays)
challenge.status = "succeeded" if success else "failed"
db.add(challenge)
db.commit()
outcome = "succeeded" if success else "failed"
add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!", kind="challenge")
# Settle a refused Gat/Name Tax: keep the prize on success, return it on failure.
if challenge.tax_state == "refused" and challenge.tax_target_id:
refuser = get_player(db, challenge.tax_target_id)
thing = "Gat" if challenge.tax_type == "gat" else "Name"
if refuser:
if success:
add_game_event(db, game.id, f"{acting.name} succeeded and keeps the stolen {thing}! {refuser.name} must find a new one.", kind="tax")
else:
if challenge.tax_type == "gat":
acting.completed_personal_1 = False
refuser.completed_personal_1 = True
# The Gat (and its description) returns to its owner.
refuser.gat_description = acting.gat_description
acting.gat_description = ""
else:
# Return the stolen name string; the failed thief reverts to
# their smell-based recruit identity.
acting.completed_personal_2 = False
refuser.completed_personal_2 = True
refuser.name = acting.name
acting.name = recruit_name(acting)
acting.tax_banned = True
db.add(refuser)
db.add(acting)
db.commit()
change_player_rank(db, game, acting, -1)
add_game_event(db, game.id, f"{acting.name} failed and returns the {thing} to {refuser.name}. No more Gat/Name Taxes for {acting.name} this scene!", kind="tax")
if not success:
_apply_captain_tax(db, game, acting)
return True, f"Challenge {outcome}."
def cancel_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple[bool, str]:
"""The Deep withdraws an open Challenge (e.g. called by mistake). Played cards stay in the columns."""
challenge = get_challenge(db, challenge_id)
resolver = get_player(db, resolver_id)
if not challenge or not resolver:
return False, "Challenge not found."
if challenge.status != "open":
return False, "This Challenge is already resolved."
if resolver.role != "deep":
return False, "Only Deep Players can withdraw Challenges."
game = get_game(db, challenge.game_id)
target = get_player(db, challenge.target_player_id)
db.delete(challenge)
db.commit()
if game and target:
add_game_event(db, game.id, f"The Deep withdrew the Challenge against {target.name}.", kind="challenge")
return True, "Challenge withdrawn."
# --- Gat Tax & Name Tax ---
def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id: str) -> Tuple[bool, str]:
"""
A Gatless Pi-Rat may ask a Gatted Pi-Rat (or a Gatted-but-Nameless Pi-Rat may ask
a Named one) to attempt their Challenge for them.
"""
challenge = get_challenge(db, challenge_id)
requester = get_player(db, requester_id)
target = get_player(db, tax_target_id)
if not challenge or not requester or not target:
return False, "Game entities not found."
if challenge.status != "open" or challenge.challenge_type != "deep":
return False, "That Challenge cannot be taxed."
if challenge.acting_player_id != requester.id:
return False, "Only the challenged Pi-Rat can call a Gat/Name Tax."
if challenge.tax_state is not None:
return False, "A Tax has already been called on this Challenge."
if requester.tax_banned:
return False, "You failed a refused Tax this scene — no more Gat/Name Taxes for you!"
if target.id == requester.id or target.role == "deep" or target.is_dead or target.needs_reroll:
return False, "Pick another living Pi-Rat in the scene."
if not requester.completed_personal_1:
if not target.completed_personal_1:
return False, f"{target.name} has no Gat either!"
tax_type = "gat"
elif not requester.completed_personal_2:
if not target.completed_personal_2:
return False, f"{target.name} has no Name either!"
tax_type = "name"
else:
return False, "You already have a Gat and a Name. Face your own Challenges!"
challenge.tax_type = tax_type
challenge.tax_target_id = target.id
challenge.tax_state = "requested"
db.add(challenge)
db.commit()
game = get_game(db, challenge.game_id)
thing = "Gat" if tax_type == "gat" else "Name"
add_game_event(db, game.id, f"{requester.name} calls a {thing} Tax on {target.name}: 'Take this Challenge for me!'", kind="tax")
return True, f"{thing} Tax called on {target.name}."
def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool) -> Tuple[bool, str]:
"""
Accept: the requester hands over one random card and the responder attempts the
Challenge, sharing successes and failures.
Refuse: the responder hands over their Gat/Name (the requester immediately
completes that Objective) and the requester attempts the Challenge — winner keeps it.
"""
challenge = get_challenge(db, challenge_id)
responder = get_player(db, responder_id)
if not challenge or not responder:
return False, "Game entities not found."
if challenge.status != "open" or challenge.tax_state != "requested":
return False, "There is no pending Tax on this Challenge."
if challenge.tax_target_id != responder.id:
return False, "This Tax was not called on you."
requester = get_player(db, challenge.acting_player_id)
game = get_game(db, challenge.game_id)
if not requester or not game:
return False, "Game entities not found."
thing = "Gat" if challenge.tax_type == "gat" else "Name"
if accept:
# One random card from the requester's hand, chosen without looking
requester_hand = get_player_hand(requester)
card_note = ""
if requester_hand:
card = random.choice(requester_hand)
requester_hand.remove(card)
set_player_hand(requester, requester_hand)
responder_hand = get_player_hand(responder)
responder_hand.append(card)
set_player_hand(responder, responder_hand)
db.add(requester)
db.add(responder)
card_note = " One random card changed paws."
challenge.acting_player_id = responder.id
challenge.tax_state = "accepted"
db.add(challenge)
db.commit()
add_game_event(db, game.id, f"{responder.name} accepted the {thing} Tax and takes over the Challenge!{card_note}", kind="tax")
return True, "Tax accepted."
# Refused: hand over the Gat/Name. The requester attempts the Challenge themselves;
# the transfer is settled (kept or returned) when the Challenge resolves.
if challenge.tax_type == "gat":
requester.completed_personal_1 = True
responder.completed_personal_1 = False
# The Gat changes hands along with its description (like a stolen Name).
requester.gat_description = responder.gat_description
responder.gat_description = ""
event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!"
else:
# The Name itself is stolen: the requester literally takes the responder's
# name string, demoting the responder back to their smell-based identity.
stolen_name = responder.name
old_requester_name = requester.name
requester.completed_personal_2 = True
responder.completed_personal_2 = False
responder.name = recruit_name(responder)
requester.name = stolen_name
event_msg = f"{stolen_name} refused the Name Tax and is demoted to {responder.name}! {old_requester_name} steals the name '{stolen_name}' and attempts the Challenge — succeed to keep it!"
challenge.tax_state = "refused"
db.add(requester)
db.add(responder)
db.add(challenge)
db.commit()
change_player_rank(db, game, requester, 1)
add_game_event(db, game.id, event_msg, kind="tax")
return True, "Tax refused. The prize is on the line!"
# --- Pi-Rat vs Pi-Rat Challenges ---
def create_pvp_challenge(
db: Session,
game_id: str,
challenger_id: str,
defender_id: str,
card_code: str
) -> Tuple[bool, str]:
"""
A Pi-Rat challenges another: the challenger plays one card face up as a temporary
Obstacle (narrating based on suit). The defender plays a card against it.
"""
game = get_game(db, game_id)
challenger = get_player(db, challenger_id)
defender = get_player(db, defender_id)
if not game or not challenger or not defender:
return False, "Game entities not found."
if challenger.role == "deep" or defender.role == "deep":
return False, "Only Pi-Rats can duel each other."
if challenger.id == defender.id:
return False, "You cannot challenge yourself. (Well, you can, but not with cards.)"
if defender.is_dead:
return False, f"{defender.name} is dead. Let them rest."
if challenger.needs_reroll or defender.needs_reroll:
return False, "Pending recruits have no Pi-Rat to duel with."
for challenge in game.challenges:
if challenge.status == "open" and defender.id in (challenge.target_player_id, challenge.acting_player_id):
return False, f"{defender.name} is already facing an open Challenge."
hand = get_player_hand(challenger)
if card_code not in hand:
return False, "Card not in hand."
if cards.parse_card(card_code)["is_joker"]:
return False, "A Joker cannot serve as a temporary Obstacle."
hand.remove(card_code)
set_player_hand(challenger, hand)
db.add(challenger)
challenge = Challenge(
game_id=game.id,
challenge_type="pvp",
target_player_id=defender.id,
acting_player_id=defender.id,
challenger_player_id=challenger.id,
temp_card=card_code,
)
db.add(challenge)
db.commit()
parsed = cards.parse_card(card_code)
narration = cards.SUITS[parsed["suit"]]["narration"]
add_game_event(db, game.id, f"{challenger.name} challenges {defender.name}, playing {parsed['display']} as a temporary Obstacle — {narration}!", kind="challenge")
return True, "Duel called!"
def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code: str) -> Tuple[bool, str, Dict[str, Any]]:
"""
The defender plays a card against the temporary Obstacle. Resolved immediately;
both cards are discarded and only the defender draws back on a suit-color match.
"""
challenge = get_challenge(db, challenge_id)
defender = get_player(db, defender_id)
if not challenge or not defender:
return False, "Game entities not found.", {}
if challenge.status != "open" or challenge.challenge_type != "pvp":
return False, "This duel is already settled.", {}
if challenge.acting_player_id != defender.id:
return False, "You are not the one being challenged.", {}
game = get_game(db, challenge.game_id)
challenger = get_player(db, challenge.challenger_player_id)
if not game or not challenger:
return False, "Game entities not found.", {}
hand = get_player_hand(defender)
if card_code not in hand:
return False, "Card not in hand.", {}
played_parsed = cards.parse_card(card_code)
if played_parsed["is_joker"]:
return False, "Jokers discard Obstacles from the list; they can't parry a duel.", {}
hand.remove(card_code)
set_player_hand(defender, hand)
db.add(defender)
# The temp card is the Obstacle: both its value source and its face-card check.
temp_value = cards.parse_card(challenge.temp_card)["numeric_value"]
result = evaluate_card_play(defender, card_code, challenge.temp_card, temp_value, defender.rank)
success = result["success"]
plays = json.loads(challenge.plays)
plays.append({
"obstacle_id": None,
"card": card_code,
"player_id": defender.id,
"player_name": defender.name,
"success": success,
"details": result["details"]
})
challenge.plays = json.dumps(plays)
challenge.status = "succeeded" if success else "failed"
db.add(challenge)
db.commit()
drew_card = None
if cards.match_suit_color(card_code, challenge.temp_card):
drawn = draw_cards_for_player(db, game, defender, 1)
if drawn:
drew_card = drawn[0]
result["details"] += " (Drew a card back due to matching suit colors!)"
outcome = "wins" if success else "loses"
add_game_event(db, game.id, f"{defender.name} defends against {challenger.name} and {outcome}! {result['details']}", kind="challenge")
if not success:
_apply_captain_tax(db, game, defender)
result["drew_card"] = drew_card
result["is_joker"] = False
return True, "Duel resolved.", result

View File

@@ -0,0 +1,826 @@
import json
import random
from sqlmodel import Session
from .models import Game
from .crud_base import get_player, get_game, add_game_event
# --- Character Creation Operations ---
def recruit_name_from_smell(avatar_smell: str, fallback: str = "") -> str:
"""
A nameless Pi-Rat goes by "Recruit {Smell}". Strips lead-ins like "smells like"
or "faintly of" (the suggestion pool uses these) so the name reads naturally.
"""
clean_smell = avatar_smell.strip()
if not clean_smell:
return f"Recruit {fallback}".strip() if fallback else "Recruit"
lead_ins = [
"suspiciously smelling of", "pleasantly smelling of", "reminiscent of",
"smells like", "a mix of", "like a mix of", "strongly of", "faintly of",
"like", "of",
]
stripped = True
while stripped:
stripped = False
for lead in lead_ins:
if clean_smell.lower().startswith(lead + " "):
clean_smell = clean_smell[len(lead) + 1:]
stripped = True
clean_smell = clean_smell[:1].upper() + clean_smell[1:]
return f"Recruit {clean_smell}"
def recruit_name(player) -> str:
"""The smell-based temporary identity for a player's Pi-Rat."""
return recruit_name_from_smell(player.avatar_smell, fallback=player.player_name or player.name)
def auto_delegate_all(db: Session, game: Game):
"""
Assigns the Like/Hate delegated questions for every player at once.
Uses a shuffled cycle so each player answers exactly one Like and one Hate,
never their own, and (with 3+ players) from two different crewmates.
"""
players = list(game.players)
if len(players) < 2:
return
random.shuffle(players)
n = len(players)
for i, p in enumerate(players):
if not p.other_like_from_player_id:
p.other_like_from_player_id = players[(i + 1) % n].id
p.other_like = ""
if not p.other_hate_from_player_id:
p.other_hate_from_player_id = players[(i - 1) % n].id
p.other_hate = ""
db.add(p)
db.commit()
def delegate_question(db: Session, player_id: str, question_type: str, target_player_id: str):
player = get_player(db, player_id)
if not player:
return
if question_type == "like":
player.other_like_from_player_id = target_player_id
player.other_like = "" # Clear old answer
elif question_type == "hate":
player.other_hate_from_player_id = target_player_id
player.other_hate = "" # Clear old answer
db.add(player)
db.commit()
def submit_delegated_answer(db: Session, target_player_id: str, question_type: str, answer: str, from_player_id: str):
player = get_player(db, target_player_id)
if not player:
return
if question_type == "like" and player.other_like_from_player_id == from_player_id:
player.other_like = answer
elif question_type == "hate" and player.other_hate_from_player_id == from_player_id:
player.other_hate = answer
db.add(player)
db.commit()
def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3: str):
"""
Saves a player's 3 techniques. Returns an error string if any of them collide
(case-insensitively) with each other or with techniques other players already
submitted, so the player can fix it immediately instead of the whole pool
being reset after everyone submits.
"""
player = get_player(db, player_id)
if not player:
return None
techs = [tech1, tech2, tech3]
cleaned = [t.strip().lower() for t in techs]
if len(set(cleaned)) < 3:
return "Your 3 techniques must all be different."
game = get_game(db, player.game_id)
if game:
taken = set()
for p in game.players:
if p.id == player.id:
continue
for t in json.loads(p.created_techniques):
taken.add(t.strip().lower())
clashes = [techs[i] for i, c in enumerate(cleaned) if c in taken]
if clashes:
return f"Already taken by a crewmate: {', '.join(clashes)}. Pick something more original!"
player.created_techniques = json.dumps(techs)
db.add(player)
db.commit()
# Check if all players have submitted techniques. If so, trigger the swap!
if not game:
return None
maybe_trigger_technique_swap(db, game)
return None
def unsubmit_techniques(db: Session, player_id: str):
"""Unlocks a player's submitted techniques for revision. Only possible
while the crew is still in character_creation: clearing the submission
also keeps the swap phase from triggering until they resubmit."""
player = get_player(db, player_id)
if not player:
return False, "Player not found."
game = get_game(db, player.game_id)
if not game or game.phase != "character_creation":
return False, "Techniques can't be revised once swapping has begun."
if player.needs_reroll:
return False, "You're spectating until recruit creation."
if not json.loads(player.created_techniques):
return False, "You haven't submitted techniques yet."
player.created_techniques = "[]"
db.add(player)
db.commit()
return True, "Techniques unlocked for revision."
def unassign_face_techniques(db: Session, player_id: str):
"""Unlocks a player's J/Q/K assignment for revision. Clearing is_ready
keeps the game from advancing to scene setup until they reassign."""
player = get_player(db, player_id)
if not player:
return False, "Player not found."
game = get_game(db, player.game_id)
if not game or game.phase != "assign_techniques":
return False, "Face-card assignment isn't in progress."
if not player.is_ready:
return False, "You haven't assigned your face cards yet."
player.tech_jack = ""
player.tech_queen = ""
player.tech_king = ""
player.is_ready = False
db.add(player)
db.commit()
return True, "Face cards unlocked for revision."
def swap_participants(game: Game) -> list:
"""Players taking part in initial character creation (late joiners with a
queued recruit re-roll spectate and are excluded)."""
return [p for p in game.players if not p.needs_reroll]
def holds_own_technique(player) -> bool:
return any(t["creator_id"] == player.id for t in json.loads(player.held_techniques))
def maybe_trigger_technique_swap(db: Session, game: Game):
"""Begin the swap phase once every participant has submitted 3 techniques.
Safe to call after a roster change (e.g. a kick) to unblock the phase."""
participants = swap_participants(game)
if not participants:
return
for p in participants:
techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs):
return
trigger_technique_swap(db, game)
def trigger_technique_swap(db: Session, game: Game):
"""
All techniques are in: everyone enters the swap phase holding their own 3
creations (tagged with their creator) and must trade them all away through
blind swap offers before the game moves on to face-card assignment.
"""
players = swap_participants(game)
if not players:
return
# Reject the pool if any two techniques collide (case-insensitively)
seen_texts = set()
has_duplicates = False
for p in players:
for t in json.loads(p.created_techniques):
t_clean = t.strip().lower()
if t_clean in seen_texts:
has_duplicates = True
seen_texts.add(t_clean)
if has_duplicates:
# Force players to resubmit
for p in players:
p.created_techniques = "[]"
db.add(p)
db.commit()
add_game_event(db, game.id, "Duplicate techniques submitted! The pool has been cleared. All players must try again with unique technique names.", kind="warning")
return
for p in players:
p.held_techniques = json.dumps([{"text": t, "creator_id": p.id} for t in json.loads(p.created_techniques)])
p.swapped_techniques = "[]"
p.swap_offer_to_id = None
p.swap_offer_technique = ""
p.is_ready = False
db.add(p)
game.phase = "swap_techniques"
db.add(game)
db.commit()
add_game_event(db, game.id, "Phase changed: Swap Techniques — trade away all 3 of your own techniques with blind swap offers!", kind="phase")
def offer_swap(db: Session, player_id: str, target_player_id: str, technique: str):
"""Offers one held technique to another player as a blind swap. The target
learns only that an offer exists, never which technique is on the table."""
player = get_player(db, player_id)
target = get_player(db, target_player_id)
if not player or not target or player.game_id != target.game_id:
return False, "Player not found."
game = get_game(db, player.game_id)
if not game or game.phase != "swap_techniques":
return False, "Technique swapping isn't in progress."
if player.id == target.id:
return False, "You can't swap techniques with yourself."
if target.needs_reroll:
return False, "That player is spectating until recruit creation."
if player.swap_offer_to_id:
return False, "You already have a swap offer pending. Cancel it first."
held = json.loads(player.held_techniques)
entry = next((t for t in held if t["text"] == technique), None)
if not entry:
return False, "You don't hold that technique."
if entry["creator_id"] == target.id:
return False, "You can't offer a technique back to its creator."
player.swap_offer_to_id = target.id
player.swap_offer_technique = technique
db.add(player)
db.commit()
add_game_event(db, game.id, f"{player.name} offered a mystery technique swap to {target.name}.", kind="info")
return True, "Swap offered."
def cancel_swap_offer(db: Session, player_id: str):
player = get_player(db, player_id)
if not player:
return False, "Player not found."
if not player.swap_offer_to_id:
return False, "You have no pending swap offer."
target = get_player(db, player.swap_offer_to_id)
player.swap_offer_to_id = None
player.swap_offer_technique = ""
db.add(player)
db.commit()
if target:
add_game_event(db, player.game_id, f"{player.name} withdrew their swap offer to {target.name}.", kind="info")
return True, "Offer withdrawn."
def respond_swap_offer(db: Session, player_id: str, offerer_id: str, accept: bool, technique: str = ""):
"""Accepts (trading back one of your held techniques) or declines a swap
offer. On accept the two techniques change hands, creators and all."""
player = get_player(db, player_id)
offerer = get_player(db, offerer_id)
if not player or not offerer or player.game_id != offerer.game_id:
return False, "Player not found."
game = get_game(db, player.game_id)
if not game or game.phase != "swap_techniques":
return False, "Technique swapping isn't in progress."
if offerer.swap_offer_to_id != player.id:
return False, "That player hasn't offered you a swap."
if not accept:
offerer.swap_offer_to_id = None
offerer.swap_offer_technique = ""
db.add(offerer)
db.commit()
add_game_event(db, game.id, f"{player.name} declined {offerer.name}'s swap offer.", kind="info")
return True, "Offer declined."
my_held = json.loads(player.held_techniques)
my_entry = next((t for t in my_held if t["text"] == technique), None)
if not my_entry:
return False, "You don't hold that technique."
if my_entry["creator_id"] == offerer.id:
return False, "You can't trade a technique back to its creator."
their_held = json.loads(offerer.held_techniques)
their_entry = next((t for t in their_held if t["text"] == offerer.swap_offer_technique), None)
if not their_entry:
return False, "The offered technique is no longer available."
my_held.remove(my_entry)
my_held.append(their_entry)
their_held.remove(their_entry)
their_held.append(my_entry)
player.held_techniques = json.dumps(my_held)
offerer.held_techniques = json.dumps(their_held)
offerer.swap_offer_to_id = None
offerer.swap_offer_technique = ""
# If the technique we just gave away was promised to someone else, withdraw that offer
if player.swap_offer_technique == my_entry["text"]:
player.swap_offer_to_id = None
player.swap_offer_technique = ""
db.add(player)
db.add(offerer)
db.commit()
add_game_event(db, game.id, f"{player.name} and {offerer.name} swapped techniques!", kind="info")
return True, "Techniques swapped."
def set_swap_ready(db: Session, player_id: str, ready: bool):
"""Marks a player done (or not done) swapping. Readying up requires having
traded away all self-created techniques; once everyone is ready the game
moves on to face-card assignment."""
player = get_player(db, player_id)
if not player:
return False, "Player not found."
game = get_game(db, player.game_id)
if not game or game.phase != "swap_techniques":
return False, "Technique swapping isn't in progress."
if ready:
if holds_own_technique(player):
return False, "You still hold techniques you created. Swap them away first!"
if player.swap_offer_to_id:
return False, "You have a swap offer pending. Cancel it or wait for an answer first."
player.is_ready = ready
db.add(player)
db.commit()
maybe_finish_technique_swap(db, game)
return True, "Ready!" if ready else "No longer ready."
def maybe_finish_technique_swap(db: Session, game: Game):
"""Advance to face-card assignment once everyone has readied up in the swap
phase. Safe to call after a roster change to unblock the phase."""
participants = swap_participants(game)
if participants and all(p.is_ready for p in participants):
finish_technique_swap(db, game)
def finish_technique_swap(db: Session, game: Game):
"""Locks in everyone's held techniques and advances to J/Q/K assignment."""
for p in swap_participants(game):
p.swapped_techniques = json.dumps([t["text"] for t in json.loads(p.held_techniques)])
p.swap_offer_to_id = None
p.swap_offer_technique = ""
p.is_ready = False # Reset ready flag for the J/Q/K assignment step
db.add(p)
game.phase = "assign_techniques"
db.add(game)
db.commit()
add_game_event(db, game.id, "Phase changed: Assign Techniques — place your new techniques on your Jack, Queen, and King.", kind="phase")
def auto_assign_techniques(db: Session, game: Game):
"""
Admin escape hatch: randomly redistributes every created technique to an
eligible player (never its creator) and assigns them to J/Q/K, finishing
the whole technique distribution step in one click.
"""
if game.phase not in ("swap_techniques", "assign_techniques"):
return False, "Technique distribution isn't in progress."
if game.phase == "swap_techniques":
players = swap_participants(game)
pool = []
for p in players:
for t in json.loads(p.created_techniques):
pool.append({"text": t, "creator_id": p.id})
# Random shuffling with retries: for a small table this finds a valid
# creator != assignee distribution almost immediately.
assignments = None
for attempt in range(200):
random.shuffle(pool)
candidate = {p.id: [] for p in players}
temp_pool = pool.copy()
failed = False
for p in players:
valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id]
if len(valid_for_p) < 3:
# With 1-2 players a non-owned distribution can be impossible;
# fall back to allowing own techniques.
if len(players) < 3:
valid_for_p = temp_pool.copy()
else:
failed = True
break
chosen = valid_for_p[:3]
for c in chosen:
candidate[p.id].append(c)
temp_pool.remove(c)
if not failed:
assignments = candidate
break
if assignments is None:
# Last-resort fallback: deal the pool out in order
random.shuffle(pool)
assignments = {p.id: pool[i * 3:(i + 1) * 3] for i, p in enumerate(players)}
for p in players:
p.held_techniques = json.dumps(assignments[p.id])
p.swapped_techniques = json.dumps([t["text"] for t in assignments[p.id]])
p.swap_offer_to_id = None
p.swap_offer_technique = ""
p.is_ready = False
db.add(p)
game.phase = "assign_techniques"
db.add(game)
db.commit()
add_game_event(db, game.id, "Techniques were auto-distributed to the crew!", kind="info")
# Random J/Q/K for anyone who hasn't assigned yet; the last assignment
# triggers the rank rolls and the scene_setup transition.
for p in list(swap_participants(game)):
if not (p.tech_jack and p.tech_queen and p.tech_king):
swapped = json.loads(p.swapped_techniques)
random.shuffle(swapped)
assign_face_card_techniques(db, p.id, swapped[0], swapped[1], swapped[2])
elif not p.is_ready:
assign_face_card_techniques(db, p.id, p.tech_jack, p.tech_queen, p.tech_king)
db.refresh(game)
if game.phase != "scene_setup":
return False, "Auto-assignment couldn't finish; check the event log."
return True, "Techniques auto-assigned."
def maybe_finish_assign_techniques(db: Session, game: Game):
"""Once everyone has assigned their J/Q/K, roll starting ranks and move to
scene setup. Safe to call after a roster change to unblock the phase."""
players_list = swap_participants(game)
if not players_list or not all(p.is_ready for p in players_list):
return
# 1. Randomly assign starting ranks
random.shuffle(players_list)
# Teaching mode: an admin opted to be the forced Rank-3 Deep for the first
# scene, so seed them at the front instead of leaving it to chance.
if game.teaching_deep_player_id:
teacher = next((p for p in players_list if p.id == game.teaching_deep_player_id), None)
if teacher:
players_list.remove(teacher)
players_list.insert(0, teacher)
# "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene."
# "Randomly choose one Player to start the game at Rank 1. This Player must play their Pi-Rat during the first scene."
# "All remaining Players start at Rank 2. These Players may choose to play either during the first scene."
if len(players_list) >= 2:
players_list[0].rank = 3
players_list[0].role = "deep"
players_list[0].previous_role = "deep"
players_list[1].rank = 1
players_list[1].role = "pirat"
players_list[1].previous_role = "pirat"
for p in players_list[2:]:
p.rank = 2
p.role = None
p.previous_role = None
else:
# Edge case for 1 player testing
players_list[0].rank = 3
players_list[0].role = "pirat"
players_list[0].previous_role = "pirat"
# Reset ready status for scene setup
for p in game.players:
p.is_ready = False
db.add(p)
game.phase = "scene_setup"
db.add(game)
db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
player = get_player(db, player_id)
if not player:
return False, "Player not found."
game = get_game(db, player.game_id)
if not game or game.phase != "assign_techniques":
return False, "Face-card assignment isn't in progress."
if sorted([jack, queen, king]) != sorted(json.loads(player.swapped_techniques)):
return False, "Assign each of your 3 swapped techniques to exactly one face card."
player.tech_jack = jack
player.tech_queen = queen
player.tech_king = king
player.is_ready = True
db.add(player)
db.commit()
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
maybe_finish_assign_techniques(db, game)
return True, "Techniques assigned."
def skip_character_creation(db: Session, game: Game, fills: dict):
"""
Dev Mode shortcut: auto-completes every unfinished part of character
creation for ALL players and advances to scene setup. `fills` maps
player_id -> suggested values for the free-text fields (avatar_look,
avatar_smell, first_words, good_at_math, like, hate), supplied by the
admin's client from the frontend suggestion pools. Techniques come from
the backend pool. Already-filled fields are left untouched.
"""
from .cards import TECHNIQUE_SUGGESTIONS
if game.phase not in ("character_creation", "swap_techniques", "assign_techniques"):
return False, "Character creation isn't in progress."
if game.phase == "character_creation":
# 1. Basic Rat Records
for p in game.players:
f = fills.get(p.id, {})
p.avatar_look = p.avatar_look or f.get("avatar_look", "A nondescript test rat")
p.avatar_smell = p.avatar_smell or f.get("avatar_smell", "of fresh paint")
p.first_words = p.first_words or f.get("first_words", "Is this thing on?")
p.good_at_math = p.good_at_math or f.get("good_at_math", "Untested")
if not p.completed_personal_2:
p.name = recruit_name(p)
db.add(p)
# 2. Delegated Like/Hate questions
auto_delegate_all(db, game)
for p in game.players:
f = fills.get(p.id, {})
if p.other_like_from_player_id and not p.other_like:
p.other_like = f.get("like", "Their impeccable punctuality")
if p.other_hate_from_player_id and not p.other_hate:
p.other_hate = f.get("hate", "Their impeccable punctuality")
db.add(p)
# 3. Created techniques, drawn from the backend pool without collisions
taken = set()
for p in game.players:
for t in json.loads(p.created_techniques):
taken.add(t.strip().lower())
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in taken]
random.shuffle(available)
for p in game.players:
techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs):
techs = [available.pop() for _ in range(3)]
p.created_techniques = json.dumps(techs)
db.add(p)
db.commit()
# 4. Move to the swap phase (rejects the pool if there are duplicates)
trigger_technique_swap(db, game)
db.refresh(game)
if game.phase != "swap_techniques":
return False, "Technique swap failed; check the event log."
# 5. Auto-distribute any unswapped techniques and random-assign J/Q/K;
# the last assignment triggers the rank rolls and the scene_setup transition.
ok, msg = auto_assign_techniques(db, game)
if not ok:
return False, "Could not finish character creation; check the event log."
return True, "Character creation skipped."
# --- Recruit Creation (death re-rolls and late joiners) ---
#
# Dead Pi-Rats who choose to re-roll (and players who joined after initial
# character creation) are queued with needs_reroll=True. Once between-scenes
# upkeep finishes, the game detours through the "recruit_creation" phase:
# the recruit answers the basic sheet questions while crewmates answer their
# Like/Hate questions and write their 3 replacement techniques. The recruit
# then assigns those techniques to J/Q/K and finalizes, and once every pending
# recruit has finalized the game moves on to scene_setup.
def choose_reroll(db: Session, player_id: str):
"""A dead Pi-Rat opts to come back as a fresh recruit (vs. becoming a Ghost).
The actual creation happens in the recruit_creation phase after upkeep."""
player = get_player(db, player_id)
if not player:
return False, "Player not found."
if player.needs_reroll:
return True, "Already queued for a new recruit."
if not player.is_dead or player.is_ghost:
return False, "Only dead Pi-Rats can roll a new recruit."
player.needs_reroll = True
db.add(player)
db.commit()
add_game_event(db, player.game_id, f"{player.name} will return as a fresh recruit! The crew will help create them after upkeep.", kind="join")
return True, "Queued for recruit creation."
def techniques_in_use(game: Game, exclude_player_id: str = None) -> set:
"""Lowercased technique names already on someone's sheet or pending for a recruit."""
in_use = set()
for p in game.players:
if p.id == exclude_player_id:
continue
texts = [p.tech_jack, p.tech_queen, p.tech_king]
texts += json.loads(p.created_techniques) + json.loads(p.swapped_techniques)
texts += [t["text"] for t in json.loads(p.held_techniques)]
texts += [s["text"] for s in json.loads(p.incoming_techniques)]
for t in texts:
if t:
in_use.add(t.strip().lower())
return in_use
def activate_recruit(db: Session, game: Game, player):
"""Wipes a pending recruit's sheet and assigns crewmates to answer their
Like/Hate questions and write their 3 replacement techniques."""
from .crud_base import get_player_hand, set_player_hand, get_game_deck, set_game_deck
# Return any leftover hand to the deck
hand = get_player_hand(player)
if hand:
deck = get_game_deck(game)
deck.extend(hand)
random.shuffle(deck)
set_game_deck(game, deck)
set_player_hand(player, [])
# A dead Captain's position does not pass to their replacement recruit
if game.captain_player_id == player.id:
game.captain_player_id = None
# New recruits always start at Rank 1 with a blank sheet
player.rank = 1
player.role = None
player.is_ready = False
player.is_dead = False
player.is_ghost = False
player.tax_banned = False
player.needs_name = False
player.needs_rank_3_bonus = False
player.completed_personal_1 = False
player.completed_personal_2 = False
player.completed_personal_3 = False
player.avatar_look = ""
player.avatar_smell = ""
player.first_words = ""
player.good_at_math = ""
player.created_techniques = "[]"
player.held_techniques = "[]"
player.swapped_techniques = "[]"
player.swap_offer_to_id = None
player.swap_offer_technique = ""
player.tech_jack = ""
player.tech_queen = ""
player.tech_king = ""
player.name = recruit_name(player)
# Crewmates fill in the social questions and write the recruit's techniques.
# Prefer helpers who aren't busy creating their own recruit.
others = [p for p in game.players if p.id != player.id]
helpers = [p for p in others if not p.needs_reroll] or others
if helpers:
random.shuffle(helpers)
player.other_like_from_player_id = helpers[0].id
player.other_like = ""
player.other_hate_from_player_id = helpers[1 % len(helpers)].id
player.other_hate = ""
slots = [{"from_id": helpers[i % len(helpers)].id, "text": ""} for i in range(3)]
player.incoming_techniques = json.dumps(slots)
else:
# Solo game: nobody to ask, so fall back to the suggestion pool
from .cards import TECHNIQUE_SUGGESTIONS
player.other_like_from_player_id = None
player.other_like = ""
player.other_hate_from_player_id = None
player.other_hate = ""
in_use = techniques_in_use(game, exclude_player_id=player.id)
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use]
if len(available) < 3:
available = TECHNIQUE_SUGGESTIONS
chosen = random.sample(available, 3)
player.incoming_techniques = json.dumps([{"from_id": None, "text": t} for t in chosen])
db.add(player)
db.add(game)
db.commit()
def start_recruit_creation(db: Session, game: Game):
"""Enters the recruit_creation phase and activates every pending recruit."""
game.phase = "recruit_creation"
db.add(game)
db.commit()
for p in game.players:
if p.needs_reroll:
activate_recruit(db, game, p)
add_game_event(db, game.id, "Phase changed: New Recruits — help your crewmates create their fresh Rank 1 Pi-Rats!", kind="phase")
def contribute_recruit_technique(db: Session, contributor_id: str, recruit_id: str, slot: int, text: str):
"""A crewmate writes (or revises) one of their assigned technique slots for a recruit."""
recruit = get_player(db, recruit_id)
contributor = get_player(db, contributor_id)
if not recruit or not contributor or recruit.game_id != contributor.game_id:
return False, "Player not found."
game = get_game(db, recruit.game_id)
if not game or game.phase != "recruit_creation" or not recruit.needs_reroll:
return False, "That recruit isn't being created right now."
text = text.strip()
if not text:
return False, "Technique can't be empty."
slots = json.loads(recruit.incoming_techniques)
if slot < 0 or slot >= len(slots):
return False, "Invalid technique slot."
if slots[slot]["from_id"] != contributor_id:
return False, "That technique slot belongs to another crewmate."
in_use = techniques_in_use(game, exclude_player_id=recruit.id)
for i, s in enumerate(slots):
if i != slot and s["text"]:
in_use.add(s["text"].strip().lower())
if text.lower() in in_use:
return False, f'"{text}" is already in play. Pick something more original!'
slots[slot]["text"] = text
recruit.incoming_techniques = json.dumps(slots)
db.add(recruit)
db.commit()
return True, "Technique contributed."
def finalize_recruit(db: Session, player_id: str, jack: str, queen: str, king: str):
"""The recruit assigns their crew-written techniques to J/Q/K and joins the crew.
Once the last pending recruit finalizes, the game advances to scene_setup."""
player = get_player(db, player_id)
if not player:
return False, "Player not found."
game = get_game(db, player.game_id)
if not game or game.phase != "recruit_creation" or not player.needs_reroll:
return False, "No recruit creation in progress."
if not (player.avatar_look and player.avatar_smell and player.first_words and player.good_at_math):
return False, "Fill out your Rat Records first."
if player.other_like_from_player_id and not player.other_like:
return False, "A crewmate still owes you a LIKE answer."
if player.other_hate_from_player_id and not player.other_hate:
return False, "A crewmate still owes you a HATE answer."
texts = [s["text"] for s in json.loads(player.incoming_techniques)]
if len(texts) < 3 or not all(texts):
return False, "Your crewmates haven't finished writing your techniques."
if sorted([jack, queen, king]) != sorted(texts):
return False, "Assign each of your 3 new techniques to exactly one face card."
player.swapped_techniques = json.dumps(texts)
player.held_techniques = json.dumps([{"text": s["text"], "creator_id": s["from_id"]} for s in json.loads(player.incoming_techniques)])
player.tech_jack = jack
player.tech_queen = queen
player.tech_king = king
player.needs_reroll = False
db.add(player)
db.commit()
# Deal the new recruit's starting hand
from .crud_base import calculate_max_hand_size, draw_cards_for_player
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
draw_cards_for_player(db, game, player, max_size)
add_game_event(db, game.id, f"{player.name} has joined the crew as a fresh Rank {player.rank} recruit!", kind="join")
maybe_finish_recruit_creation(db, game)
return True, "Welcome aboard!"
def maybe_finish_recruit_creation(db: Session, game: Game):
"""Leave recruit creation for scene setup once no pending recruits remain.
Safe to call after a roster change to unblock the phase."""
if not any(p.needs_reroll for p in game.players):
from .crud_upkeep import begin_next_scene_setup
begin_next_scene_setup(db, game)
def _unused_recruit_technique(game: Game, recruit_id: str) -> str:
"""A technique name not already in play, for backfilling a recruit slot whose
assigned crewmate is gone (no one left to write it)."""
from .cards import TECHNIQUE_SUGGESTIONS
in_use = techniques_in_use(game, exclude_player_id=recruit_id)
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use] or list(TECHNIQUE_SUGGESTIONS)
return random.choice(available)
def repair_references_to_removed_player(db: Session, game: Game, removed_id: str):
"""Fix every other player's pointers to a player who is being removed, so
nothing dangles or stalls: pending swap offers are dropped, delegated
Like/Hate questions and recruit technique slots are handed to a present
crewmate (or filled from the pool when nobody is left to do them)."""
others = [p for p in game.players if p.id != removed_id]
helpers = [p for p in others if not p.needs_reroll]
def pick_helper(exclude_id):
pool = [h for h in helpers if h.id != exclude_id]
return random.choice(pool) if pool else None
for p in others:
changed = False
# A swap offer aimed at the removed player can never be answered.
if p.swap_offer_to_id == removed_id:
p.swap_offer_to_id = None
p.swap_offer_technique = ""
changed = True
# Delegated Like/Hate the removed player still owed: hand to another
# present crewmate, or drop the question if there's no one left.
if p.other_like_from_player_id == removed_id and not p.other_like:
alt = pick_helper(p.id)
p.other_like_from_player_id = alt.id if alt else None
changed = True
if p.other_hate_from_player_id == removed_id and not p.other_hate:
alt = pick_helper(p.id)
p.other_hate_from_player_id = alt.id if alt else None
changed = True
# Recruit technique slots the removed player was assigned to write.
if p.needs_reroll:
slots = json.loads(p.incoming_techniques)
slots_changed = False
for s in slots:
if s.get("from_id") == removed_id:
alt = pick_helper(p.id)
if alt:
s["from_id"] = alt.id
elif not s.get("text"):
# Nobody left to write it — backfill so the recruit can
# still finalize.
s["from_id"] = None
s["text"] = _unused_recruit_technique(game, p.id)
else:
s["from_id"] = None
slots_changed = True
if slots_changed:
p.incoming_techniques = json.dumps(slots)
changed = True
if changed:
db.add(p)
db.commit()

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."

421
src/pirats/crud_scene.py Normal file
View File

@@ -0,0 +1,421 @@
import json
import logging
from typing import Tuple, Dict, Any
from sqlmodel import Session
from .models import Game, Player, Obstacle
from . import cards
from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS,
is_eligible_nominee
)
logger = logging.getLogger(__name__)
# --- Scene Setup Operations ---
VALID_ROLES = ("pirat", "deep")
def update_scene_role(db: Session, player_id: str, role: str):
# Constrain to the two real roles so the endpoint can't stash an arbitrary
# string in the column (defensive: it only ever drives gameplay branches).
if role not in VALID_ROLES:
return
player = get_player(db, player_id)
if not player:
return
player.role = role
db.add(player)
db.commit()
def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
"""
Validates that:
1. At least 1 Pi-Rat and 1 Deep player are selected.
2. First scene: the Rank 3 player must play the Deep, the Rank 1 player must play their Pi-Rat.
3. Any player who was Deep in the previous scene must play Pi-Rat in this scene.
If valid, starts the scene: shuffles discards into the deck, tops up the Obstacle List
(unbeaten Obstacles persist across scenes), and deals starting hands in the first scene.
"""
game = get_game(db, game_id)
if not game:
return False, "Game not found."
if not has_min_players(game):
return False, f"You need at least {MIN_PLAYERS} players to start a scene."
players = game.players
# Default any unselected roles to 'pirat'. Pending recruits have no Pi-Rat
# yet, so they stay roleless and spectate until the next between-scenes.
for p in players:
if p.role is None and not p.needs_reroll:
p.role = "pirat"
db.add(p)
elif p.needs_reroll:
p.role = None
db.add(p)
db.commit()
if game.current_scene_number == 1:
# The Rank 3 starter must play the Deep; the Rank 1 starter must play their Pi-Rat.
# Rank 2 players may choose either role.
if len(players) >= 2:
for p in players:
if p.rank >= 3 and p.role != "deep":
return False, f"{p.name} starts at Rank 3 and must play The Deep in the first scene."
if p.rank <= 1 and p.role != "pirat":
return False, f"{p.name} starts at Rank 1 and must play their Pi-Rat in the first scene."
# 1. Check previous Deep players
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
# Note: Only enforce if this is scene 2+, and we have enough players to make a rotation.
if game.current_scene_number > 1 and len(players) >= 3:
for p in players:
if p.previous_role == "deep" and p.role == "deep":
return False, f"{p.name} was Deep in the last scene and must play a Pi-Rat in this scene."
# 2. Check single eligible Deep player
# If only one player is eligible to play the Deep, they must play the Deep.
if game.current_scene_number > 1:
eligible_deeps = [p for p in players if p.previous_role != "deep" and not p.needs_reroll]
if len(eligible_deeps) == 1:
must_be_deep_player = eligible_deeps[0]
if must_be_deep_player.role != "deep":
return False, f"{must_be_deep_player.name} is the only player eligible to play the Deep and must play the Deep in this scene."
# 3. Check counts
pirats_count = sum(1 for p in players if p.role == "pirat")
deeps_count = sum(1 for p in players if p.role == "deep")
if pirats_count < 1:
return False, "You need at least one Pi-Rat player to play."
if deeps_count < 1:
return False, "You need at least one Deep player."
# Everything is valid! Start the scene.
# A. Clear votes and stale challenges. Obstacles persist across scenes.
for vote in game.votes:
db.delete(vote)
for challenge in game.challenges:
db.delete(challenge)
db.commit()
# B. Set previous_role to current role, reset per-scene flags
for p in players:
p.previous_role = p.role
p.is_ready = False
p.tax_banned = False
db.add(p)
db.commit()
# C. Return discards to the deck and shuffle (cards in hands or on the table stay where they are)
reshuffle_discard_pile(db, game)
deck = get_game_deck(game)
# D. Top up the Obstacle List until it holds at least Deep Players + 1 Obstacles,
# plus the permanent increase from Jokers previously drawn as Obstacles.
required_obstacles = deeps_count + 1 + game.extra_obstacles
db.refresh(game)
active_count = len(game.obstacles)
drawn_count = 0
while active_count + drawn_count < required_obstacles:
if not deck:
break
card = deck.pop(0)
parsed = cards.parse_card(card)
if parsed["is_joker"]:
# Joker drawn as an Obstacle: discard it and permanently increase the
# number of Obstacles required for following scenes by 1.
game.extra_obstacles += 1
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.", kind="joker")
continue
info = cards.get_obstacle_info(card)
obstacle = Obstacle(
game_id=game.id,
original_card=card,
suit=info["suit"],
title=info["title"],
description=info["description"],
current_value=info["initial_value"],
played_cards="[]"
)
db.add(obstacle)
drawn_count += 1
# E. Deal starting hands in the first scene. In later scenes hands carry over;
# players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw.
if game.current_scene_number == 1:
for p in players:
if p.needs_reroll:
continue
max_size = calculate_max_hand_size(p, players, game.captain_player_id)
current_hand = get_player_hand(p)
while len(current_hand) < max_size and deck:
current_hand.append(deck.pop(0))
p.hand_cards = json.dumps(current_hand)
db.add(p)
game.deck_cards = json.dumps(deck)
game.phase = "scene"
db.add(game)
db.commit()
total_obstacles = active_count + drawn_count
add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {drawn_count} new obstacle(s) drawn ({total_obstacles} active).", kind="scene")
return True, "Scene started successfully!"
def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
game = get_game(db, game_id)
player = get_player(db, player_id)
if not game or not player:
return []
players = game.players
allowed = ["pirat", "deep"]
if player.needs_reroll:
# Pending recruits spectate until their Pi-Rat is created between scenes
return []
if game.current_scene_number == 1:
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either.
if len(players) >= 2:
if player.rank >= 3:
allowed = ["deep"]
elif player.rank <= 1:
allowed = ["pirat"]
elif game.current_scene_number > 1:
# Rule 1: Previous Deep must play Pi-Rat (if >= 3 players)
if len(players) >= 3 and player.previous_role == "deep":
if "deep" in allowed:
allowed.remove("deep")
# Rule 2: If only 1 eligible Deep, they MUST play Deep
eligible_deeps = [p for p in players if p.previous_role != "deep" and not p.needs_reroll]
if len(eligible_deeps) == 1 and eligible_deeps[0].id == player_id:
if "pirat" in allowed:
allowed.remove("pirat")
return allowed
# --- Scene Gameplay Operations ---
def resolve_card_against_obstacle(
db: Session,
game: Game,
player: Player,
obstacle: Obstacle,
card_code: str,
acting_rank: int
) -> Dict[str, Any]:
"""
Resolves one (non-Joker) card from a player against an Obstacle via the shared
rules in evaluate_card_play, appends the card to the Obstacle's column, and
updates the Obstacle's current value. Does not touch hands or draws.
"""
# The last card in the column (or the original Obstacle card) determines the value.
played_list = json.loads(obstacle.played_cards)
obstacle_card = played_list[-1]["card"] if played_list else obstacle.original_card
result = evaluate_card_play(player, card_code, obstacle_card, obstacle.current_value, acting_rank)
# Place the card in the Obstacle's column; it becomes the Obstacle's new value.
played_list.append({
"card": card_code,
"player_id": player.id,
"player_name": player.name,
"success": result["success"],
"details": result["details"]
})
obstacle.played_cards = json.dumps(played_list)
obstacle.current_value = cards.parse_card(card_code)["numeric_value"]
db.add(obstacle)
db.commit()
return result
def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]:
"""
Pirate Luck! A Joker may be played at any time to discard one Obstacle (and its
column) from the list entirely and draw a new one. The Joker is then discarded.
"""
player = get_player(db, player_id)
obstacle = db.get(Obstacle, obstacle_id)
if not player or not obstacle:
return False, "Game entities not found."
game = get_game(db, player.game_id)
if not game:
return False, "Game not found."
parsed = cards.parse_card(card_code)
if not parsed["is_joker"]:
return False, "That card is not a Joker."
hand = get_player_hand(player)
if card_code not in hand:
return False, "Card not in hand."
hand.remove(card_code)
set_player_hand(player, hand)
db.add(player)
obstacle_title = obstacle.title
# Remove the obstacle from any open challenge it was applied to
for challenge in game.challenges:
if challenge.status != "open":
continue
obstacle_ids = json.loads(challenge.obstacle_ids)
if obstacle_id in obstacle_ids:
obstacle_ids.remove(obstacle_id)
challenge.obstacle_ids = json.dumps(obstacle_ids)
db.add(challenge)
db.delete(obstacle)
db.commit()
# Draw a replacement Obstacle from the deck
deck = get_game_deck(game)
new_title = None
while deck:
new_card = deck.pop(0)
new_parsed = cards.parse_card(new_card)
if new_parsed["is_joker"]:
game.extra_obstacles += 1
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.", kind="joker")
continue
info = cards.get_obstacle_info(new_card)
new_obs = Obstacle(
game_id=game.id,
original_card=new_card,
suit=info["suit"],
title=info["title"],
description=info["description"],
current_value=info["initial_value"],
played_cards="[]"
)
db.add(new_obs)
new_title = info["title"]
break
game.deck_cards = json.dumps(deck)
db.add(game)
db.commit()
msg = f"{player.name} played a Joker! '{obstacle_title}' is discarded"
msg += f" and replaced by '{new_title}'." if new_title else " (no replacement left in the deck)."
add_game_event(db, game.id, msg, kind="joker")
return True, msg
def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool):
"""Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly."""
player = get_player(db, target_id)
if not player:
return
game = get_game(db, game_id)
if not game:
return
if obj_type.startswith("crew_"):
if obj_type == "crew_1":
# Stealing a ship no longer auto-anoints a Captain — the crew has to
# earn and choose one themselves (Crew Objective 2 + the Captaincy UI).
game.completed_crew_1 = status
elif obj_type == "crew_2":
game.completed_crew_2 = status
elif obj_type == "crew_3":
game.completed_crew_3 = status
db.add(game)
db.commit()
obj_name = obj_type.replace('_', ' ').title()
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")
return
rank_diff = 0
if obj_type == "personal_1":
if status and not player.completed_personal_1:
rank_diff = 1
player.needs_gat_description = True
elif not status and player.completed_personal_1:
rank_diff = -1
player.needs_gat_description = False
player.gat_description = ""
player.completed_personal_1 = status
elif obj_type == "personal_2":
if status and not player.completed_personal_2:
rank_diff = 1
player.needs_name = True
elif not status and player.completed_personal_2:
rank_diff = -1
player.needs_name = False
player.completed_personal_2 = status
elif obj_type == "personal_3":
if status and not player.completed_personal_3:
player.needs_rank_3_bonus = True
player.is_dead = True
elif not status and player.completed_personal_3:
player.needs_rank_3_bonus = False
player.is_dead = False
player.completed_personal_3 = status
db.add(player)
db.commit()
if rank_diff != 0:
change_player_rank(db, game, player, rank_diff)
# The Captain remains in power until they give up the position, are defeated, or die.
if obj_type == "personal_3" and status and game.captain_player_id == player.id:
set_captain(db, game, None, f"{player.name}'s story arc is complete")
obj_name = obj_type.replace('_', ' ').title()
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 grant_story_bonus_rank(db: Session, game: Game, granter: Player, target):
"""A Pi-Rat who finished their story (3rd Personal Objective) grants another
eligible Pi-Rat +1 Rank. `target=None` forfeits the bonus (e.g. no eligible
crewmate); either way the needs_rank_3_bonus prompt clears. Naming an
ineligible target is refused so the prompt stays up rather than waste the Rank."""
if not granter.needs_rank_3_bonus:
return False, "There is no story Rank bonus to grant."
if target is not None and not is_eligible_nominee(granter, target):
return False, "Pick a living crewmate who isn't this scene's Deep."
if target is not None:
change_player_rank(db, game, target, 1)
add_game_event(db, game.id, f"{granter.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.", kind="rank")
else:
add_game_event(db, game.id, f"{granter.name} completed their story, but had no crewmate to pass a Rank to.", kind="rank")
granter.needs_rank_3_bonus = False
db.add(granter)
db.commit()
return True, "Rank granted."
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
obstacle = db.get(Obstacle, obstacle_id)
if obstacle:
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.", kind="obstacle")
db.delete(obstacle)
db.commit()
def finish_game(db: Session, game_id: str):
"""Ends the story. The wrap-up screen shows how the crew measured up."""
game = get_game(db, game_id)
if not game:
return
game.phase = "ended"
db.add(game)
db.commit()
add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉", kind="victory")
logger.info("Game %s ended after %s scene(s)", game_id, game.current_scene_number)

306
src/pirats/crud_upkeep.py Normal file
View File

@@ -0,0 +1,306 @@
import logging
import random
from typing import List, Tuple
from sqlmodel import Session, select
from .models import Game, Player, Vote
from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
calculate_max_hand_size, change_player_rank, set_captain, is_eligible_nominee
)
logger = logging.getLogger(__name__)
# --- Between Scenes Operations ---
def eligible_vote_nominees(game: Game, voter) -> list:
"""The crewmates `voter` may nominate to rank up. An empty list means the
voter has no valid pick and skips voting entirely (no deadlock)."""
return [p for p in game.players if is_eligible_nominee(voter, p)]
def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str) -> Tuple[bool, str]:
"""
Every Player (including last scene's Deep Players) nominates one *other* Pi-Rat
Player to Rank up. Deep Players from the previous scene cannot be nominated,
nor can dead or awaiting-recruit Pi-Rats. A voter whose only options are
ineligible skips voting (the frontend lets them ready up without a vote).
"""
voter = get_player(db, voter_id)
nominee = get_player(db, nominated_id)
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
return False, "Player not found."
if not is_eligible_nominee(voter, nominee):
return False, "You can only nominate a living crewmate who isn't this scene's Deep — and not yourself."
# Remove any existing vote by this voter for this game
existing = db.exec(
select(Vote).where(Vote.game_id == game_id, Vote.voter_player_id == voter_id)
).all()
for v in existing:
db.delete(v)
db.commit()
vote = Vote(
game_id=game_id,
voter_player_id=voter_id,
nominated_player_id=nominated_id
)
db.add(vote)
db.commit()
return True, "Vote submitted."
def unchallenged_pirats(game: Game):
"""Living Pi-Rats who have not yet been the target of a Deep Challenge this scene."""
challenged_ids = {c.target_player_id for c in game.challenges if c.challenge_type == "deep"}
return [p for p in game.players
if p.role == "pirat" and not p.is_dead and p.id not in challenged_ids]
def end_scene_and_transition(db: Session, game_id: str):
"""Moves game phase to between_scenes.
Unless Dev Mode is on, the scene can't end until every living Pi-Rat has
faced at least one Deep Challenge, or the Obstacle List has been cleared.
(Whether each Pi-Rat had a chance at their Objective is left to table talk.)
"""
game = get_game(db, game_id)
if not game:
return False, "Game not found."
if not game.dev_mode and game.obstacles:
waiting = unchallenged_pirats(game)
if waiting:
names = ", ".join(p.name for p in waiting)
return False, (
f"Every Pi-Rat must face a Challenge before the scene can end "
f"(still waiting on: {names}). Clear the Obstacle List to end early."
)
game.phase = "between_scenes"
# Clear ready flags for players
for p in game.players:
p.is_ready = False
db.add(p)
db.add(game)
db.commit()
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
return True, "Scene ended."
def process_between_scenes_votes(db: Session, game: Game):
"""
Analyzes votes, ranks up the winner, and processes any Rank 3 Objective triggers.
Then sets players who were Deep to discard and redraw.
"""
votes = game.votes
# Reset the previous scene's winner before tallying this one.
game.last_rank_up_player_id = None
db.add(game)
if not votes:
db.commit()
return
# Count votes
counts = {}
for v in votes:
counts[v.nominated_player_id] = counts.get(v.nominated_player_id, 0) + 1
if counts:
max_votes = max(counts.values())
winners = [pid for pid, val in counts.items() if val == max_votes]
# If there is a clear winner (or random in case of tie)
if winners:
winner_id = random.choice(winners)
winner = get_player(db, winner_id)
if winner:
change_player_rank(db, game, winner, 1)
game.last_rank_up_player_id = winner.id
db.add(game)
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank")
# Clear all votes
for v in votes:
db.delete(v)
db.commit()
def begin_next_scene_setup(db: Session, game: Game):
"""Advances to the next scene's setup: bump the scene counter and clear roles."""
game.current_scene_number += 1
game.phase = "scene_setup"
game.last_rank_up_player_id = None # Stale once we leave the post-voting screen
for p in game.players:
p.role = None
p.is_ready = False
db.add(p)
db.add(game)
db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
def advance_after_upkeep(db: Session, game: Game):
"""After votes (and any resting-Deep hand refresh), detour through recruit
creation if anyone is waiting on a new Pi-Rat; otherwise straight to setup."""
if any(p.needs_reroll for p in game.players):
from .crud_character import start_recruit_creation
start_recruit_creation(db, game)
else:
begin_next_scene_setup(db, game)
def maybe_transition_to_deep_upkeep(db: Session, game: Game):
"""In between_scenes, move on once every player has readied up. Safe to call
after a roster change to unblock the phase."""
if game.players and all(p.is_ready for p in game.players):
transition_to_deep_upkeep(db, game.id)
def transition_to_deep_upkeep(db: Session, game_id: str):
game = get_game(db, game_id)
if not game:
return
# Process votes & rank up winner
process_between_scenes_votes(db, game)
# Reset ready flags
for p in game.players:
p.is_ready = False
db.add(p)
db.commit()
# Check if we have resting Deep players (previous_role was 'deep')
has_resting_deep = any(p.previous_role == "deep" for p in game.players)
if has_resting_deep:
game.phase = "deep_upkeep"
db.add(game)
db.commit()
add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
else:
# No resting Deep players: skip straight past the deep_upkeep phase
advance_after_upkeep(db, game)
def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
player = get_player(db, player_id)
if not player:
return
game = get_game(db, player.game_id)
if not game:
return
# 1. Discard cards
hand = get_player_hand(player)
deck = get_game_deck(game)
for card in discard_cards:
if card in hand:
hand.remove(card)
deck.append(card)
random.shuffle(deck)
set_game_deck(game, deck)
set_player_hand(player, hand)
db.add(game)
db.add(player)
db.commit()
# 2. Draw cards back up to max size (based on Rank, per the hand size table)
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
if len(hand) < max_size:
diff = max_size - len(hand)
draw_cards_for_player(db, game, player, diff)
# Mark as ready
player.is_ready = True
db.add(player)
db.commit()
# Once every resting Deep player has refreshed their hand, move on.
maybe_advance_after_deep_upkeep(db, game)
def maybe_advance_after_deep_upkeep(db: Session, game: Game):
"""Leave deep upkeep once every resting Deep player has refreshed. With no
resting Deep left (e.g. the last one was kicked) this advances immediately.
Safe to call after a roster change to unblock the phase."""
resting_deep = [p for p in game.players if p.previous_role == "deep"]
if all(p.is_ready for p in resting_deep):
advance_after_upkeep(db, game)
# --- Kicking a player ---
def recheck_phase_completion(db: Session, game: Game):
"""Re-evaluate the current phase's "everyone is done" gate and advance if it
is now satisfied. Called after the roster changes (a kick) so a vanished
player can't leave the table stuck on a step they'll never complete."""
from . import crud_character as cc
phase = game.phase
if phase == "character_creation":
cc.maybe_trigger_technique_swap(db, game)
elif phase == "swap_techniques":
cc.maybe_finish_technique_swap(db, game)
elif phase == "assign_techniques":
cc.maybe_finish_assign_techniques(db, game)
elif phase == "between_scenes":
maybe_transition_to_deep_upkeep(db, game)
elif phase == "deep_upkeep":
maybe_advance_after_deep_upkeep(db, game)
elif phase == "recruit_creation":
cc.maybe_finish_recruit_creation(db, game)
def _remove_player(db: Session, game: Game, target: Player, event_message: str,
captain_reason: str, event_kind: str = "warning"):
"""Shared mechanics for taking a player out of a game (a kick or a voluntary
leave): drop their captaincy, votes and Challenges, repair everyone else's
references to them, then delete the row. Their hand and any PvP temp card
simply stop being "in play", so reshuffle_discard_pile folds them back into
the deck as discards — no hand clearing needed. Finally re-check the current
phase's completion gate so a departed blocker can't stall the table."""
from . import crud_character as cc
target_id = target.id
logger.info("Removing player %s (%r) from game %s (%s)", target_id, target.name, game.id, event_kind)
if game.captain_player_id == target_id:
set_captain(db, game, None, reason=captain_reason)
for v in list(game.votes):
if target_id in (v.voter_player_id, v.nominated_player_id):
db.delete(v)
for ch in list(game.challenges):
if target_id in (ch.target_player_id, ch.acting_player_id, ch.challenger_player_id, ch.tax_target_id):
db.delete(ch)
db.commit()
cc.repair_references_to_removed_player(db, game, target_id)
db.delete(target)
db.commit()
db.refresh(game)
add_game_event(db, game.id, event_message, kind=event_kind)
recheck_phase_completion(db, game)
def kick_player(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
"""An Admin removes a player mid-game (e.g. one who vanished) so the table
isn't left waiting on them."""
if target.game_id != game.id:
return False, "That player isn't in this crew."
admin_count = sum(1 for p in game.players if p.is_admin)
if target.is_admin and admin_count <= 1:
return False, "You can't kick the last Admin. Grant someone else Admin first."
name = target.name
_remove_player(db, game, target,
f"{name} was kicked from the crew. Their cards return to the discard.",
f"{name} was kicked")
return True, f"{name} was kicked from the crew."
def leave_game(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
"""A player removes themselves from the game. Same mechanics as a kick; the
last Admin must hand off Admin first so the table isn't left without one."""
if target.game_id != game.id:
return False, "You're not in this crew."
admin_count = sum(1 for p in game.players if p.is_admin)
if target.is_admin and admin_count <= 1:
return False, "You're the last Admin — grant someone else Admin before you leave."
name = target.name
_remove_player(db, game, target,
f"{name} left the crew. Their cards return to the discard.",
f"{name} left", event_kind="join")
return True, f"{name} left the crew."

View File

@@ -1,7 +1,11 @@
from sqlmodel import SQLModel, create_engine, Session
import os import os
from pathlib import Path from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect, text
from sqlmodel import Session, create_engine
# Allow configuring via environment variable # Allow configuring via environment variable
sqlite_url = os.environ.get("DATABASE_URL") sqlite_url = os.environ.get("DATABASE_URL")
if not sqlite_url: if not sqlite_url:
@@ -11,8 +15,68 @@ if not sqlite_url:
# Use connect_args={"check_same_thread": False} for SQLite in FastAPI # Use connect_args={"check_same_thread": False} for SQLite in FastAPI
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False}) engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
def create_db_and_tables(): MIGRATIONS_DIR = Path(__file__).parent / "migrations"
SQLModel.metadata.create_all(engine) # The revision that matches the schema produced by the pre-Alembic
# create_all + ad-hoc ALTER TABLE era.
BASELINE_REVISION = "0001"
def _alembic_config() -> Config:
cfg = Config()
cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
cfg.attributes["engine"] = engine
return cfg
def _upgrade_legacy_db() -> None:
"""Bring a pre-Alembic database up to the baseline (0001) schema.
This is the old ad-hoc migration list, kept only so existing databases
can be stamped at the baseline. New columns go in Alembic revisions,
not here.
"""
with Session(engine) as session:
columns = [
("game", "crew_name", "VARCHAR"),
("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"),
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
("game", "captain_player_id", "VARCHAR"),
("player", "needs_name", "BOOLEAN DEFAULT FALSE"),
("player", "needs_rank_3_bonus", "BOOLEAN DEFAULT FALSE"),
("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
("player", "player_name", "VARCHAR DEFAULT ''"),
("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
]
for table, col, def_type in columns:
try:
session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}"))
except Exception:
pass # Column already exists
# Backfill: pre-existing players used a single name for both player and Pi-Rat
session.execute(
text("UPDATE player SET player_name = name WHERE player_name = '' OR player_name IS NULL")
)
session.commit()
def run_migrations() -> None:
"""Migrate the database to the latest schema.
Fresh databases are built entirely by Alembic. Databases that predate
Alembic (tables exist but no alembic_version) are first normalized to
the baseline schema, stamped, and then upgraded normally.
"""
cfg = _alembic_config()
inspector = inspect(engine)
if inspector.has_table("game") and not inspector.has_table("alembic_version"):
_upgrade_legacy_db()
command.stamp(cfg, BASELINE_REVISION)
command.upgrade(cfg, "head")
def get_session(): def get_session():
with Session(engine) as session: with Session(engine) as session:

View File

@@ -1,54 +1,256 @@
import json import asyncio
from typing import List, Optional import contextlib
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status from contextlib import asynccontextmanager
from fastapi.responses import RedirectResponse, HTMLResponse 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 fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlmodel import Session from sqlmodel import Session
import logging
from logging.handlers import RotatingFileHandler
import re
import sys
import uvicorn import uvicorn
import os
from pathlib import Path from pathlib import Path
from . import crud from . import crud
from . import cards from . import maintenance
from .database import create_db_and_tables, get_session from .database import run_migrations, get_session, engine
from .models import Game, Player, Obstacle, Challenge, Vote from .validation import sanitize_name
from .ws import manager
app = FastAPI(title="Rats with Gats Remote Play") logger = logging.getLogger(__name__)
# Get the directory of this module EVENT_PAGE_SIZE = 50
BASE_DIR = Path(__file__).resolve().parent
# Mount static files BASE_DIR = Path(__file__).parent
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
# Templates setup _logging_configured = False
templates = Jinja2Templates(directory=BASE_DIR / "templates")
# Register startup event to create tables def configure_logging() -> None:
@app.on_event("startup") """Set up backend logging: always to stderr, plus a rotating file when
def on_startup(): PIRATS_LOG_FILE is set (the NixOS service points it at /var/log/pirats/).
create_db_and_tables() Level comes from PIRATS_LOG_LEVEL (default INFO). Idempotent so it's safe to
call from both the CLI entrypoint and the app's startup lifespan."""
global _logging_configured
if _logging_configured:
return
_logging_configured = True
# --- HTTP Route Handlers --- level_name = os.environ.get("PIRATS_LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
@app.get("/", response_class=HTMLResponse) root = logging.getLogger()
def home(request: Request): root.setLevel(level)
return templates.TemplateResponse("index.html", {"request": request}) fmt = logging.Formatter(
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
@app.post("/game/create") stderr_handler = logging.StreamHandler(sys.stderr)
def create_game_route(db: Session = Depends(get_session)): stderr_handler.setFormatter(fmt)
game = crud.create_game(db) root.addHandler(stderr_handler)
# Redirect creator to the join page with creator flag
return RedirectResponse(url=f"/game/{game.id}/join?creator=true", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/game/{game_id}/join", response_class=HTMLResponse) log_file = os.environ.get("PIRATS_LOG_FILE")
def join_game_form(request: Request, game_id: str, creator: bool = False, db: Session = Depends(get_session)): if log_file:
game = crud.get_game(db, game_id) try:
if not game: Path(log_file).parent.mkdir(parents=True, exist_ok=True)
raise HTTPException(status_code=404, detail="Game not found") file_handler = RotatingFileHandler(
return templates.TemplateResponse("join_form.html", {"request": request, "game": game, "creator": creator}) log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
)
file_handler.setFormatter(fmt)
root.addHandler(file_handler)
logger.info("Logging to %s", log_file)
except OSError:
# A bad/unwritable path mustn't take the server down — keep stderr.
logger.exception("Could not open log file %s; logging to stderr only", log_file)
@app.post("/game/{game_id}/join") DEFAULT_MAX_BODY_BYTES = 1024 * 1024 # 1 MiB — far above any legitimate form post
def max_body_bytes() -> int:
"""Largest request body to accept, from PIRATS_MAX_BODY_BYTES (default 1 MiB)."""
val = os.environ.get("PIRATS_MAX_BODY_BYTES")
if val is None:
return DEFAULT_MAX_BODY_BYTES
try:
return max(int(val), 0)
except ValueError:
logger.warning("Invalid PIRATS_MAX_BODY_BYTES=%r; using default", val)
return DEFAULT_MAX_BODY_BYTES
class _BodyTooLarge(Exception):
pass
class LimitRequestBodyMiddleware:
"""Reject request bodies larger than `max_bytes` with HTTP 413 before they are
buffered into memory. The app is on the open internet, so this caps the damage
a single oversized POST can do. The declared Content-Length is checked up front
(the common case); the bytes actually streamed are then counted too, so a
chunked or length-omitting client can't slip past the header check."""
def __init__(self, app, max_bytes: int):
self.app = app
self.max_bytes = max_bytes
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
for name, value in scope.get("headers", []):
if name == b"content-length":
try:
declared = int(value)
except ValueError:
break
if declared > self.max_bytes:
await self._send_413(send)
return
break
received = 0
response_started = False
async def capped_receive():
nonlocal received
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > self.max_bytes:
raise _BodyTooLarge()
return message
async def watched_send(message):
nonlocal response_started
if message["type"] == "http.response.start":
response_started = True
await send(message)
try:
await self.app(scope, capped_receive, watched_send)
except _BodyTooLarge:
# The handler reads the whole body before responding, so nothing has
# been sent yet; if a response somehow already started, we can only stop.
if not response_started:
await self._send_413(send)
async def _send_413(self, send):
body = b'{"error":"Request body too large."}'
await send({
"type": "http.response.start",
"status": 413,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
],
})
await send({"type": "http.response.body", "body": body})
@asynccontextmanager
async def lifespan(app: FastAPI):
configure_logging()
logger.info(
"Starting Rats with Gats backend (db=%s, dev_mode_default=%s)",
engine.url.render_as_string(hide_password=True),
crud.dev_mode_default(),
)
run_migrations()
logger.info("Database migrations applied")
purge_cfg = maintenance.purge_config()
purge_task = None
if purge_cfg.enabled:
purge_task = asyncio.create_task(maintenance.purge_loop(purge_cfg))
else:
logger.info("Game purge task disabled (PIRATS_PURGE_ENABLED)")
yield
if purge_task is not None:
purge_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await purge_task
logger.info("Shutting down")
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)
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):
response = await call_next(request)
if request.method == "POST" and response.status_code < 400:
match = GAME_PATH_RE.match(request.url.path)
if match:
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
# Added last so it wraps outermost: oversized bodies are rejected before any
# other middleware or the route handler buffers them.
app.add_middleware(LimitRequestBodyMiddleware, max_bytes=max_body_bytes())
@app.websocket("/api/game/{game_id}/ws")
async def game_websocket(websocket: WebSocket, game_id: str):
await manager.connect(game_id, websocket)
try:
while True:
# Clients don't send anything meaningful; this just detects disconnects.
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.disconnect(game_id, websocket)
# --- API Core Route Handlers ---
@api.post("/game")
def create_game_route(
crew_name: str = Form(...),
db: Session = Depends(get_session)
):
game = crud.create_game(db, crew_name=sanitize_name(crew_name))
# admin_key is only revealed here, to the creator; the state endpoint hides it
return {"id": game.id, "admin_key": game.admin_key}
@api.get("/game/code/{join_code}")
def find_game_by_join_code(join_code: str, db: Session = Depends(get_session)):
normalized = join_code.strip().upper()
game = crud.get_game_by_join_code(db, normalized)
if not game or game.phase == "ended":
raise HTTPException(status_code=404, detail="No active game found for that join code")
return {"id": game.id}
@api.post("/game/{game_id}/join")
def join_game_submit( def join_game_submit(
game_id: str, game_id: str,
name: str = Form(...), name: str = Form(...),
@@ -60,635 +262,97 @@ def join_game_submit(
# First player is automatically the creator # First player is automatically the creator
is_creator = len(game.players) == 0 is_creator = len(game.players) == 0
player = crud.add_player(db, game_id, name.strip(), is_creator=is_creator) player = crud.add_player(db, game_id, sanitize_name(name), is_creator=is_creator)
return {"id": player.id}
return RedirectResponse(url=f"/game/{game_id}/player/{player.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/game/{game_id}/player/{player_id}", response_class=HTMLResponse) @api.post("/game/{game_id}/player/{player_id}/leave")
def player_dashboard(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)): def leave_game_route(game_id: str, player_id: str, db: Session = Depends(get_session)):
"""A player voluntarily removes themselves from the game (counterpart to join)."""
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.leave_game(db, game, player)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
@api.get("/game/{game_id}/player/{player_id}/state")
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id) game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id) player = crud.get_player(db, player_id)
if not game or not player: if not game or not player:
raise HTTPException(status_code=404, detail="Game or Player not found") raise HTTPException(status_code=404, detail="Game or Player not found")
return templates.TemplateResponse("dashboard.html", { events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
"request": request,
"game": game,
"player": player
})
# Creator Admin Panel return {
@app.get("/game/{game_id}/admin", response_class=HTMLResponse) # admin_key stays hidden except from Admins, who need it to open the admin panel
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)): "game": game.model_dump(exclude=(set() if player.is_admin else {"admin_key"})),
game = crud.get_game(db, game_id) "player": player.model_dump(),
if not game: # A swap offer must reveal nothing but its existence: only the offerer
raise HTTPException(status_code=404, detail="Game not found") # gets to see which technique they put on the table.
if game.admin_key != key: "players": [p.model_dump(exclude=(set() if p.id == player.id else {"swap_offer_technique"})) for p in game.players],
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key") "obstacles": [o.model_dump() for o in game.obstacles],
"challenges": [c.model_dump() for c in game.challenges],
base_url = str(request.base_url).rstrip("/") "votes": [v.model_dump() for v in game.votes],
return templates.TemplateResponse("admin.html", { "events": [e.model_dump() for e in events],
"request": request, "events_have_more": events_have_more,
"game": game, # The newest checkpoint id, so the log can tell which event is "you are here".
"base_url": base_url "latest_checkpoint_id": crud.latest_checkpoint_id(db, game_id),
})
# --- HTMX Snippet and Phase Polling Routes ---
@app.get("/game/{game_id}/player/{player_id}/phase-check")
def phase_check_route(game_id: str, player_id: str, current: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
if game.phase != current:
# Phase changed! Trigger page reload on client via HTMX header
return HTMLResponse(status_code=200, headers={"HX-Trigger": "phase-changed"})
return HTMLResponse(status_code=204) # No content, no reload
@app.get("/game/{game_id}/lobby/players", response_class=HTMLResponse)
def lobby_players_snippet(request: Request, game_id: str, player_id: Optional[str] = None, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
return HTMLResponse("")
return templates.TemplateResponse("lobby_players_snippet.html", {
"request": request,
"game": game,
"player_id": player_id
})
@app.get("/game/{game_id}/scene/roster", response_class=HTMLResponse)
def scene_roster_snippet(request: Request, game_id: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
return HTMLResponse("")
return templates.TemplateResponse("scene_roster_snippet.html", {
"request": request,
"game": game,
"is_captain": crud.is_player_captain
})
@app.get("/game/{game_id}/scene/obstacles", response_class=HTMLResponse)
def scene_obstacles_snippet(request: Request, game_id: str, player_rank: int, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
return HTMLResponse("")
return templates.TemplateResponse("scene_obstacles_snippet.html", {
"request": request,
"game": game,
"player_rank": player_rank,
"parse_card": cards.parse_card,
"json_loads": json.loads
})
@app.get("/game/{game_id}/scene/challenges", response_class=HTMLResponse)
def scene_challenges_snippet(request: Request, game_id: str, player_id: str, 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:
return HTMLResponse("")
def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]:
return db.get(Obstacle, obs_id)
hand = crud.get_player_hand(player)
return templates.TemplateResponse("scene_challenges_snippet.html", {
"request": request,
"game": game,
"player": player,
"hand": hand,
"parse_card": cards.parse_card,
"get_obstacle_by_id": get_obstacle_by_id,
"json_loads": json.loads
})
@app.get("/game/{game_id}/between/roster", response_class=HTMLResponse)
def between_scenes_roster_snippet(request: Request, game_id: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
return HTMLResponse("")
return templates.TemplateResponse("voting_roster_snippet.html", {
"request": request,
"game": game
})
# --- HTMX View Polling Render Route ---
@app.get("/game/{game_id}/player/{player_id}/view", response_class=HTMLResponse)
def get_game_phase_view(request: Request, game_id: str, player_id: str, 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:
return HTMLResponse("<p class='alert alert-danger'>Session disconnected or expired.</p>")
base_url = str(request.base_url).rstrip("/")
# Context helper functions to run inside Jinja templates
def get_player_name_by_id(*args) -> str:
pid = args[1] if len(args) == 2 else (args[0] if len(args) == 1 else None)
if not pid:
return "None"
p = db.get(Player, pid)
return p.name if p else "Unknown"
def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]:
return db.get(Obstacle, obs_id)
# Core variables
hand = crud.get_player_hand(player)
max_hand_size = crud.calculate_max_hand_size(player, game.players)
# Compute inbox tasks for current player
inbox_tasks = []
for p in game.players:
if p.id != player.id:
if p.other_like_from_player_id == player.id and not p.other_like:
inbox_tasks.append({"player": p, "type": "like"})
if p.other_hate_from_player_id == player.id and not p.other_hate:
inbox_tasks.append({"player": p, "type": "hate"})
context = {
"request": request,
"game": game,
"player": player,
"hand": hand,
"max_hand_size": max_hand_size,
"base_url": base_url,
"db": db,
"get_player_name": get_player_name_by_id,
"get_obstacle_by_id": get_obstacle_by_id,
"parse_card": cards.parse_card,
"is_captain": crud.is_player_captain,
"json_loads": json.loads,
"deck_count": len(crud.get_game_deck(game)),
"inbox_tasks": inbox_tasks
} }
# Select template based on current game phase
if game.phase == "lobby":
return templates.TemplateResponse("lobby_partial.html", context)
elif game.phase in ["character_creation", "swap_techniques"]:
return templates.TemplateResponse("character_creation_partial.html", context)
elif game.phase == "scene_setup":
return templates.TemplateResponse("scene_setup_partial.html", context)
elif game.phase == "scene":
return templates.TemplateResponse("scene_partial.html", context)
elif game.phase == "between_scenes":
return templates.TemplateResponse("between_scenes_partial.html", context)
return HTMLResponse("<p>Unknown game state.</p>")
# --- HTMX Action Handlers --- @api.get("/game/{game_id}/events")
def get_game_events(
# 1. Start character creation from lobby game_id: str,
@app.post("/game/{game_id}/lobby/start") before: Optional[float] = None,
def lobby_start_character_creation(game_id: str, db: Session = Depends(get_session)): limit: int = EVENT_PAGE_SIZE,
db: Session = Depends(get_session),
):
"""Pages back through the event log: returns the newest `limit` events older than `before`."""
game = crud.get_game(db, game_id) game = crud.get_game(db, game_id)
if not game: if not game:
raise HTTPException(status_code=404, detail="Game not found") raise HTTPException(status_code=404, detail="Game not found")
game.phase = "character_creation"
db.add(game)
db.commit()
return HTMLResponse(status_code=204) # Return No Content, polling redirects
# 2. Save basic character details limit = max(1, min(limit, 200))
@app.post("/game/{game_id}/player/{player_id}/save-basic") events, have_more = crud.get_events_page(db, game_id, before=before, limit=limit)
def player_save_basic_details( return {
game_id: str, "events": [e.model_dump() for e in events],
player_id: str, "have_more": have_more,
avatar_look: str = Form(...), }
avatar_smell: str = Form(...),
first_words: str = Form(...),
good_at_math: str = Form(...),
db: Session = Depends(get_session)
):
player = crud.get_player(db, player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
player.avatar_look = avatar_look.strip()
player.avatar_smell = avatar_smell.strip()
player.first_words = first_words.strip()
player.good_at_math = good_at_math
db.add(player)
db.commit()
return HTMLResponse(status_code=204)
# 3. Edit basic character details (reset values to trigger form view) # --- Register Modular Phase Routers ---
@app.post("/game/{game_id}/player/{player_id}/edit-basic") from .routes_lobby import router as lobby_router
def player_edit_basic_details( from .routes_character import router as character_router
game_id: str, from .routes_scene import router as scene_router
player_id: str, from .routes_challenge import router as challenge_router
db: Session = Depends(get_session) from .routes_upkeep import router as upkeep_router
): from .routes_admin import router as admin_router
player = crud.get_player(db, player_id) from .routes_rollback import router as rollback_router
if not player:
raise HTTPException(status_code=404, detail="Player not found")
player.avatar_look = ""
player.avatar_smell = ""
player.first_words = ""
db.add(player)
db.commit()
# Trigger HTMX reload
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates api.include_router(lobby_router)
@app.post("/game/{game_id}/player/{player_id}/delegate/auto") api.include_router(character_router)
def player_auto_delegate( api.include_router(scene_router)
game_id: str, api.include_router(challenge_router)
player_id: str, api.include_router(upkeep_router)
db: Session = Depends(get_session) api.include_router(admin_router)
): api.include_router(rollback_router)
import random
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player:
raise HTTPException(status_code=404, detail="Not found")
other_players = [p for p in game.players if p.id != player.id]
if other_players:
# Assign 'like' if not assigned
if not player.other_like_from_player_id:
like_target = random.choice(other_players)
player.other_like_from_player_id = like_target.id
player.other_like = ""
# Assign 'hate' if not assigned
if not player.other_hate_from_player_id:
if len(other_players) >= 2:
# Try to pick a different target than the like target
remaining = [op for op in other_players if op.id != player.other_like_from_player_id]
hate_target = random.choice(remaining)
else:
hate_target = random.choice(other_players)
player.other_hate_from_player_id = hate_target.id
player.other_hate = ""
db.add(player)
db.commit()
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
# Get Crew Creation Status (polling endpoint) # Mount API at /api
@app.get("/game/{game_id}/player/{player_id}/crew-status", response_class=HTMLResponse) app.include_router(api, prefix="/api")
def player_crew_status(
request: Request,
game_id: str,
player_id: str,
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:
raise HTTPException(status_code=404, detail="Not found")
def get_player_name_by_id(pid: Optional[str]) -> str:
if not pid:
return "None"
p = db.get(Player, pid)
return p.name if p else "Unknown"
return templates.TemplateResponse("crew_status_snippet.html", {
"request": request,
"game": game,
"player": player,
"json_loads": json.loads,
"get_player_name": get_player_name_by_id
})
# 4. Delegate question 5/6 to another player # Static rulebook page (registered before the SPA mount so it takes precedence)
@app.post("/game/{game_id}/player/{player_id}/delegate/{question_type}", response_class=HTMLResponse) @app.get("/rules", include_in_schema=False)
def player_delegate_question( def rules_page():
request: Request, return FileResponse(BASE_DIR / "rules.html", media_type="text/html")
game_id: str,
player_id: str,
question_type: str, # "like" or "hate"
target_player_id: str = Form(...),
db: Session = Depends(get_session)
):
crud.delegate_question(db, player_id, question_type, target_player_id)
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player:
raise HTTPException(status_code=404, detail="Not found")
def get_player_name_by_id(pid: Optional[str]) -> str:
if not pid:
return "None"
p = db.get(Player, pid)
return p.name if p else "Unknown"
return templates.TemplateResponse("delegation_snippet.html", {
"request": request,
"game": game,
"player": player,
"question_type": question_type,
"get_player_name": get_player_name_by_id
})
# Revoke a delegated question task # Mount SPA
@app.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}", response_class=HTMLResponse) static_dir = BASE_DIR / "static"
def player_revoke_delegation( if os.path.exists(static_dir):
request: Request, app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
game_id: str,
player_id: str,
question_type: str, # "like" or "hate"
db: Session = Depends(get_session)
):
player = crud.get_player(db, player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
if question_type == "like":
player.other_like_from_player_id = None
player.other_like = ""
elif question_type == "hate":
player.other_hate_from_player_id = None
player.other_hate = ""
db.add(player)
db.commit()
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
def get_player_name_by_id(pid: Optional[str]) -> str:
if not pid:
return "None"
p = db.get(Player, pid)
return p.name if p else "Unknown"
return templates.TemplateResponse("delegation_snippet.html", {
"request": request,
"game": game,
"player": player,
"question_type": question_type,
"get_player_name": get_player_name_by_id
})
# Fetch the delegation card status (for background polling when waiting)
@app.get("/game/{game_id}/player/{player_id}/delegation/{question_type}", response_class=HTMLResponse)
def player_get_delegation_status(
request: Request,
game_id: str,
player_id: str,
question_type: str, # "like" or "hate"
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:
raise HTTPException(status_code=404, detail="Not found")
def get_player_name_by_id(pid: Optional[str]) -> str:
if not pid:
return "None"
p = db.get(Player, pid)
return p.name if p else "Unknown"
return templates.TemplateResponse("delegation_snippet.html", {
"request": request,
"game": game,
"player": player,
"question_type": question_type,
"get_player_name": get_player_name_by_id
})
# 5. Answer a delegated question for another player
@app.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
def player_submit_delegated_answer(
request: Request,
game_id: str,
player_id: str, # The answering player
target_player_id: str, # The player whose sheet we are filling out
question_type: str, # "like" or "hate"
answer: str = Form(...),
db: Session = Depends(get_session)
):
crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id)
# Re-fetch data to render the updated inbox snippet
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player:
raise HTTPException(status_code=404, detail="Not found")
# Compute inbox tasks
inbox_tasks = []
for p in game.players:
if p.id != player.id:
if p.other_like_from_player_id == player.id and not p.other_like:
inbox_tasks.append({"player": p, "type": "like"})
if p.other_hate_from_player_id == player.id and not p.other_hate:
inbox_tasks.append({"player": p, "type": "hate"})
return templates.TemplateResponse("inbox_snippet.html", {
"request": request,
"game": game,
"player": player,
"inbox_tasks": inbox_tasks
})
# 6. Submit 3 Secret Pirate Techniques
@app.post("/game/{game_id}/player/{player_id}/submit-techniques")
def player_submit_techniques(
request: Request,
game_id: str,
player_id: str,
tech1: str = Form(...),
tech2: str = Form(...),
tech3: str = Form(...),
db: Session = Depends(get_session)
):
crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip())
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player:
raise HTTPException(status_code=404, detail="Not found")
return templates.TemplateResponse("techniques_snippet.html", {
"request": request,
"game": game,
"player": player,
"json_loads": json.loads
})
# 7. Assign Swapped Techniques to Face Cards (Jack, Queen, King)
@app.post("/game/{game_id}/player/{player_id}/assign-face-techniques")
def player_assign_face_techniques(
request: Request,
game_id: str,
player_id: str,
jack: str = Form(...),
queen: str = Form(...),
king: str = Form(...),
db: Session = Depends(get_session)
):
if len({jack, queen, king}) < 3:
# Prevent assigning the same technique to multiple face cards
return HTMLResponse("<p class='alert alert-danger'>Error: Each card must be assigned a unique technique.</p>", status_code=400)
crud.assign_face_card_techniques(db, player_id, jack, queen, king)
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player:
raise HTTPException(status_code=404, detail="Not found")
return templates.TemplateResponse("techniques_snippet.html", {
"request": request,
"game": game,
"player": player,
"json_loads": json.loads
})
# 8. Set Role during Scene Setup
@app.post("/game/{game_id}/player/{player_id}/set-role")
def player_set_role(
game_id: str,
player_id: str,
role: str,
db: Session = Depends(get_session)
):
crud.update_scene_role(db, player_id, role)
return HTMLResponse(status_code=204)
# 9. Start Scene (Verify Setup & Shuffle & Draw Obstacles)
@app.post("/game/{game_id}/scene/start")
def start_scene_route(request: Request, game_id: str, db: Session = Depends(get_session)):
success, msg = crud.confirm_scene_setup(db, game_id)
if not success:
# Render the setup partial again with the error message
game = crud.get_game(db, game_id)
# We need a player to render the partial, let's grab any player or the one who requested it.
# But we don't have the player_id in this path. Let's make sure we pass the error message,
# or we just return an HTTP 400 with the error text so HTMX displays it!
return HTMLResponse(f"<div class='alert alert-danger'>{msg}</div>", status_code=400)
return HTMLResponse(status_code=204)
# 10. Deep creates a new challenge
@app.post("/game/{game_id}/challenge/create")
def create_challenge_route(
game_id: str,
title: str = Form(...),
description: str = Form(""),
obstacle_ids: List[str] = Form(...),
db: Session = Depends(get_session)
):
crud.create_challenge(db, game_id, title.strip(), description.strip(), obstacle_ids)
return HTMLResponse(status_code=204)
# 11. Pi-Rat plays card against an obstacle in a challenge
@app.post("/game/{game_id}/player/{player_id}/play-card")
def play_card_route(
game_id: str,
player_id: str,
challenge_id: str = Form(...),
obstacle_id: str = Form(...),
card_code: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code, challenge_id)
if not ok:
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
# Return a success notification box that HTMX will show, or just return 204 to let polling pick it up.
# Returning a message is nice! But returning 204 is clean. Let's return a brief HTML snippet.
color_prefix = "🟩" if res["success"] else "🟥"
drew_text = f" (Drew card: {res['drew_card']})" if res["drew_card"] else ""
return HTMLResponse(
f"<div class='play-notification' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
f"<p>{color_prefix} {res['details']}{drew_text}</p>"
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
f"</div>"
)
# 12. Pi-Rat plays a Joker to replace an obstacle
@app.post("/game/{game_id}/player/{player_id}/play-joker")
def play_joker_route(
game_id: str,
player_id: str,
card_code: str = Form(...),
obstacle_id: str = Form(...),
db: Session = Depends(get_session)
):
# Retrieve any active challenge that contains this obstacle to use as a dummy,
# or let play_card_on_obstacle handle challenge_id=None.
# Wait, we can pass challenge_id = "" and check in play_card if it exists.
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code, "")
if not ok:
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
return HTMLResponse(
f"<div class='play-notification' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
f"<p>🃏 Play Joker: Discarded obstacle and drew a new replacement!</p>"
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
f"</div>"
)
# 13. Toggle Objective Completion Checklist
@app.post("/game/{game_id}/player/{player_id}/objective/toggle")
def toggle_objective_route(
game_id: str,
player_id: str,
type: str, # "personal_1", "personal_2", "personal_3"
db: Session = Depends(get_session)
):
player = crud.get_player(db, player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
# Toggle logic
current_status = False
if type == "personal_1":
current_status = player.completed_personal_1
elif type == "personal_2":
current_status = player.completed_personal_2
elif type == "personal_3":
current_status = player.completed_personal_3
crud.toggle_objective(db, player_id, type, not current_status)
return HTMLResponse(status_code=204)
# 14. Deep ends the scene
@app.post("/game/{game_id}/scene/end")
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
crud.end_scene_and_transition(db, game_id)
return HTMLResponse(status_code=204)
# 15. Cast vote to Rank Up a player
@app.post("/game/{game_id}/player/{player_id}/submit-vote")
def submit_vote_route(
game_id: str,
player_id: str,
nominated_id: str = Form(...),
db: Session = Depends(get_session)
):
crud.submit_rank_vote(db, game_id, player_id, nominated_id)
return HTMLResponse(status_code=204)
# 16. Player Ready for Next Scene
@app.post("/game/{game_id}/player/{player_id}/ready-next")
def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_session)):
player = crud.get_player(db, player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
player.is_ready = True
db.add(player)
db.commit()
# Check if all players in the game are ready. If so, transition to next scene!
game = crud.get_game(db, game_id)
if game and all(p.is_ready for p in game.players):
crud.proceed_to_next_scene_setup(db, game_id)
return HTMLResponse(status_code=204)
def main(): def main():
configure_logging()
import argparse import argparse
parser = argparse.ArgumentParser(description="Run the Rats with Gats web app") parser = argparse.ArgumentParser(description="Run the Rats with Gats web app")
parser.add_argument("--host", default="0.0.0.0", help="Host address to bind to") parser.add_argument("--host", default="0.0.0.0", help="Host address to bind to")

204
src/pirats/maintenance.py Normal file
View File

@@ -0,0 +1,204 @@
"""Periodic database maintenance: purge old finished/inactive games.
A game is purged when it either finished more than `finished_days` ago or has
had no activity for `inactive_days`. "Activity" is the timestamp of a game's
most recent GameEvent (every meaningful move records one); for an ended game
that last event is its finish, so the same signal serves both windows. Games
with no events at all are undatable and left alone.
The purge runs in-process on a background asyncio loop (started from the app
lifespan); all knobs are configurable via environment variables / the NixOS
service. Every purge is logged with the crew name, uuid and an estimate of the
bytes it held, and each run that removes anything VACUUMs the SQLite file and
logs the disk space actually reclaimed.
"""
import asyncio
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
from sqlalchemy import func
from sqlmodel import Session, select
from .database import engine
from .models import Game, GameEvent
logger = logging.getLogger(__name__)
DAY_SECONDS = 86400.0
@dataclass(frozen=True)
class PurgeConfig:
enabled: bool
interval_hours: float
finished_days: float
inactive_days: float
def _env_bool(name: str, default: bool) -> bool:
val = os.environ.get(name)
if val is None:
return default
return val.strip().lower() in ("1", "true", "yes", "on")
def _env_float(name: str, default: float) -> float:
val = os.environ.get(name)
if val is None:
return default
try:
return float(val)
except ValueError:
logger.warning("Invalid %s=%r; using default %s", name, val, default)
return default
def purge_config() -> PurgeConfig:
"""Read purge settings from the environment (see README for the knobs)."""
return PurgeConfig(
enabled=_env_bool("PIRATS_PURGE_ENABLED", True),
interval_hours=_env_float("PIRATS_PURGE_INTERVAL_HOURS", 24.0),
finished_days=_env_float("PIRATS_PURGE_FINISHED_DAYS", 14.0),
inactive_days=_env_float("PIRATS_PURGE_INACTIVE_DAYS", 30.0),
)
def _db_file_path() -> Optional[Path]:
"""The on-disk SQLite file, or None for in-memory / non-file databases."""
name = engine.url.database
if not name or name == ":memory:":
return None
return Path(name)
def _estimate_game_bytes(game: Game) -> int:
"""A rough (uncompressed) byte footprint of a game and all its rows, summed
from each row's serialized columns. Reported per purge; the dominant term is
any leftover rollback checkpoints' state_json."""
total = len(json.dumps(game.model_dump(), default=str))
for rows in (game.players, game.obstacles, game.votes, game.events,
game.challenges, game.checkpoints):
for row in rows:
total += len(json.dumps(row.model_dump(), default=str))
return total
def purge_old_games(db: Session, *, finished_days: float, inactive_days: float,
now: Optional[float] = None) -> List[dict]:
"""Delete games that finished more than `finished_days` ago or have been
inactive for `inactive_days`. Returns a summary dict per purged game and
logs each one. Does not VACUUM (the caller does, once per run)."""
import time
if now is None:
now = time.time()
finished_cutoff = now - finished_days * DAY_SECONDS
inactive_cutoff = now - inactive_days * DAY_SECONDS
# Most-recent event timestamp per game, in one grouped query.
last_activity = {
gid: ts
for gid, ts in db.exec(
select(GameEvent.game_id, func.max(GameEvent.timestamp)).group_by(GameEvent.game_id)
).all()
}
purged: List[dict] = []
for game in db.exec(select(Game)).all():
last = last_activity.get(game.id)
if last is None:
# No events ever recorded: we can't date it, so leave it be.
continue
finished = game.phase == "ended" and last < finished_cutoff
inactive = last < inactive_cutoff
if not (finished or inactive):
continue
est_bytes = _estimate_game_bytes(game)
age_days = (now - last) / DAY_SECONDS
reason = "finished" if finished else "inactive"
logger.info(
"Purging game %s (crew=%r, phase=%s, reason=%s, idle %.1f days, ~%d bytes)",
game.id, game.crew_name, game.phase, reason, age_days, est_bytes,
)
# _estimate_game_bytes loaded every relationship, so the ORM's
# cascade_delete (set on each Game relationship) deletes all the
# children along with the game — no DB-level FK pragma needed.
db.delete(game)
db.commit()
purged.append({
"id": game.id,
"crew_name": game.crew_name,
"phase": game.phase,
"reason": reason,
"idle_days": age_days,
"estimated_bytes": est_bytes,
})
return purged
def _vacuum() -> None:
"""Reclaim the pages freed by the deletes. VACUUM cannot run inside a
transaction, hence the autocommit connection."""
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
conn.exec_driver_sql("VACUUM")
def run_purge(*, finished_days: float, inactive_days: float,
now: Optional[float] = None) -> List[dict]:
"""One full purge cycle: delete stale games, then VACUUM and report the
disk space actually reclaimed from the SQLite file."""
db_path = _db_file_path()
size_before = db_path.stat().st_size if db_path and db_path.exists() else None
with Session(engine) as db:
purged = purge_old_games(
db, finished_days=finished_days, inactive_days=inactive_days, now=now
)
if not purged:
logger.debug("Game purge: nothing to remove")
return purged
if db_path and db_path.exists():
_vacuum()
size_after = db_path.stat().st_size
reclaimed = (size_before - size_after) if size_before is not None else None
logger.info(
"Game purge removed %d game(s); reclaimed %s bytes from %s",
len(purged),
reclaimed if reclaimed is not None else "?",
db_path,
)
else:
logger.info("Game purge removed %d game(s)", len(purged))
return purged
async def purge_loop(config: Optional[PurgeConfig] = None) -> None:
"""Background task: run a purge at startup, then every `interval_hours`."""
cfg = config or purge_config()
logger.info(
"Game purge task started: every %.1f h, finished > %.0f days, inactive > %.0f days",
cfg.interval_hours, cfg.finished_days, cfg.inactive_days,
)
interval_seconds = max(cfg.interval_hours, 0.0) * 3600.0
while True:
try:
from fastapi.concurrency import run_in_threadpool
await run_in_threadpool(
run_purge,
finished_days=cfg.finished_days,
inactive_days=cfg.inactive_days,
)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Game purge run failed")
await asyncio.sleep(interval_seconds)

View File

@@ -0,0 +1,65 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import create_engine
from sqlmodel import SQLModel
# Importing the models registers every table on SQLModel.metadata,
# which is what `alembic revision --autogenerate` diffs against.
from pirats import models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
target_metadata = SQLModel.metadata
def _get_engine():
# The app (pirats.database.run_migrations) passes its engine in via
# attributes; the alembic CLI falls back to the same engine the app
# would build (DATABASE_URL or ./rats_with_gats.db), unless an URL is
# set explicitly with `alembic -x url=...` or in alembic.ini.
engine = config.attributes.get("engine")
if engine is not None:
return engine
url = context.get_x_argument(as_dictionary=True).get("url") or config.get_main_option(
"sqlalchemy.url"
)
if url:
return create_engine(url)
from pirats.database import engine as app_engine
return app_engine
def run_migrations_offline() -> None:
context.configure(
url=str(_get_engine().url),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
with _get_engine().connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
# SQLite can't ALTER most things in place; batch mode rebuilds
# tables so autogenerated migrations actually run.
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,137 @@
"""Initial schema: the six tables as of 2026-06-12.
Pre-Alembic databases are brought to this exact shape by
pirats.database._upgrade_legacy_db() and then stamped at this revision,
so this migration only ever runs against an empty database.
Revision ID: 0001
Revises:
Create Date: 2026-06-12
"""
from alembic import op
import sqlalchemy as sa
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"game",
sa.Column("id", sa.String(), nullable=False),
sa.Column("admin_key", sa.String(), nullable=False),
sa.Column("crew_name", sa.String(), nullable=True),
sa.Column("phase", sa.String(), nullable=False),
sa.Column("current_scene_number", sa.Integer(), nullable=False),
sa.Column("extra_obstacles", sa.Integer(), nullable=False),
sa.Column("deck_cards", sa.String(), nullable=False),
sa.Column("completed_crew_1", sa.Boolean(), nullable=False),
sa.Column("completed_crew_2", sa.Boolean(), nullable=False),
sa.Column("completed_crew_3", sa.Boolean(), nullable=False),
sa.Column("captain_player_id", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"player",
sa.Column("id", sa.String(), nullable=False),
sa.Column("game_id", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("player_name", sa.String(), nullable=False),
sa.Column("is_ready", sa.Boolean(), nullable=False),
sa.Column("rank", sa.Integer(), nullable=False),
sa.Column("hand_cards", sa.String(), nullable=False),
sa.Column("role", sa.String(), nullable=True),
sa.Column("previous_role", sa.String(), nullable=True),
sa.Column("is_creator", sa.Boolean(), nullable=False),
sa.Column("avatar_look", sa.String(), nullable=False),
sa.Column("avatar_smell", sa.String(), nullable=False),
sa.Column("first_words", sa.String(), nullable=False),
sa.Column("good_at_math", sa.String(), nullable=False),
sa.Column("other_like", sa.String(), nullable=False),
sa.Column("other_like_from_player_id", sa.String(), nullable=True),
sa.Column("other_hate", sa.String(), nullable=False),
sa.Column("other_hate_from_player_id", sa.String(), nullable=True),
sa.Column("created_techniques", sa.String(), nullable=False),
sa.Column("swapped_techniques", sa.String(), nullable=False),
sa.Column("tech_jack", sa.String(), nullable=False),
sa.Column("tech_queen", sa.String(), nullable=False),
sa.Column("tech_king", sa.String(), nullable=False),
sa.Column("completed_personal_1", sa.Boolean(), nullable=False),
sa.Column("completed_personal_2", sa.Boolean(), nullable=False),
sa.Column("completed_personal_3", sa.Boolean(), nullable=False),
sa.Column("needs_name", sa.Boolean(), nullable=False),
sa.Column("needs_rank_3_bonus", sa.Boolean(), nullable=False),
sa.Column("is_dead", sa.Boolean(), nullable=False),
sa.Column("is_ghost", sa.Boolean(), nullable=False),
sa.Column("tax_banned", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_player_game_id"), "player", ["game_id"])
op.create_table(
"obstacle",
sa.Column("id", sa.String(), nullable=False),
sa.Column("game_id", sa.String(), nullable=False),
sa.Column("original_card", sa.String(), nullable=False),
sa.Column("suit", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("description", sa.String(), nullable=False),
sa.Column("current_value", sa.Integer(), nullable=False),
sa.Column("played_cards", sa.String(), nullable=False),
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_obstacle_game_id"), "obstacle", ["game_id"])
op.create_table(
"challenge",
sa.Column("id", sa.String(), nullable=False),
sa.Column("game_id", sa.String(), nullable=False),
sa.Column("challenge_type", sa.String(), nullable=False),
sa.Column("target_player_id", sa.String(), nullable=False),
sa.Column("acting_player_id", sa.String(), nullable=False),
sa.Column("challenger_player_id", sa.String(), nullable=True),
sa.Column("obstacle_ids", sa.String(), nullable=False),
sa.Column("temp_card", sa.String(), nullable=True),
sa.Column("stakes", sa.String(), nullable=False),
sa.Column("plays", sa.String(), nullable=False),
sa.Column("status", sa.String(), nullable=False),
sa.Column("tax_type", sa.String(), nullable=True),
sa.Column("tax_target_id", sa.String(), nullable=True),
sa.Column("tax_state", sa.String(), nullable=True),
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_challenge_game_id"), "challenge", ["game_id"])
op.create_table(
"vote",
sa.Column("id", sa.String(), nullable=False),
sa.Column("game_id", sa.String(), nullable=False),
sa.Column("voter_player_id", sa.String(), nullable=False),
sa.Column("nominated_player_id", sa.String(), nullable=False),
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_vote_game_id"), "vote", ["game_id"])
op.create_table(
"gameevent",
sa.Column("id", sa.String(), nullable=False),
sa.Column("game_id", sa.String(), nullable=False),
sa.Column("timestamp", sa.Float(), nullable=False),
sa.Column("message", sa.String(), nullable=False),
sa.Column("kind", sa.String(), nullable=False),
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_gameevent_game_id"), "gameevent", ["game_id"])
def downgrade() -> None:
op.drop_table("gameevent")
op.drop_table("vote")
op.drop_table("challenge")
op.drop_table("obstacle")
op.drop_table("player")
op.drop_table("game")

View File

@@ -0,0 +1,34 @@
"""add gat description to player
Revision ID: 153132c8e583
Revises: 5a6e73d2bc4f
Create Date: 2026-06-14 09:42:58.953909
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '153132c8e583'
down_revision = '5a6e73d2bc4f'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.add_column(sa.Column('gat_description', sqlmodel.sql.sqltypes.AutoString(), server_default="", nullable=False))
batch_op.add_column(sa.Column('needs_gat_description', sa.Boolean(), server_default=sa.false(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.drop_column('needs_gat_description')
batch_op.drop_column('gat_description')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""add teaching_deep_player_id to game
Revision ID: 19ee9ae5ca0e
Revises: e6294f6f8a81
Create Date: 2026-06-14 09:11:17.810765
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '19ee9ae5ca0e'
down_revision = 'e6294f6f8a81'
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('teaching_deep_player_id', sqlmodel.sql.sqltypes.AutoString(), 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('teaching_deep_player_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""add last_rank_up_player_id to game
Revision ID: 3bbf6812c261
Revises: 19ee9ae5ca0e
Create Date: 2026-06-14 09:21:11.037751
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '3bbf6812c261'
down_revision = '19ee9ae5ca0e'
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('last_rank_up_player_id', sqlmodel.sql.sqltypes.AutoString(), 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('last_rank_up_player_id')
# ### end Alembic commands ###

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,34 @@
"""add success/failure stakes to challenge
Revision ID: 5a6e73d2bc4f
Revises: 3bbf6812c261
Create Date: 2026-06-14 09:25:46.637295
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '5a6e73d2bc4f'
down_revision = '3bbf6812c261'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('challenge', schema=None) as batch_op:
batch_op.add_column(sa.Column('stakes_success', sqlmodel.sql.sqltypes.AutoString(), server_default="", nullable=False))
batch_op.add_column(sa.Column('stakes_failure', sqlmodel.sql.sqltypes.AutoString(), server_default="", nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('challenge', schema=None) as batch_op:
batch_op.drop_column('stakes_failure')
batch_op.drop_column('stakes_success')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Add Game.dev_mode
Revision ID: 6071db83ba81
Revises: 71c6d3e50ba3
Create Date: 2026-06-12 11:37:36.965417
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '6071db83ba81'
down_revision = '71c6d3e50ba3'
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('dev_mode', sa.Boolean(), nullable=False, server_default=sa.false()))
# ### 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('dev_mode')
# ### end Alembic commands ###

View File

@@ -0,0 +1,59 @@
"""add game join codes
Revision ID: 6ea41638edfc
Revises: 153132c8e583
Create Date: 2026-07-10 12:28:45.476200
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
import secrets
revision = '6ea41638edfc'
down_revision = '153132c8e583'
branch_labels = None
depends_on = None
JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
def _new_join_code(used: set[str]) -> str:
while True:
code = "".join(secrets.choice(JOIN_CODE_ALPHABET) for _ in range(5))
if code not in used:
used.add(code)
return code
def upgrade() -> None:
# SQLite cannot add a required column to a populated table. Add it as
# nullable, backfill every existing game with a distinct code, then tighten
# the constraint and create the unique lookup index.
op.add_column('game', sa.Column('join_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
connection = op.get_bind()
game_ids = connection.execute(sa.text("SELECT id FROM game")).scalars().all()
used: set[str] = set()
for game_id in game_ids:
connection.execute(
sa.text("UPDATE game SET join_code = :join_code WHERE id = :game_id"),
{"join_code": _new_join_code(used), "game_id": game_id},
)
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.alter_column(
'join_code',
existing_type=sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
)
batch_op.create_index(batch_op.f('ix_game_join_code'), ['join_code'], unique=True)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_game_join_code'))
batch_op.drop_column('join_code')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""recruit creation columns
Revision ID: 71c6d3e50ba3
Revises: 0001
Create Date: 2026-06-12 11:12:06.389057
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '71c6d3e50ba3'
down_revision = '0001'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.add_column(sa.Column('needs_reroll', sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.add_column(sa.Column('incoming_techniques', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default='[]'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.drop_column('incoming_techniques')
batch_op.drop_column('needs_reroll')
# ### end Alembic commands ###

View File

@@ -0,0 +1,28 @@
"""Add Player.is_admin
Revision ID: 8b2f4c9d1e07
Revises: 6071db83ba81
Create Date: 2026-06-12 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '8b2f4c9d1e07'
down_revision = '6071db83ba81'
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=False, server_default=sa.false()))
# Existing creators keep their privileges under the new flag
op.execute("UPDATE player SET is_admin = is_creator")
def downgrade() -> None:
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.drop_column('is_admin')

View File

@@ -0,0 +1,33 @@
"""technique swapping columns
Revision ID: b306c59e9cc2
Revises: 8b2f4c9d1e07
Create Date: 2026-06-12 16:35:11.867104
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = 'b306c59e9cc2'
down_revision = '8b2f4c9d1e07'
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.add_column(sa.Column('held_techniques', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default='[]'))
batch_op.add_column(sa.Column('swap_offer_to_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('swap_offer_technique', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default=''))
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.drop_column('swap_offer_technique')
batch_op.drop_column('swap_offer_to_id')
batch_op.drop_column('held_techniques')
# ### 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

@@ -1,30 +1,53 @@
import time
import uuid import uuid
import secrets
from typing import Optional, List from typing import Optional, List
from sqlmodel import Field, SQLModel, Relationship from sqlmodel import Field, SQLModel, Relationship
JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
def new_join_code() -> str:
return "".join(secrets.choice(JOIN_CODE_ALPHABET) for _ in range(5))
class Game(SQLModel, table=True): class Game(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)
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4())) admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes" join_code: str = Field(default_factory=new_join_code, index=True, unique=True)
crew_name: Optional[str] = Field(default=None)
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "assign_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "recruit_creation", "ended"
dev_mode: bool = Field(default=False) # Creator-toggled; reveals testing shortcuts (Suggest buttons, skip character creation) to the whole table
current_scene_number: int = Field(default=1) current_scene_number: int = Field(default=1)
extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers) 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)
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
captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship
teaching_deep_player_id: Optional[str] = Field(default=None) # Teaching mode: an admin who seeds themselves as the forced Rank-3 Deep for the first scene (set in the lobby, honored when starting ranks are rolled)
last_rank_up_player_id: Optional[str] = Field(default=None) # Winner of the most recent rank-up vote, shown on the post-voting upkeep screen; cleared when the next scene begins
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True) players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True) obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True)
challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=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)
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)
game_id: str = Field(foreign_key="game.id", index=True) game_id: str = Field(foreign_key="game.id", index=True)
name: str = Field(default="") name: str = Field(default="") # The Pi-Rat's current identity: "Recruit {smell}" until they earn a Name
player_name: str = Field(default="") # The human player's lobby name, shown alongside the Pi-Rat
is_ready: bool = Field(default=False) is_ready: bool = Field(default=False)
rank: int = Field(default=1) rank: int = Field(default=1)
hand_cards: str = Field(default="[]") # JSON string of list of card codes hand_cards: str = Field(default="[]") # JSON string of list of card codes
role: Optional[str] = Field(default=None) # "pirat", "deep", or None role: Optional[str] = Field(default=None) # "pirat", "deep", or None
previous_role: Optional[str] = Field(default=None) # "pirat", "deep", or None previous_role: Optional[str] = Field(default=None) # "pirat", "deep", or None
is_creator: bool = Field(default=False) is_creator: bool = Field(default=False)
is_admin: bool = Field(default=False) # Admin privileges; the creator starts with them and can grant them to others
# Character sheet questions # Character sheet questions
avatar_look: str = Field(default="") avatar_look: str = Field(default="")
@@ -40,7 +63,10 @@ class Player(SQLModel, table=True):
# Techniques # Techniques
created_techniques: str = Field(default="[]") # JSON string of list of 3 strings created_techniques: str = Field(default="[]") # JSON string of list of 3 strings
held_techniques: str = Field(default="[]") # JSON list of {"text", "creator_id"}: what the player currently holds during/after the swap phase
swapped_techniques: str = Field(default="[]") # JSON string of list of 3 strings swapped_techniques: str = Field(default="[]") # JSON string of list of 3 strings
swap_offer_to_id: Optional[str] = Field(default=None) # Pending outgoing swap offer's recipient
swap_offer_technique: str = Field(default="") # The held technique being offered; hidden from everyone but the offerer
tech_jack: str = Field(default="") tech_jack: str = Field(default="")
tech_queen: str = Field(default="") tech_queen: str = Field(default="")
tech_king: str = Field(default="") tech_king: str = Field(default="")
@@ -50,6 +76,19 @@ class Player(SQLModel, table=True):
completed_personal_2: bool = Field(default=False) # Earn a Name completed_personal_2: bool = Field(default=False) # Earn a Name
completed_personal_3: bool = Field(default=False) # Die like a Pirate completed_personal_3: bool = Field(default=False) # Die like a Pirate
gat_description: str = Field(default="") # Free-text description of the Pi-Rat's Gat, entered via a modal when they earn one
# States for Milestones & Death
needs_name: bool = Field(default=False)
needs_gat_description: bool = Field(default=False) # Prompts the gat-description modal after earning a Gat objective
needs_rank_3_bonus: bool = Field(default=False)
is_dead: bool = Field(default=False)
is_ghost: bool = Field(default=False)
tax_banned: bool = Field(default=False) # Failed a refused Gat/Name Tax; cannot initiate one again this scene
# Recruit creation (death re-roll, or joining after initial character creation)
needs_reroll: bool = Field(default=False) # Waiting on the between-scenes recruit_creation phase
incoming_techniques: str = Field(default="[]") # JSON list of {"from_id", "text"}: techniques crewmates write for this recruit
# Relationships # Relationships
game: Optional[Game] = Relationship(back_populates="players") game: Optional[Game] = Relationship(back_populates="players")
@@ -65,15 +104,35 @@ class Obstacle(SQLModel, table=True):
game: Optional[Game] = Relationship(back_populates="obstacles") game: Optional[Game] = Relationship(back_populates="obstacles")
@property
def success_count(self) -> int:
import json
try:
cards_list = json.loads(self.played_cards)
return sum(1 for c in cards_list if c.get("success") is True)
except Exception:
return 0
class Challenge(SQLModel, table=True): class Challenge(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)
game_id: str = Field(foreign_key="game.id", index=True) game_id: str = Field(foreign_key="game.id", index=True)
title: str = Field(...) challenge_type: str = Field(default="deep") # "deep" (called by Deep Players) or "pvp" (Pi-Rat vs Pi-Rat)
description: str = Field(default="") target_player_id: str = Field(...) # The originally challenged Pi-Rat
applied_obstacle_ids: str = Field(default="[]") # JSON string of list of obstacle IDs acting_player_id: str = Field(...) # Who attempts it (changes when a Gat/Name Tax is accepted)
resolved_by_player_id: Optional[str] = Field(default=None) # Player ID if resolved (for logging/history) challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
is_active: bool = Field(default=True) obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges)
temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
stakes: str = Field(default="") # Deprecated single-field stakes; kept for old rollback-snapshot compatibility. New challenges use stakes_success/stakes_failure.
stakes_success: str = Field(default="") # What the Deep promises happens if the Pi-Rat beats the Challenge
stakes_failure: str = Field(default="") # What the Deep threatens happens if the Pi-Rat fails
plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
status: str = Field(default="open") # "open", "succeeded", "failed"
# Gat/Name Tax state
tax_type: Optional[str] = Field(default=None) # "gat" or "name"
tax_target_id: Optional[str] = Field(default=None) # The player asked to take over the challenge
tax_state: Optional[str] = Field(default=None) # "requested", "accepted", "refused"
game: Optional[Game] = Relationship(back_populates="challenges") game: Optional[Game] = Relationship(back_populates="challenges")
class Vote(SQLModel, table=True): class Vote(SQLModel, table=True):
@@ -83,3 +142,28 @@ class Vote(SQLModel, table=True):
nominated_player_id: str = Field(...) nominated_player_id: str = Field(...)
game: Optional[Game] = Relationship(back_populates="votes") game: Optional[Game] = Relationship(back_populates="votes")
class GameEvent(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
game_id: str = Field(foreign_key="game.id", index=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")

192
src/pirats/routes_admin.py Normal file
View File

@@ -0,0 +1,192 @@
import json
from fastapi import APIRouter, Request, Form, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlmodel import Session
from .database import get_session
from . import crud
router = APIRouter()
def _get_admin(db: Session, game_id: str, player_id: str):
"""Resolves (game, player), requiring the player to be an Admin."""
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="Not found")
if not player.is_admin:
raise HTTPException(status_code=403, detail="Only an Admin can do that.")
return game, player
# Admin toggles Dev Mode for the whole table (reveals testing shortcuts)
@router.post("/game/{game_id}/player/{player_id}/dev-mode")
def set_dev_mode(
game_id: str,
player_id: str,
enabled: bool = Form(...),
db: Session = Depends(get_session)
):
game, player = _get_admin(db, game_id, player_id)
if game.dev_mode != enabled:
game.dev_mode = enabled
db.add(game)
db.commit()
crud.add_game_event(db, game.id, f"🛠 Dev Mode turned {'ON' if enabled else 'OFF'} by {player.name}.", kind="info")
return {"status": "ok", "dev_mode": game.dev_mode}
# Teaching mode: an admin volunteers to be the forced Rank-3 Deep for scene 1.
# Only meaningful before starting ranks are rolled (i.e. before scene setup).
_PRE_RANK_PHASES = {"lobby", "character_creation", "swap_techniques", "assign_techniques"}
@router.post("/game/{game_id}/player/{player_id}/teaching-mode")
def set_teaching_mode(
game_id: str,
player_id: str,
enabled: bool = Form(...),
db: Session = Depends(get_session)
):
game, player = _get_admin(db, game_id, player_id)
if game.phase not in _PRE_RANK_PHASES:
raise HTTPException(status_code=400, detail="Starting ranks have already been rolled.")
if enabled:
game.teaching_deep_player_id = player.id
elif game.teaching_deep_player_id == player.id:
game.teaching_deep_player_id = None
else:
# Disabling someone else's teaching mode shouldn't silently clear it.
return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id}
db.add(game)
db.commit()
crud.add_game_event(
db, game.id,
f"📚 {player.name} {'will teach as the Deep' if enabled else 'cancelled teaching mode'} for the first scene.",
kind="info",
)
return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id}
# Dev Mode shortcut: auto-complete character creation for everyone and move on
@router.post("/game/{game_id}/player/{player_id}/skip-character-creation")
def skip_character_creation_route(
game_id: str,
player_id: str,
fills: str = Form("{}"), # JSON: player_id -> suggested values for free-text fields
db: Session = Depends(get_session)
):
game, player = _get_admin(db, game_id, player_id)
if not game.dev_mode:
raise HTTPException(status_code=403, detail="Dev Mode must be on to skip character creation.")
try:
fills_dict = json.loads(fills) if fills else {}
if not isinstance(fills_dict, dict):
raise ValueError
except ValueError:
raise HTTPException(status_code=400, detail="fills must be a JSON object.")
crud.add_game_event(db, game.id, f"🛠 {player.name} is skipping the rest of character creation (Dev Mode).", kind="info")
ok, msg = crud.skip_character_creation(db, game, fills_dict)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Admin escape hatch during technique distribution: randomly distribute every
# technique to an eligible player and auto-assign J/Q/K (no Dev Mode required)
@router.post("/game/{game_id}/player/{player_id}/auto-assign-techniques")
def auto_assign_techniques_route(
game_id: str,
player_id: str,
db: Session = Depends(get_session)
):
game, player = _get_admin(db, game_id, player_id)
if game.phase not in ("swap_techniques", "assign_techniques"):
return JSONResponse({"error": "Technique distribution isn't in progress."}, status_code=400)
crud.add_game_event(db, game.id, f"🗝 {player.name} is auto-assigning everyone's techniques.", kind="info")
ok, msg = crud.auto_assign_techniques(db, game)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Creator Admin Panel
@router.get("/game/{game_id}/admin")
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
if game.admin_key != key:
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
base_url = str(request.base_url).rstrip("/")
players_data = []
for p in game.players:
players_data.append({
"id": p.id,
"name": p.name,
"player_name": p.player_name,
"rank": p.rank,
"is_dead": p.is_dead,
"is_ghost": p.is_ghost,
"role": p.role,
"is_creator": p.is_creator,
"is_admin": p.is_admin,
})
return {
"status": "ok",
"game": {
"id": game.id,
"crew_name": game.crew_name,
"join_code": game.join_code,
"phase": game.phase,
"admin_key": game.admin_key
},
"players": players_data,
"base_url": base_url
}
# Grant or revoke another player's Admin privileges (admin-key authenticated, like the panel itself)
@router.post("/game/{game_id}/admin/set-admin")
def set_player_admin(
game_id: str,
key: str = Form(...),
target_player_id: str = Form(...),
is_admin: bool = Form(...),
db: Session = Depends(get_session)
):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
if game.admin_key != key:
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
target = crud.get_player(db, target_player_id)
if not target or target.game_id != game.id:
raise HTTPException(status_code=404, detail="Player not found")
if target.is_creator and not is_admin:
return JSONResponse({"error": "The game creator's Admin privileges can't be revoked."}, status_code=400)
if target.is_admin != is_admin:
target.is_admin = is_admin
db.add(target)
db.commit()
verb = "granted" if is_admin else "revoked"
crud.add_game_event(db, game.id, f"🗝 Admin privileges {verb} for {target.name}.", kind="info")
return {"status": "ok", "is_admin": target.is_admin}
# Kick a player out of the game (admin-key authenticated). Admins can kick
# anyone — including other admins — except the last remaining Admin, so the
# table is never locked out. The kicked player's cards return to the discard.
@router.post("/game/{game_id}/admin/kick")
def kick_player_route(
game_id: str,
key: str = Form(...),
target_player_id: str = Form(...),
db: Session = Depends(get_session)
):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
if game.admin_key != key:
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
target = crud.get_player(db, target_player_id)
if not target or target.game_id != game.id:
raise HTTPException(status_code=404, detail="Player not found")
ok, msg = crud.kick_player(db, game, target)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}

View File

@@ -0,0 +1,113 @@
import json
from fastapi import APIRouter, Form, Depends
from fastapi.responses import JSONResponse
from sqlmodel import Session
from .database import get_session
from . import crud
from .validation import sanitize_text
router = APIRouter()
# Deep calls a Challenge: applies one or more Obstacles to a Pi-Rat
@router.post("/game/{game_id}/player/{player_id}/challenge/create")
def create_challenge_route(
game_id: str,
player_id: str,
target_player_id: str = Form(...),
obstacle_ids: str = Form(...), # JSON list of obstacle ids
stakes_success: str = Form(""),
stakes_failure: str = Form(""),
db: Session = Depends(get_session)
):
try:
ids = json.loads(obstacle_ids)
assert isinstance(ids, list)
except Exception:
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids,
sanitize_text(stakes_success), sanitize_text(stakes_failure))
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Deep resolves an open Challenge
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/resolve")
def resolve_challenge_route(
game_id: str,
player_id: str,
challenge_id: str,
db: Session = Depends(get_session)
):
ok, msg = crud.resolve_challenge(db, challenge_id, player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok", "message": msg}
# Deep withdraws an open Challenge
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/cancel")
def cancel_challenge_route(
game_id: str,
player_id: str,
challenge_id: str,
db: Session = Depends(get_session)
):
ok, msg = crud.cancel_challenge(db, challenge_id, player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Challenged Pi-Rat calls a Gat/Name Tax on another Pi-Rat
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/tax/request")
def request_tax_route(
game_id: str,
player_id: str,
challenge_id: str,
target_player_id: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.request_tax(db, challenge_id, player_id, target_player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok", "message": msg}
# Taxed Pi-Rat accepts or refuses
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/tax/respond")
def respond_tax_route(
game_id: str,
player_id: str,
challenge_id: str,
accept: str = Form(...), # "true" or "false"
db: Session = Depends(get_session)
):
ok, msg = crud.respond_tax(db, challenge_id, player_id, accept.lower() == "true")
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok", "message": msg}
# Pi-Rat challenges another Pi-Rat, playing a card as a temporary Obstacle
@router.post("/game/{game_id}/player/{player_id}/challenge/pvp/create")
def create_pvp_route(
game_id: str,
player_id: str,
defender_id: str = Form(...),
card_code: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.create_pvp_challenge(db, game_id, player_id, defender_id, card_code)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Defender plays a card against the temporary Obstacle
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/pvp/defend")
def pvp_defend_route(
game_id: str,
player_id: str,
challenge_id: str,
card_code: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg, res = crud.play_pvp_defense(db, challenge_id, player_id, card_code)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok", "details": res["details"], "success": res["success"], "drew_card": res["drew_card"]}

Some files were not shown because too many files have changed in this diff Show More