Compare commits

...

83 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
118 changed files with 12373 additions and 6634 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" }
}
]
}

7
.gitignore vendored
View File

@@ -1,4 +1,11 @@
.venv .venv
rats_with_gats.db rats_with_gats.db
smoke_test.db
result result
*.pdf *.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
\#*

View File

@@ -1 +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. 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

View File

@@ -1,8 +1,8 @@
# Rats with Gats Remote Play (`pirats`) # 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 space-faring pirate rats, or "Pi-Rats"). The application manages lobbies, character creation, card mechanics, scene phases, obstacles, challenges, and player voting. 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 **FastAPI**, **SQLModel** (SQLite database), **Jinja2 templates**, and **HTMX** for dynamic, real-time page updates. 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.
--- ---
@@ -61,6 +61,20 @@ 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). 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 ### Option 2: Using Nix Flakes
@@ -99,10 +113,22 @@ services.pirats = {
host = "127.0.0.1"; host = "127.0.0.1";
port = 8000; port = 8000;
databasePath = "/var/lib/pirats/rats_with_gats.db"; 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 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 ## Running Tests
@@ -126,15 +152,37 @@ nix develop --command pytest
``` ```
pirats/ pirats/
├── pyproject.toml # Python package metadata and dependencies ├── pyproject.toml # Python package metadata and dependencies
├── flake.nix # Nix package, dev shell, and NixOS service module ├── flake.nix # Nix package (incl. frontend build), dev shell, NixOS service module
├── tests/ # Unit and integration tests ├── tests/
│ └── test_game.py # Game logic and CRUD database test suites │ └── 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/ └── src/
└── pirats/ # Core Python package └── pirats/ # FastAPI backend package
├── main.py # FastAPI routes, HTTP endpoints, CLI entrypoint ├── main.py # App setup, /api/game create/join/state endpoints, CLI entrypoint
├── crud.py # Core database CRUD, game mechanics, and transitions ├── models.py # SQLModel tables (Game, Player, Obstacle, Challenge, Vote, GameEvent)
├── models.py # SQLModel database tables (Game, Player, Obstacle, Challenge, Vote) ├── cards.py # Card parsing, deck building, obstacle table from the rulebook
├── cards.py # Playing card parser, deck setups, and obstacle definitions ├── crud.py # Facade re-exporting all crud_* modules
├── templates/ # Jinja2 HTML pages and HTMX snippets ├── crud_*.py # Game logic by phase: base (deck/hands/rank), character, scene, challenge, upkeep
── static/ # CSS styles and frontend assets ── 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.

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

View File

@@ -24,7 +24,7 @@
pname = "pirats-frontend"; pname = "pirats-frontend";
version = "0.1.0"; version = "0.1.0";
src = ./frontend; src = ./frontend;
npmDepsHash = "sha256-ekYBi0oUjtnsfdig2B0yrmT7KbSc9IB1DVbTCRwYI24="; npmDepsHash = "sha256-S2CXmFgovGD40wS4bTsiVF84J3zEQK6qrg7EPy+ojPY=";
installPhase = '' installPhase = ''
mkdir -p $out mkdir -p $out
cp -r dist $out/ cp -r dist $out/
@@ -45,9 +45,11 @@
]; ];
propagatedBuildInputs = with python.pkgs; [ propagatedBuildInputs = with python.pkgs; [
alembic
fastapi fastapi
sqlmodel sqlmodel
uvicorn uvicorn
websockets
python-multipart python-multipart
httpx httpx
]; ];
@@ -110,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 {
@@ -127,6 +177,16 @@
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 = {
@@ -136,6 +196,10 @@
# 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";

View File

@@ -4,7 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <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> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -8,6 +8,9 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "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" "svelte-spa-router": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -50,6 +53,33 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@fontsource/alegreya-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/alegreya-sans/-/alegreya-sans-5.2.8.tgz",
"integrity": "sha512-kXUTR1S6dsWa7rBxaDJ3P05HodmYmxfY20aQtQ4SYVmrEYrWtaw5A1n+h+wcCF0nAMwf+CQ4M435dNuWD1YfMg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/alegreya-sc": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/alegreya-sc/-/alegreya-sc-5.2.8.tgz",
"integrity": "sha512-28ZMhxJ/bx7f9S2oTTPeXvKchAu5VxPbEG95RI+EI9l3/1YyomEXzUqX0lCdCQb5fDXhdmVwHSD07P0BL4ueQA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/pirata-one": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/pirata-one/-/pirata-one-5.2.8.tgz",
"integrity": "sha512-hl3Zqt6mgP+jstHnv2qYc2xQdYM1DSYrpSU9bpS1fYExoGFgJJNbXTPBQ9XivNk9RCKkibhou3iqoOZQOXM48w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",

View File

@@ -14,6 +14,9 @@
"vite": "^8.0.12" "vite": "^8.0.12"
}, },
"dependencies": { "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" "svelte-spa-router": "^5.1.0"
} }
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 240 B

View File

@@ -4,6 +4,7 @@
import Join from './pages/Join.svelte'; import Join from './pages/Join.svelte';
import Dashboard from './pages/Dashboard.svelte'; import Dashboard from './pages/Dashboard.svelte';
import Admin from './pages/Admin.svelte'; import Admin from './pages/Admin.svelte';
import CornerMenu from './components/CornerMenu.svelte';
const routes = { const routes = {
'/': Home, '/': Home,
@@ -15,4 +16,5 @@
<main> <main>
<Router {routes} /> <Router {routes} />
<CornerMenu />
</main> </main>

View File

@@ -1,11 +1,17 @@
@import './assets/css/style.css'; /* ==========================================================================
@import './assets/css/core.css'; 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/components.css';
@import './assets/css/card.css';
@import './assets/css/welcome.css'; @import './assets/css/welcome.css';
@import './assets/css/lobby.css'; @import './assets/css/lobby.css';
@import './assets/css/character.css'; @import './assets/css/character.css';
@import './assets/css/scene-setup.css'; @import './assets/css/scene-setup.css';
@import './assets/css/scene-play.css'; @import './assets/css/scene-play.css';
@import './assets/css/card.css';
@import './assets/css/upkeep.css'; @import './assets/css/upkeep.css';
@import './assets/css/admin.css'; @import './assets/css/admin.css';

View File

@@ -1,22 +1,123 @@
/* --- Admin Panel --- */ /* --- Admin panel --- */
.admin-players-list { .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; 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; flex-direction: column;
gap: 1rem; }
.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; margin-top: 1rem;
} }
.admin-player-row { .admin-table th, .admin-table td {
display: flex; padding: 0.75rem;
justify-content: space-between; border-bottom: 1px solid var(--edge-soft);
align-items: center;
padding: 1rem;
gap: 1rem;
flex-wrap: wrap;
} }
.player-info-basic { .admin-table th {
font-size: 1.05rem; font-family: var(--font-heading);
color: var(--accent);
} }
.link-copy-action { .link-copy-action {
@@ -26,3 +127,38 @@
flex: 1; flex: 1;
max-width: 500px; 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

@@ -1,22 +1,44 @@
/* Card Widget (Mini) */ /* ============================================================
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 { .card-mini {
width: 32px; width: 32px;
height: 48px; height: 48px;
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--text-muted);
background: var(--bg-dark);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
padding: 2px 4px; padding: 2px 4px;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 900; font-weight: 900;
position: relative;
} }
.card-mini.base-card { .card-mini.base-card {
border-color: var(--gold); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
color: var(--gold);
background: rgba(212,175,55,0.1);
} }
.card-mini.rotated { .card-mini.rotated {
@@ -25,13 +47,11 @@
} }
.card-mini.success { .card-mini.success {
border-color: var(--neon-emerald); box-shadow: 0 0 0 2px var(--success);
color: var(--neon-emerald);
} }
.card-mini.failure { .card-mini.failure {
border-color: var(--red-suit); box-shadow: 0 0 0 2px var(--danger);
color: var(--red-suit);
} }
.card-mini .val { .card-mini .val {
@@ -48,17 +68,15 @@
position: absolute; position: absolute;
bottom: -1px; bottom: -1px;
left: 1px; left: 1px;
color: var(--text-muted); color: color-mix(in srgb, var(--card-ink-black) 60%, var(--card-face));
} }
/* Card Widget (Medium) */ /* --- Medium (obstacle display) --- */
.card-medium { .card-medium {
width: 75px; width: 75px;
height: 112px; height: 112px;
border-radius: 6px; border-radius: var(--radius-sm);
border: 1.5px solid var(--glass-border); box-shadow: var(--shadow-card);
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
box-shadow: 0 3px 10px rgba(0,0,0,0.4);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@@ -83,29 +101,6 @@
font-size: 0.75rem; font-size: 0.75rem;
} }
.card-medium.suit-c, .card-medium.suit-s {
border-color: rgba(226, 232, 240, 0.15);
}
.card-medium.suit-h, .card-medium.suit-d {
border-color: rgba(255, 42, 95, 0.15);
}
.card-medium.joker-card {
border-color: var(--gold);
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
}
.card-medium.suit-c .val, .card-medium.suit-c .suit,
.card-medium.suit-s .val, .card-medium.suit-s .suit {
color: var(--black-suit);
}
.card-medium.suit-h .val, .card-medium.suit-h .suit,
.card-medium.suit-d .val, .card-medium.suit-d .suit {
color: var(--red-suit);
}
.card-medium .card-center { .card-medium .card-center {
font-size: 1.8rem; font-size: 1.8rem;
text-align: center; text-align: center;
@@ -113,17 +108,13 @@
margin: auto 0; margin: auto 0;
} }
.card-medium.suit-c .card-center, .card-medium.suit-s .card-center { color: var(--black-suit); } /* --- Large (the player's hand) --- */
.card-medium.suit-h .card-center, .card-medium.suit-d .card-center { color: var(--red-suit); }
/* Card Widget (Large) */
.card-large { .card-large {
width: 110px; width: 110px;
height: 165px; height: 165px;
border-radius: 8px; border-radius: var(--radius-md);
border: 2px solid var(--glass-border); border-width: 2px;
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%); box-shadow: var(--shadow-card-raised);
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@@ -134,29 +125,7 @@
.card-large:hover { .card-large:hover {
transform: translateY(-8px) scale(1.05); transform: translateY(-8px) scale(1.05);
border-color: var(--gold); box-shadow: var(--shadow-card-lifted), 0 0 14px color-mix(in srgb, var(--accent) 30%, transparent);
box-shadow: 0 10px 25px rgba(212, 175, 55, 0.2);
}
.card-large.suit-c, .card-large.suit-s {
border-color: rgba(226, 232, 240, 0.15);
}
.card-large.suit-c:hover, .card-large.suit-s:hover {
border-color: var(--black-suit);
box-shadow: 0 10px 25px var(--black-glow);
}
.card-large.suit-h, .card-large.suit-d {
border-color: rgba(255, 42, 95, 0.15);
}
.card-large.suit-h:hover, .card-large.suit-d:hover {
border-color: var(--red-suit);
box-shadow: 0 10px 25px var(--red-glow);
}
.card-large.joker-card {
border-color: var(--gold);
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
} }
.card-large .card-corner { .card-large .card-corner {
@@ -175,16 +144,6 @@
font-size: 1rem; font-size: 1rem;
} }
.card-large.suit-c .val, .card-large.suit-c .suit,
.card-large.suit-s .val, .card-large.suit-s .suit {
color: var(--black-suit);
}
.card-large.suit-h .val, .card-large.suit-h .suit,
.card-large.suit-d .val, .card-large.suit-d .suit {
color: var(--red-suit);
}
.card-large .card-center { .card-large .card-center {
font-size: 2.8rem; font-size: 2.8rem;
text-align: center; text-align: center;
@@ -192,9 +151,6 @@
margin: auto 0; margin: auto 0;
} }
.card-large.suit-c .card-center, .card-large.suit-s .card-center { color: var(--black-suit); }
.card-large.suit-h .card-center, .card-large.suit-d .card-center { color: var(--red-suit); }
.card-large .card-tech-overlay { .card-large .card-tech-overlay {
position: absolute; position: absolute;
bottom: 0.5rem; bottom: 0.5rem;
@@ -203,10 +159,10 @@
text-align: center; text-align: center;
} }
.tech-tag { .card-large .tech-tag {
background: rgba(0, 0, 0, 0.7); background: color-mix(in srgb, var(--surface) 92%, transparent);
border: 1px solid var(--gold); border: 1px solid var(--accent);
color: var(--gold); color: var(--accent-bright);
font-size: 0.65rem; font-size: 0.65rem;
padding: 0.15rem 0.3rem; padding: 0.15rem 0.3rem;
border-radius: 4px; border-radius: 4px;
@@ -220,7 +176,7 @@
gap: 2px; gap: 2px;
} }
/* Drag and Drop Interface Styles */ /* --- Drag & drop --- */
.card-large[draggable="true"] { .card-large[draggable="true"] {
cursor: grab; cursor: grab;
} }
@@ -236,9 +192,9 @@
} }
.obstacle-item.drag-over { .obstacle-item.drag-over {
border-color: var(--gold) !important; border-color: var(--accent) !important;
background: rgba(212, 175, 55, 0.1) !important; background: color-mix(in srgb, var(--accent) 9%, transparent) !important;
box-shadow: 0 0 20px rgba(212, 175, 55, 0.2) !important; box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 20%, transparent) !important;
transform: translateY(-2px) scale(1.01); transform: translateY(-2px) scale(1.01);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: var(--transition-smooth);
} }

View File

@@ -1,4 +1,4 @@
/* --- Character Creation & Sheet Grid --- */ /* --- Character creation & sheet --- */
.creation-grid { .creation-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -10,20 +10,18 @@
.creation-grid { .creation-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
}
.section-desc { .creation-grid > .card {
font-size: 0.9rem; grid-column: span 1 !important;
color: var(--text-muted); }
margin-bottom: 1rem;
} }
.record-line { .record-line {
background: rgba(0,0,0,0.2); background: var(--well);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: 6px; border-radius: var(--radius-sm);
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
border-left: 3px solid var(--gold); border-left: 3px solid var(--accent);
} }
.delegation-box { .delegation-box {
@@ -33,14 +31,15 @@
} }
.delegation-box h4 { .delegation-box h4 {
font-family: var(--font-body);
font-size: 0.95rem; font-size: 0.95rem;
color: var(--text-primary); color: var(--text);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.answer-box { .answer-box {
font-style: italic; font-style: italic;
color: var(--neon-cyan); color: var(--accent-bright);
font-size: 1.05rem; font-size: 1.05rem;
} }
@@ -50,14 +49,6 @@
font-style: normal; font-style: normal;
} }
.waiting-box {
font-size: 0.9rem;
color: var(--gold);
display: flex;
align-items: center;
gap: 0.5rem;
}
.inbox-list:has(.inbox-item) .inbox-empty-message { .inbox-list:has(.inbox-item) .inbox-empty-message {
display: none; display: none;
} }
@@ -65,8 +56,8 @@
.inbox-item { .inbox-item {
padding: 1rem; padding: 1rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
border-left: 3px solid var(--neon-cyan); border-left: 3px solid var(--deep);
background: rgba(0, 242, 254, 0.02); background: color-mix(in srgb, var(--deep) 4%, transparent);
} }
.inbox-item label { .inbox-item label {
@@ -76,26 +67,22 @@
} }
.submitted-techniques .tech-chip { .submitted-techniques .tech-chip {
background: rgba(212,175,55,0.05); background: color-mix(in srgb, var(--accent) 6%, transparent);
border: 1px solid var(--gold); border: 1px solid var(--accent);
color: var(--gold); color: var(--accent);
padding: 0.5rem; padding: 0.6rem 1rem;
border-radius: 6px; font-size: 0.95rem;
border-radius: var(--radius-sm);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-family: var(--font-heading); font-family: var(--font-heading);
} }
.tech-chip {
padding: 0.6rem 1rem;
font-size: 0.95rem;
}
.tech-assignment-pill { .tech-assignment-pill {
background: rgba(0, 242, 254, 0.05); background: color-mix(in srgb, var(--deep) 6%, transparent);
border: 1px solid var(--neon-cyan); border: 1px solid var(--deep);
color: var(--text-primary); color: var(--text);
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
border-radius: 6px; border-radius: var(--radius-sm);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
text-align: left; text-align: left;
display: flex; display: flex;
@@ -104,8 +91,8 @@
} }
.card-rank { .card-rank {
background: var(--neon-cyan); background: var(--accent);
color: var(--bg-dark); color: var(--text-inverse);
font-weight: 900; font-weight: 900;
width: 24px; width: 24px;
height: 24px; height: 24px;
@@ -114,31 +101,30 @@
justify-content: center; justify-content: center;
border-radius: 50%; border-radius: 50%;
font-size: 0.85rem; font-size: 0.85rem;
flex-shrink: 0;
} }
/* --- Character Sheet layout --- */ /* --- Character sheet layout --- */
.character-sheet-details { .character-sheet-details {
text-align: left; text-align: left;
display: flex;
flex-direction: column;
} }
.sheet-group { @media (min-width: 768px) {
background: rgba(0,0,0,0.15); .character-sheet-details {
padding: 1rem; flex-direction: row;
border-radius: 8px; align-items: flex-start;
border-left: 2px solid var(--gold-dark); gap: 1.5rem;
} }
.sheet-main-column {
.sheet-group h4 { flex: 2;
color: var(--gold); min-width: 0;
font-size: 0.95rem; }
margin-bottom: 0.5rem; .sheet-side-column {
text-transform: uppercase; flex: 1;
letter-spacing: 0.5px; min-width: 0;
} }
.footnote {
font-size: 0.8rem;
color: var(--text-muted);
} }
.techniques-list-sheet { .techniques-list-sheet {
@@ -150,37 +136,23 @@
font-size: 0.95rem; font-size: 0.95rem;
} }
.footnote-desc { /* --- Face-card technique assignment (drag & drop) --- */
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.15rem;
margin-left: 1.5rem;
}
.margin-top-small {
margin-top: 0.5rem;
}
/* --- Visual & Tactile Technique Assignment --- */
.face-tech-drag-form { .face-tech-drag-form {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.available-techniques-container { .available-techniques-container {
background: rgba(255, 255, 255, 0.02); background: var(--well);
border: 1px dashed var(--glass-border); border: 1px dashed var(--edge);
border-radius: 12px; border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
margin-bottom: 2rem; margin-bottom: 2rem;
box-shadow: inset 0 0 15px rgba(0,0,0,0.2);
} }
.available-techniques-container h4 { .available-techniques-container h4 {
font-family: var(--font-heading); color: var(--accent);
color: var(--gold);
font-size: 1.1rem; font-size: 1.1rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -200,18 +172,17 @@
} }
.draggable-tech-chip { .draggable-tech-chip {
background: linear-gradient(135deg, rgba(22, 36, 59, 0.9) 0%, rgba(13, 23, 38, 0.9) 100%); background: var(--surface-raised);
border: 1px solid var(--gold); border: 1px solid var(--accent);
color: var(--text-primary); color: var(--text);
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
border-radius: 8px; border-radius: var(--radius-sm);
cursor: grab; cursor: grab;
user-select: none; user-select: none;
font-family: var(--font-body);
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 500; font-weight: 500;
transition: var(--transition-smooth); transition: var(--transition-smooth);
box-shadow: 0 4px 10px rgba(0,0,0,0.3), 0 0 5px rgba(212, 175, 55, 0.1); box-shadow: var(--shadow-soft);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -219,8 +190,7 @@
.draggable-tech-chip:hover { .draggable-tech-chip:hover {
transform: translateY(-2px); transform: translateY(-2px);
border-color: var(--neon-cyan); border-color: var(--accent-bright);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4), 0 0 10px rgba(0, 242, 254, 0.3);
} }
.draggable-tech-chip:active { .draggable-tech-chip:active {
@@ -232,26 +202,20 @@
border-style: dashed; border-style: dashed;
} }
.draggable-tech-chip.selected {
border-color: var(--neon-cyan);
box-shadow: 0 0 15px rgba(0, 242, 254, 0.5);
background: rgba(0, 242, 254, 0.1);
}
.draggable-tech-chip.assigned-hidden { .draggable-tech-chip.assigned-hidden {
opacity: 0.2; opacity: 0.2;
pointer-events: none; pointer-events: none;
cursor: not-allowed; cursor: not-allowed;
border-color: var(--text-muted); border-color: var(--edge);
} }
.drag-icon { .drag-icon {
color: var(--gold); color: var(--accent);
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.7; opacity: 0.7;
} }
/* Slots Grid */ /* Face-card slots */
.face-cards-slots-grid { .face-cards-slots-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -276,9 +240,9 @@
max-width: 220px; max-width: 220px;
aspect-ratio: 2 / 3; aspect-ratio: 2 / 3;
min-height: 280px; min-height: 280px;
background: linear-gradient(135deg, rgba(13, 23, 38, 0.8) 0%, rgba(7, 11, 18, 0.9) 100%); background: var(--well);
border: 2px dashed rgba(212, 175, 55, 0.3); border: 2px dashed var(--edge-accent);
border-radius: 12px; border-radius: var(--radius-lg);
padding: 1rem; padding: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -286,27 +250,25 @@
transition: var(--transition-smooth); transition: var(--transition-smooth);
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4); box-shadow: var(--shadow-soft);
} }
.face-card-slot:hover { .face-card-slot:hover {
border-color: rgba(212, 175, 55, 0.7); border-color: var(--accent);
box-shadow: 0 12px 30px rgba(212, 175, 55, 0.15);
transform: translateY(-4px); transform: translateY(-4px);
} }
.face-card-slot.drag-over { .face-card-slot.drag-over {
border-color: var(--neon-cyan); border-color: var(--accent-bright);
border-style: solid; border-style: solid;
background: rgba(0, 242, 254, 0.05); background: color-mix(in srgb, var(--accent) 7%, transparent);
box-shadow: 0 0 20px rgba(0, 242, 254, 0.2);
transform: scale(1.02); transform: scale(1.02);
} }
.face-card-slot.has-assignment { .face-card-slot.has-assignment {
border-style: solid; border-style: solid;
border-color: var(--gold); border-color: var(--accent);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 15px rgba(212, 175, 55, 0.15); background: color-mix(in srgb, var(--accent) 5%, var(--well));
} }
.card-bg-letter { .card-bg-letter {
@@ -314,10 +276,9 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
font-family: var(--font-heading); font-family: var(--font-display);
font-size: 8rem; font-size: 9rem;
font-weight: 900; color: color-mix(in srgb, var(--text) 4%, transparent);
color: rgba(255, 255, 255, 0.025);
line-height: 1; line-height: 1;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
@@ -325,7 +286,7 @@
} }
.face-card-slot.has-assignment .card-bg-letter { .face-card-slot.has-assignment .card-bg-letter {
color: rgba(212, 175, 55, 0.04); color: color-mix(in srgb, var(--accent) 8%, transparent);
} }
.card-slot-header, .card-slot-footer { .card-slot-header, .card-slot-footer {
@@ -342,7 +303,7 @@
.face-card-slot.has-assignment .card-slot-header, .face-card-slot.has-assignment .card-slot-header,
.face-card-slot.has-assignment .card-slot-footer { .face-card-slot.has-assignment .card-slot-footer {
color: var(--gold); color: var(--accent);
} }
.card-slot-body { .card-slot-body {
@@ -360,29 +321,29 @@
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text);
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
pointer-events: none; pointer-events: none;
} }
.face-card-slot.has-assignment .card-title { .face-card-slot.has-assignment .card-title {
color: var(--gold); color: var(--accent);
} }
.drop-zone-placeholder { .drop-zone-placeholder {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); color: var(--text-muted);
border: 1px dashed rgba(255, 255, 255, 0.1); border: 1px dashed var(--edge);
background: rgba(0, 0, 0, 0.2); background: var(--well);
border-radius: 6px; border-radius: var(--radius-sm);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
transition: var(--transition-smooth); transition: var(--transition-smooth);
pointer-events: none; pointer-events: none;
} }
.face-card-slot:hover .drop-zone-placeholder { .face-card-slot:hover .drop-zone-placeholder {
color: var(--text-primary); color: var(--text);
border-color: rgba(255, 255, 255, 0.2); border-color: var(--edge-accent);
} }
.assigned-tech-content { .assigned-tech-content {
@@ -399,14 +360,14 @@
} }
.assigned-tech-chip { .assigned-tech-chip {
background: rgba(212, 175, 55, 0.08); background: color-mix(in srgb, var(--accent) 9%, transparent);
border: 1px solid var(--gold); border: 1px solid var(--accent);
color: var(--text-primary); color: var(--text);
padding: 0.5rem; padding: 0.5rem;
border-radius: 8px; border-radius: var(--radius-sm);
font-size: 0.85rem; font-size: 0.85rem;
position: relative; position: relative;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-soft);
word-break: break-word; word-break: break-word;
animation: fadeIn 0.3s ease; animation: fadeIn 0.3s ease;
} }
@@ -418,22 +379,21 @@
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 50%; border-radius: 50%;
background: #c0392b; background: var(--danger);
border: 1px solid #e74c3c; border: 1px solid color-mix(in srgb, var(--danger) 70%, var(--text));
color: white; color: var(--text-inverse);
font-size: 10px; font-size: 10px;
font-weight: 900; font-weight: 900;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.5); box-shadow: var(--shadow-soft);
transition: var(--transition-smooth); transition: var(--transition-smooth);
} }
.remove-tech-btn:hover { .remove-tech-btn:hover {
background: #e74c3c; transform: scale(1.15);
transform: scale(1.1);
} }
@keyframes fadeIn { @keyframes fadeIn {

View File

@@ -1,39 +1,108 @@
/* --- UI Panels & Glassmorphism --- */ /* --- Panels --- */
.glass-panel { .glass-panel {
background: var(--glass-bg); background: color-mix(in srgb, var(--surface) 88%, transparent);
backdrop-filter: blur(12px); border: 1px solid var(--edge);
-webkit-backdrop-filter: blur(12px); border-radius: var(--radius-lg);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 2rem; padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow: var(--shadow-soft);
transition: var(--transition-smooth); transition: var(--transition-smooth);
} }
.glass-panel:hover { .glass-panel:hover {
border-color: rgba(212, 175, 55, 0.35); border-color: var(--edge-accent);
box-shadow: 0 8px 32px rgba(212, 175, 55, 0.05);
} }
.card { .card {
background: var(--bg-ocean-light); background: var(--surface);
border: 1px solid var(--glass-border); border: 1px solid var(--edge);
border-radius: 12px; border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.3); box-shadow: var(--shadow-soft);
transition: var(--transition-smooth);
} }
.card h3 { .card h3 {
font-family: var(--font-heading); color: var(--accent);
color: var(--gold); font-size: 1.35rem;
margin-bottom: 1rem; margin-bottom: 1rem;
border-bottom: 1px solid rgba(212, 175, 55, 0.2); border-bottom: 1px solid var(--edge-accent);
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
} }
/* --- Forms & Controls --- */ /* 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 { .form-group {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
text-align: left; text-align: left;
@@ -47,40 +116,35 @@
font-weight: 500; font-weight: 500;
} }
.input-field { .input-field, .form-control, .select-field, .copy-input {
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: 0.7rem 1rem;
background: rgba(7, 11, 18, 0.8); background: var(--well);
border: 1px solid var(--glass-border); border: 1px solid var(--edge);
border-radius: 8px; border-radius: var(--radius-sm);
color: var(--text-primary); color: var(--text);
font-family: var(--font-body); font-family: var(--font-body);
font-size: 1rem; font-size: 1rem;
transition: var(--transition-smooth); transition: var(--transition-smooth);
} }
.input-field:focus { .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; outline: none;
border-color: var(--neon-cyan); border-color: var(--accent);
box-shadow: 0 0 10px rgba(0, 242, 254, 0.2); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
} }
.select-field { .select-field {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(7, 11, 18, 0.8);
border: 1px solid var(--glass-border);
border-radius: 8px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 1rem;
transition: var(--transition-smooth);
cursor: pointer; cursor: pointer;
} }
.select-field:focus { .input-large {
outline: none; padding: 0.9rem 1.1rem;
border-color: var(--neon-cyan); font-size: 1.1rem;
} }
.input-row { .input-row {
@@ -92,96 +156,116 @@
flex: 1; 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 --- */ /* --- Buttons --- */
.btn { .btn {
display: inline-block; display: inline-block;
padding: 0.75rem 1.5rem; padding: 0.7rem 1.5rem;
font-family: var(--font-body); font-family: var(--font-heading);
font-weight: 700; font-weight: 700;
font-size: 1rem; font-size: 1rem;
text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
border: none; border: 1px solid transparent;
border-radius: 8px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
transition: var(--transition-smooth); transition: var(--transition-smooth);
text-decoration: none; text-decoration: none;
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-dark) 100%); background: linear-gradient(160deg, var(--accent-bright) 0%, var(--accent) 45%, var(--accent-dark) 100%);
color: var(--text-dark); border-color: var(--accent-dark);
box-shadow: 0 4px 15px var(--gold-glow); color: var(--text-inverse);
box-shadow: var(--shadow-soft);
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(212, 175, 55, 0.6); box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 35%, transparent);
} }
.btn-secondary { .btn-secondary {
background: rgba(255,255,255,0.08); background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid rgba(255,255,255,0.15); border-color: var(--edge);
color: var(--text-primary); color: var(--text);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: rgba(255,255,255,0.15); background: color-mix(in srgb, var(--text) 15%, transparent);
transform: translateY(-2px); transform: translateY(-2px);
} }
.btn-deep { .btn-deep {
background: linear-gradient(135deg, #152238 0%, #0d1726 100%); background: color-mix(in srgb, var(--deep) 10%, var(--surface));
border: 1px solid var(--neon-cyan); border-color: var(--deep);
color: var(--neon-cyan); color: var(--deep);
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.1);
} }
.btn-deep:hover:not(:disabled) { .btn-deep:hover:not(:disabled) {
background: var(--neon-cyan); background: var(--deep);
color: var(--text-dark); color: var(--text-inverse);
box-shadow: 0 4px 20px rgba(0, 242, 254, 0.4);
transform: translateY(-2px); transform: translateY(-2px);
} }
.btn-danger { .btn-danger {
background: linear-gradient(135deg, #7a1c1c 0%, #c0392b 100%); background: color-mix(in srgb, var(--danger) 18%, var(--surface));
color: var(--text-primary); border-color: var(--danger);
box-shadow: 0 4px 15px rgba(192, 57, 43, 0.2); color: color-mix(in srgb, var(--danger) 70%, var(--text));
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
background: var(--danger);
color: var(--text-inverse);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(192, 57, 43, 0.5); }
.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 { .btn-gold {
background: transparent; background: transparent;
border: 2px solid var(--gold); border: 2px solid var(--accent);
color: var(--gold); color: var(--accent);
box-shadow: 0 0 10px rgba(212,175,55,0.1);
} }
.btn-gold:hover:not(:disabled) { .btn-gold:hover:not(:disabled) {
background: var(--gold); background: var(--accent);
color: var(--bg-dark); color: var(--text-inverse);
box-shadow: 0 0 20px var(--gold-glow); box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 40%, transparent);
transform: translateY(-2px); transform: translateY(-2px);
} }
.btn-full { .btn-full, .btn-block { width: 100%; }
width: 100%; .btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
} .btn-large { padding: 0.9rem 2rem; font-size: 1.15rem; }
.btn-small {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.1rem;
}
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.5;
@@ -191,111 +275,215 @@
} }
.glow-effect:hover { .glow-effect:hover {
filter: brightness(1.2); filter: brightness(1.15);
} }
/* --- Event Log --- */ /* --- Alerts --- */
.event-log-fab { .alert {
position: fixed; padding: 0.75rem 1.25rem;
bottom: 20px; margin-bottom: 1.5rem;
right: 20px; border-radius: var(--radius-sm);
width: 60px; text-align: center;
height: 60px; font-weight: 500;
border-radius: 50%; }
background: var(--glass-bg);
backdrop-filter: blur(8px); .alert-danger {
-webkit-backdrop-filter: blur(8px); background: color-mix(in srgb, var(--danger) 14%, transparent);
border: 1px solid var(--gold); border: 1px solid var(--danger);
color: var(--gold); color: color-mix(in srgb, var(--danger) 75%, var(--text));
font-size: 1.5rem; }
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5), 0 0 10px rgba(212, 175, 55, 0.2);
.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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; gap: 0.75rem;
z-index: 1000;
transition: var(--transition-smooth);
} }
.event-log-fab:hover { /* --- Modal overlay (shared by popups) --- */
transform: scale(1.1); .modal-backdrop {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6), 0 0 15px rgba(212, 175, 55, 0.4);
}
.event-log-panel {
position: fixed; position: fixed;
bottom: 90px; inset: 0;
right: 20px; z-index: 2000;
width: 350px;
max-width: calc(100vw - 40px);
height: 400px;
max-height: calc(100vh - 120px);
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
display: flex; display: flex;
flex-direction: column;
z-index: 999;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.event-log-panel.hidden {
transform: translateY(20px) scale(0.95);
opacity: 0;
pointer-events: none;
}
.event-log-header {
display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem; justify-content: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding: 20px;
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
backdrop-filter: blur(6px);
} }
.event-log-header h3 { .modal-box {
margin: 0; position: relative;
font-family: var(--font-heading); max-width: 440px;
font-size: 1.1rem; width: 100%;
color: var(--gold); max-height: 88vh;
}
.event-log-container {
flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1rem; padding: 1.75rem;
display: flex; border: 1px solid var(--edge);
flex-direction: column; border-radius: var(--radius-md);
gap: 0.5rem; box-shadow: var(--shadow-deep);
} }
.event-list { .modal-box h3 {
list-style: none; margin-top: 0;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.8rem;
} }
.event-item { .modal-box.sheet-modal {
font-size: 0.9rem; max-width: 520px;
background: rgba(0, 0, 0, 0.3); text-align: left;
padding: 0.75rem;
border-radius: 8px;
border-left: 3px solid var(--neon-cyan);
} }
.event-time { .modal-box.character-sheet-modal {
display: block; max-width: 1260px;
font-size: 0.75rem; }
.modal-close {
position: absolute;
top: 0.6rem;
right: 0.75rem;
background: transparent;
border: none;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 0.25rem; font-size: 1.6rem;
line-height: 1;
cursor: pointer;
padding: 0.1rem 0.4rem;
border-radius: var(--radius-sm);
} }
.event-msg { .modal-close:hover {
color: var(--text-primary); 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

@@ -1,237 +0,0 @@
/* --- Variables & Tokens --- */
:root {
--bg-dark: #070b12;
--bg-ocean: #0d1726;
--bg-ocean-light: #16243b;
--gold: #d4af37;
--gold-glow: rgba(212, 175, 55, 0.4);
--gold-dark: #aa841c;
--neon-cyan: #00f2fe;
--neon-emerald: #00ff87;
--red-suit: #ff2a5f;
--red-glow: rgba(255, 42, 95, 0.3);
--black-suit: #e2e8f0;
--black-glow: rgba(226, 232, 240, 0.2);
--glass-bg: rgba(13, 23, 38, 0.7);
--glass-bg-hover: rgba(22, 36, 59, 0.85);
--glass-border: rgba(212, 175, 55, 0.2);
--glass-border-focus: rgba(0, 242, 254, 0.4);
--text-primary: #f1f5f9;
--text-muted: #94a3b8;
--text-dark: #0f172a;
--font-heading: 'Cinzel', serif;
--font-body: 'Outfit', sans-serif;
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* --- Base Reset & Setup --- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-dark);
}
::-webkit-scrollbar-thumb {
background: var(--gold-dark);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--gold);
}
/* --- Layout Components --- */
.app-header {
background: rgba(7, 11, 18, 0.95);
border-bottom: 2px solid var(--gold);
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.logo-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-container h1 {
font-family: var(--font-heading);
font-size: 1.8rem;
font-weight: 900;
letter-spacing: 2px;
background: linear-gradient(135deg, var(--gold) 30%, #fff 70%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 10px var(--gold-glow);
}
.math-magic {
font-family: var(--font-heading);
font-size: 2.2rem;
color: var(--neon-cyan);
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
animation: pulse-glow 3s infinite ease-in-out;
}
.header-status {
display: flex;
align-items: center;
gap: 1rem;
}
.player-pill {
background: rgba(0, 242, 254, 0.1);
border: 1px solid var(--neon-cyan);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.5rem;
}
.player-pill .bullet {
width: 8px;
height: 8px;
background-color: var(--neon-emerald);
border-radius: 50%;
box-shadow: 0 0 8px var(--neon-emerald);
}
.phase-pill {
background: rgba(212, 175, 55, 0.1);
border: 1px solid var(--gold);
color: var(--gold);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 1px;
}
.app-main {
flex: 1;
padding: 2rem;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
.app-footer {
background: var(--bg-dark);
padding: 1.5rem;
text-align: center;
border-top: 1px solid rgba(255,255,255,0.05);
font-size: 0.85rem;
color: var(--text-muted);
}
/* --- Loader / Spinner widgets --- */
.loader-container {
padding: 4rem;
}
.spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(0, 242, 254, 0.1);
border-radius: 50%;
border-top-color: var(--neon-cyan);
animation: spin 1s ease-in-out infinite;
margin: 0 auto 1.5rem;
}
.spinner-small {
width: 20px;
height: 20px;
border: 2px solid rgba(255,255,255,0.1);
border-radius: 50%;
border-top-color: var(--gold);
animation: spin 1s ease-in-out infinite;
display: inline-block;
}
/* --- Alert styles --- */
.alert {
padding: 0.75rem 1.25rem;
margin-bottom: 1.5rem;
border-radius: 8px;
text-align: center;
font-weight: 500;
}
.alert-danger {
background: rgba(192, 57, 43, 0.2);
border: 1px solid #c0392b;
color: #e74c3c;
}
/* --- Animation keyframes --- */
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse-glow {
0% {
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
transform: scale(1);
}
50% {
text-shadow: 0 0 15px var(--neon-cyan), 0 0 30px var(--neon-cyan), 0 0 40px var(--neon-cyan);
transform: scale(1.05);
}
100% {
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
transform: scale(1);
}
}
/* --- Utilities --- */
.text-center { text-align: center; }
.text-left { text-align: left; }
.margin-top { margin-top: 1.5rem; }
.gold-text { color: var(--gold); }
.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%; }
.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; }
.select-xsmall { padding: 0.4rem; font-size: 0.85rem; }
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
/* --- Ghost World Styles --- */
.ghost-world {
filter: grayscale(95%) brightness(0.85) sepia(15%) contrast(1.05);
transition: filter 1.2s ease-in-out;
}

View File

@@ -1,78 +1,44 @@
/* --- Lobby / Hold View --- */ /* --- Lobby / Ship's Hold --- */
.lobby-view { .lobby-view {
max-width: 700px; max-width: 700px;
margin: 2rem auto; margin: 2rem auto;
} }
.lobby-view h2 {
font-family: var(--font-heading);
font-size: 2.2rem;
color: var(--gold);
margin-bottom: 0.5rem;
}
.lobby-status-box {
margin: 2rem 0;
}
.player-chips {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
}
.player-chip {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
padding: 0.5rem 1.25rem;
border-radius: 30px;
display: flex;
align-items: center;
gap: 0.5rem;
transition: var(--transition-smooth);
}
.player-chip.current-user {
background: rgba(0, 242, 254, 0.15);
border-color: var(--neon-cyan);
box-shadow: 0 0 10px rgba(0,242,254,0.15);
}
.player-chip.voted-chip {
border-color: var(--neon-emerald);
background-color: rgba(0,255,135,0.05);
}
.player-chip.not-voted-chip {
border-color: rgba(255,255,255,0.1);
}
.creator-badge {
background: var(--gold);
color: var(--text-dark);
font-size: 0.7rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 4px;
text-transform: uppercase;
}
.links-box { .links-box {
text-align: left; text-align: left;
margin-bottom: 2rem; 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 { .link-item {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.link-item h4 { .link-item h4 {
color: var(--text-primary); color: var(--text);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.lobby-join-code-item {
display: flex;
justify-content: center;
}
.copy-container { .copy-container {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -80,27 +46,17 @@
.copy-input { .copy-input {
flex: 1; flex: 1;
background: #05080e; background: var(--bg-deep);
border: 1px solid var(--glass-border);
color: var(--text-primary);
padding: 0.5rem 1rem;
border-radius: 8px;
font-family: var(--font-body);
font-size: 0.95rem; font-size: 0.95rem;
padding: 0.5rem 1rem;
} }
.copy-input.admin-input { .copy-input.admin-input {
border-color: var(--gold-dark); border-color: var(--edge-accent);
color: var(--gold); color: var(--accent);
} }
.admin-link-item { .admin-link-item {
border-top: 1px dashed rgba(212, 175, 55, 0.3); border-top: 1px dashed var(--edge-accent);
padding-top: 1.5rem; padding-top: 1.5rem;
} }
.info-text {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}

View File

@@ -1,8 +1,48 @@
/* --- Scene Play Board Layout --- */ /* --- Scene board layout --- */
.scene-view-layout { .scene-view-layout {
display: grid; display: grid;
grid-template-columns: 1.4fr 1fr; grid-template-columns: 180px minmax(0, 1.7fr) minmax(300px, 1fr);
gap: 2rem; 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) { @media (max-width: 1100px) {
@@ -11,11 +51,162 @@
} }
} }
/* --- 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 { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: 1px solid rgba(212, 175, 55, 0.2); border-bottom: 1px solid var(--edge-accent);
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -28,11 +219,11 @@
.deck-counter { .deck-counter {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--gold); color: var(--accent);
font-weight: 700; font-weight: 700;
} }
/* --- Active Obstacle Item Display --- */ /* --- Obstacles --- */
.obstacles-container { .obstacles-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -40,10 +231,10 @@
} }
.obstacle-item { .obstacle-item {
border: 1px solid var(--glass-border); border: 1px solid var(--edge);
border-radius: 10px; border-radius: var(--radius-md);
padding: 1.25rem; padding: 1.25rem;
background: rgba(7, 11, 18, 0.4); background: var(--well);
display: grid; display: grid;
grid-template-columns: 0.8fr 2fr 1fr 1fr; grid-template-columns: 0.8fr 2fr 1fr 1fr;
gap: 1rem; gap: 1rem;
@@ -51,6 +242,10 @@
overflow: hidden; overflow: hidden;
} }
.obstacle-item.in-challenge {
box-shadow: 0 0 0 2px var(--deep);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.obstacle-item { .obstacle-item {
grid-template-columns: 0.8fr 2fr 1.5fr; grid-template-columns: 0.8fr 2fr 1.5fr;
@@ -63,20 +258,44 @@
} }
} }
/* Color theme mappings for suits */ /* Suit accent stripes */
.suit-c { border-left: 4px solid var(--black-suit); } .suit-c, .suit-s { border-left: 4px solid var(--suit-black); }
.suit-s { border-left: 4px solid var(--black-suit); } .suit-h, .suit-d { border-left: 4px solid var(--suit-red); }
.suit-h { border-left: 4px solid var(--red-suit); }
.suit-d { border-left: 4px solid var(--red-suit); }
.obstacle-meta { /* 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; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
background: rgba(0,0,0,0.3); padding: 2px; /* room for the rings, which overflow:hidden would clip */
border-radius: 6px; }
padding: 0.5rem;
.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 { .suit-badge {
@@ -84,8 +303,8 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
.suit-c .suit-badge, .suit-s .suit-badge { color: var(--black-suit); } .suit-c .suit-badge, .suit-s .suit-badge { color: var(--suit-black); }
.suit-h .suit-badge, .suit-d .suit-badge { color: var(--red-suit); } .suit-h .suit-badge, .suit-d .suit-badge { color: var(--suit-red); }
.card-code { .card-code {
font-size: 0.8rem; font-size: 0.8rem;
@@ -94,10 +313,21 @@
} }
.obstacle-details h4 { .obstacle-details h4 {
font-family: var(--font-heading); color: var(--text);
color: var(--text-primary);
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
font-size: 1.1rem; 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 { .obstacle-details .desc {
@@ -106,29 +336,32 @@
} }
.obstacle-value-display { .obstacle-value-display {
align-self: start;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background: rgba(212, 175, 55, 0.04); background: color-mix(in srgb, var(--accent) 5%, transparent);
border: 1px dashed var(--glass-border); border: 1px dashed var(--edge-accent);
border-radius: 6px; border-radius: var(--radius-sm);
padding: 0.5rem; padding: 0.5rem;
} }
.obstacle-successes-display { .obstacle-successes-display {
align-self: start;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background: rgba(0, 255, 135, 0.04); background: color-mix(in srgb, var(--success) 5%, transparent);
border: 1px dashed rgba(0, 255, 135, 0.25); border: 1px dashed color-mix(in srgb, var(--success) 30%, transparent);
border-radius: 6px; border-radius: var(--radius-sm);
padding: 0.5rem; padding: 0.5rem;
} }
.obstacle-successes-display .val-number { .obstacle-successes-display .val-number {
color: var(--neon-emerald); color: var(--success);
font-size: 1.5rem;
} }
.val-label { .val-label {
@@ -141,132 +374,104 @@
font-size: 1.8rem; font-size: 1.8rem;
font-family: var(--font-heading); font-family: var(--font-heading);
font-weight: 900; font-weight: 900;
color: var(--gold); color: var(--accent);
} }
.played-column { .clear-obstacle-row {
grid-column: span 4; grid-column: 1 / -1;
border-top: 1px solid rgba(255,255,255,0.05);
padding-top: 0.75rem;
margin-top: 0.5rem;
} }
@media (max-width: 600px) { /* --- Challenges --- (framed in the Deep's teal so red/black always means suit color) */
.played-column { /* The challenge-area card IS the box; the challenge content sits flush inside it. */
grid-column: span 1; .challenge-area-card {
} border-color: color-mix(in srgb, var(--deep) 45%, transparent);
}
.played-column h5 {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.column-cards-flex {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
/* --- Challenges Section --- */
.challenges-container {
display: flex;
flex-direction: column;
gap: 1.25rem;
} }
.challenge-item { .challenge-item {
background: rgba(255,255,255,0.02); margin-bottom: 1rem;
border: 1px solid var(--glass-border);
border-radius: 8px;
padding: 1.25rem;
text-align: left; text-align: left;
} }
.challenge-item + .challenge-item {
border-top: 1px solid var(--edge-soft);
padding-top: 1rem;
}
.challenge-item h4 { .challenge-item h4 {
font-family: var(--font-heading); color: var(--deep);
color: var(--neon-cyan); font-size: 1.25rem;
font-size: 1.2rem; margin: 0;
margin-bottom: 0.25rem;
} }
.challenge-item .desc { .challenge-head {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.applied-obstacles h5 {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.applied-obs-row {
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem;
}
.obs-info {
display: flex;
align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.obs-info .symbol { .challenge-actions {
font-size: 1.2rem;
}
.play-card-form {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap;
align-items: center; align-items: center;
} }
.select-xsmall { .tax-callout {
padding: 0.25rem 0.5rem; margin-top: 0.75rem;
font-size: 0.8rem; padding: 0.75rem;
width: 140px; border: 1px dashed var(--accent);
background: var(--bg-dark); border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 6%, transparent);
} }
/* --- Deep Control panel --- */ .tax-callout p {
.deep-text { margin: 0 0 0.5rem 0;
color: var(--neon-cyan) !important;
} }
.deep-divider { /* --- Challenge area (middle column) --- */
border: 0; /* Obstacles applied to an active Challenge, pulled up into the Challenge panel */
height: 1px; .challenge-obstacles {
background: linear-gradient(to right, rgba(0, 242, 254, 0), rgba(0, 242, 254, 0.4), rgba(0, 242, 254, 0));
margin: 2rem 0;
}
.obstacles-checkboxes {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 1rem;
background: rgba(0,0,0,0.2); margin-top: 0.75rem;
padding: 1rem;
border-radius: 8px;
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--glass-border);
} }
.checkbox-label { /* The Deep's call-a-challenge + end-scene controls live below any open Challenges */
display: flex; .deep-challenge-controls {
align-items: center; margin-top: 0.5rem;
gap: 0.5rem; }
/* When a Challenge is active the rest of the Obstacle list collapses out of the way */
.obstacle-collapse > summary {
cursor: pointer; cursor: pointer;
font-size: 0.95rem; 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 { .hand-flex {

View File

@@ -1,4 +1,4 @@
/* --- Scene Setup / Role Choose --- */ /* --- Scene setup / role choice --- */
.setup-grid { .setup-grid {
display: grid; display: grid;
grid-template-columns: 1.2fr 1fr; grid-template-columns: 1.2fr 1fr;
@@ -6,6 +6,11 @@
margin: 2rem 0; margin: 2rem 0;
} }
.setup-grid.single {
grid-template-columns: minmax(0, 700px);
justify-content: center;
}
@media (max-width: 800px) { @media (max-width: 800px) {
.setup-grid { .setup-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -22,62 +27,44 @@
.role-btn { .role-btn {
padding: 1.5rem; padding: 1.5rem;
font-size: 1.2rem; font-size: 1.2rem;
background: rgba(255,255,255,0.03); background: var(--well);
border: 2px solid rgba(255,255,255,0.1); border: 2px solid var(--edge);
color: var(--text-muted); color: var(--text-muted);
} }
.role-btn.pirat-btn:hover, .role-btn.pirat-btn.active { .role-btn.pirat-btn:hover:not(:disabled), .role-btn.pirat-btn.active {
border-color: var(--neon-emerald); border-color: var(--pirat);
color: var(--text-primary); color: var(--text);
background-color: rgba(0,255,135,0.06); background-color: color-mix(in srgb, var(--pirat) 8%, transparent);
box-shadow: 0 0 15px rgba(0,255,135,0.15);
} }
.role-btn.deep-btn:hover, .role-btn.deep-btn.active { .role-btn.deep-btn:hover:not(:disabled), .role-btn.deep-btn.active {
border-color: var(--neon-cyan); border-color: var(--deep);
color: var(--text-primary); color: var(--text);
background-color: rgba(0,242,254,0.06); background-color: color-mix(in srgb, var(--deep) 8%, transparent);
box-shadow: 0 0 15px rgba(0,242,254,0.15);
} }
.large-badge { .role-badge {
padding: 0.5rem 2rem; padding: 0.15rem 0.75rem;
font-size: 1.5rem; border-radius: var(--radius-sm);
border-radius: 8px;
font-family: var(--font-heading); font-family: var(--font-heading);
margin-top: 0.5rem;
}
.role-badge.deep {
background: rgba(0, 242, 254, 0.15);
border: 2px solid var(--neon-cyan);
color: var(--neon-cyan);
box-shadow: 0 0 15px rgba(0,242,254,0.1);
}
.role-badge.pirat {
background: rgba(0, 255, 135, 0.15);
border: 2px solid var(--neon-emerald);
color: var(--neon-emerald);
box-shadow: 0 0 15px rgba(0,255,135,0.1);
}
.list-chips {
justify-content: flex-start;
}
.list-chips .player-chip {
padding: 0.4rem 1rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.deep-chip { .role-badge.deep {
border-color: var(--neon-cyan); background: color-mix(in srgb, var(--deep) 14%, transparent);
background: rgba(0, 242, 254, 0.03); border: 1px solid var(--deep);
color: var(--deep);
} }
.pirat-chip { .role-badge.pirat {
border-color: var(--neon-emerald); background: color-mix(in srgb, var(--pirat) 14%, transparent);
background: rgba(0, 255, 135, 0.03); 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

@@ -1,18 +0,0 @@
/* ==========================================================================
RATS WITH GATS DESIGN SYSTEM & STYLESHEET
A weathered math-magic pirate aesthetic: deep-ocean dark mode, glassmorphism,
neon math runes, gold accents, and animated card widgets.
This file imports modular stylesheets divided by page phase and components.
========================================================================== */
@import url("core.css");
@import url("components.css");
@import url("card.css");
@import url("welcome.css");
@import url("lobby.css");
@import url("character.css");
@import url("scene-setup.css");
@import url("scene-play.css");
@import url("upkeep.css");
@import url("admin.css");

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

@@ -1,4 +1,4 @@
/* --- Between Scenes Upkeep --- */ /* --- Between-scenes upkeep --- */
.between-grid { .between-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -6,6 +6,14 @@
margin: 2rem 0; 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) { @media (max-width: 800px) {
.between-grid { .between-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -19,12 +27,7 @@
.voted-confirmation-box { .voted-confirmation-box {
padding: 1.5rem; padding: 1.5rem;
text-align: center; text-align: center;
border: 1px solid var(--neon-emerald); border: 1px solid var(--success);
}
.success-text {
color: var(--neon-emerald);
font-weight: 700;
} }
.ranks-ledger { .ranks-ledger {
@@ -33,30 +36,66 @@
.ranks-ledger li { .ranks-ledger li {
padding: 0.5rem; padding: 0.5rem;
border-bottom: 1px solid rgba(255,255,255,0.05); border-bottom: 1px solid var(--edge-soft);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.deep-badge { /* --- Death fate panel --- */
background: rgba(0, 242, 254, 0.15); .death-fate-card {
border: 1px solid var(--neon-cyan); border: 2px solid var(--danger);
color: var(--neon-cyan); background: color-mix(in srgb, var(--danger) 5%, var(--surface));
padding: 0.1rem 0.4rem; padding: 2.5rem;
font-size: 0.75rem; margin: 2rem auto;
border-radius: 4px; max-width: 650px;
text-align: center;
} }
.pirat-badge { .death-fate-card > h2 {
background: rgba(0, 255, 135, 0.15); color: var(--danger);
border: 1px solid var(--neon-emerald); font-size: 2rem;
color: var(--neon-emerald); margin-bottom: 1rem;
padding: 0.1rem 0.4rem;
font-size: 0.75rem;
border-radius: 4px;
} }
/* --- Deep Upkeep Hand Refresh Drag & Drop --- */ .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 { .upkeep-drag-container {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -76,9 +115,9 @@
flex-direction: column; flex-direction: column;
} }
.upkeep-box h3 { .upkeep-box h4 {
margin-bottom: 1rem; margin-bottom: 1rem;
font-family: var(--font-heading); font-size: 1.1rem;
} }
.upkeep-flex { .upkeep-flex {
@@ -87,14 +126,16 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; gap: 1rem;
padding: 1.5rem; padding: 1.5rem;
background: rgba(0, 0, 0, 0.3); background: var(--well);
border: 1px dashed var(--glass-border); border: 1px dashed var(--edge);
border-radius: 8px; border-radius: var(--radius-md);
align-content: flex-start; align-content: flex-start;
min-height: 300px; min-height: 300px;
transition: var(--transition-smooth); transition: var(--transition-smooth);
} }
.upkeep-box[ondragover]:hover .upkeep-flex { .end-story-box {
border-color: var(--neon-cyan); margin-top: 1.5rem;
border-top: 1px solid var(--edge-soft);
padding-top: 1rem;
} }

View File

@@ -1,50 +1,89 @@
/* --- Welcome / Main Menu Screen --- */ /* --- Welcome / splash & join screens --- */
.welcome-container { .welcome-container {
max-width: 900px; max-width: 560px;
margin: 4rem auto; margin: 0 auto;
padding: 4rem 1.5rem 3rem;
text-align: center;
} }
.welcome-hero h2 { .welcome-hero {
margin-bottom: 2.5rem;
}
.hero-overline {
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: 2.5rem; font-size: 0.95rem;
color: var(--text-primary); letter-spacing: 4px;
margin-bottom: 1rem; text-transform: uppercase;
}
.welcome-hero .subtitle {
font-size: 1.2rem;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 3rem; margin-bottom: 0.5rem;
} }
.welcome-actions { .wordmark {
display: grid; font-family: var(--font-display);
grid-template-columns: 1fr 1fr; font-weight: 400;
gap: 2rem; font-size: clamp(3.2rem, 10vw, 5rem);
} line-height: 1;
letter-spacing: 2px;
@media (max-width: 768px) { background: linear-gradient(175deg, var(--accent-bright) 20%, var(--accent) 55%, var(--accent-dark) 100%);
.welcome-actions { -webkit-background-clip: text;
grid-template-columns: 1fr; background-clip: text;
} -webkit-text-fill-color: transparent;
} filter: drop-shadow(0 3px 12px color-mix(in srgb, var(--accent) 30%, transparent));
.action-card {
padding: 2.5rem 1.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 300px;
}
.action-card h3 {
font-family: var(--font-heading);
color: var(--gold);
font-size: 1.5rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.action-card p { .wordmark .amp {
color: var(--text-muted); font-size: 0.45em;
margin-bottom: 2rem; 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;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

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

@@ -1,7 +1,9 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { getSuggestion, getSuggestions } from '../lib/suggestions'; import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards';
import FaceCardAssigner from './FaceCardAssigner.svelte';
export let state; export let state;
@@ -14,11 +16,13 @@
}; };
let savingBasic = false; let savingBasic = false;
let editingBasic = false;
async function saveBasicDetails() { async function saveBasicDetails() {
savingBasic = true; savingBasic = true;
try { try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails); await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails);
editingBasic = false;
} catch(e) { } catch(e) {
console.error(e); console.error(e);
} finally { } finally {
@@ -26,6 +30,21 @@
} }
} }
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. // Like/Hate tasks are assigned automatically when character creation starts.
// This is a fallback for games that entered the phase before assignments existed. // This is a fallback for games that entered the phase before assignments existed.
onMount(async () => { onMount(async () => {
@@ -42,13 +61,17 @@
function getPlayerName(id) { function getPlayerName(id) {
if (!id) return "None"; if (!id) return "None";
const p = state.players.find(x => x.id === id); const p = state.players.find(x => x.id === id);
return p ? p.name : "Unknown"; return p ? displayName(p) : "Unknown";
} }
// Techniques // Techniques
let tech1 = '', tech2 = '', tech3 = ''; let tech1 = '', tech2 = '', tech3 = '';
let savingTechs = false; let savingTechs = false;
let techError = ''; 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() { async function submitTechniques() {
savingTechs = true; savingTechs = true;
@@ -57,6 +80,7 @@
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', { await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
tech1, tech2, tech3 tech1, tech2, tech3
}); });
revisingTechs = false;
} catch(e) { } catch(e) {
techError = e.message; techError = e.message;
} finally { } finally {
@@ -64,10 +88,79 @@
} }
} }
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 // Face Techniques
let jackTech = '', queenTech = '', kingTech = ''; let jackTech = '', queenTech = '', kingTech = '';
let assigningFace = false; let assigningFace = false;
let faceError = ''; let faceError = '';
// True while revising a previously-assigned J/Q/K layout, so we offer Cancel.
let revisingFace = false;
let faceSnapshot = {};
async function assignFaceTechniques() { async function assignFaceTechniques() {
assigningFace = true; assigningFace = true;
@@ -78,6 +171,44 @@
queen: queenTech, queen: queenTech,
king: kingTech 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) { } catch(e) {
faceError = e.message; faceError = e.message;
} finally { } finally {
@@ -101,68 +232,23 @@
} }
// Check completion status // Check completion status
$: phase = state.game.phase;
$: createdTechniques = state.player.created_techniques ? JSON.parse(state.player.created_techniques) : []; $: 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) : []; $: 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); $: 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); $: 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); $: techniquesComplete = !!(state.player.tech_jack && state.player.tech_queen && state.player.tech_king);
// Dev Mode // Dev Mode (toggled by the creator from the corner menu) reveals the
let devMode = false; // Suggest buttons; in real games players write their own material.
let autoFilling = false; $: devMode = !!state.game.dev_mode;
async function autoFillAll() {
autoFilling = true;
try {
// 1. Basic details — drawn from the same pools as the Suggest buttons
if (!basicComplete) {
basicDetails = {
avatar_look: getSuggestion('look'),
avatar_smell: getSuggestion('smell'),
first_words: getSuggestion('first_words'),
good_at_math: getSuggestion('good_at_math')
};
await saveBasicDetails();
}
// 2. Answer pending inbox questions with pool suggestions
for (const p of state.players) {
if (p.other_like_from_player_id === state.player.id && !p.other_like) {
inboxAnswers[`${p.id}:like`] = getSuggestion('like');
await submitDelegatedAnswer('like', p.id);
}
if (p.other_hate_from_player_id === state.player.id && !p.other_hate) {
inboxAnswers[`${p.id}:hate`] = getSuggestion('hate');
await submitDelegatedAnswer('hate', p.id);
}
}
// 3. Techniques — 3 distinct pool picks; retry if another player
// grabbed the same name first (the backend rejects collisions)
if (createdTechniques.length === 0) {
for (let attempt = 0; attempt < 5; attempt++) {
[tech1, tech2, tech3] = getSuggestions('techniques', 3);
await submitTechniques();
if (!techError) break;
}
}
// 4. Face Techniques - we need the swapped ones. We can't do this synchronously without waiting for the server
// to process the state, because swapped_techniques comes from the server once everyone is done.
if (!techniquesComplete && swappedTechniques.length === 3) {
jackTech = swappedTechniques[0];
queenTech = swappedTechniques[1];
kingTech = swappedTechniques[2];
await assignFaceTechniques();
}
} catch (e) {
console.error(e);
} finally {
autoFilling = false;
}
}
</script> </script>
@@ -170,23 +256,7 @@
<div class="view-header text-center"> <div class="view-header text-center">
<h2>Character Sheet Creation</h2> <h2>Character Sheet Creation</h2>
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p> <p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
<div class="flex justify-center items-center gap-2 text-sm text-gray-500 mt-2">
<input type="checkbox" id="devModeToggle" bind:checked={devMode} class="accent-gold"/>
<label for="devModeToggle">Dev Mode</label>
</div> </div>
</div>
{#if devMode}
<div class="bg-dark-900 border border-gold p-4 rounded-lg flex justify-between items-center mb-6 max-w-4xl mx-auto">
<span class="text-gold font-bold">🛠 Dev Mode Tools</span>
<button
class="btn btn-primary"
on:click={autoFillAll}
disabled={autoFilling}>
{autoFilling ? 'Auto-Filling...' : 'Auto-Fill Available Fields'}
</button>
</div>
{/if}
<!-- MAIN PANEL: Grid for Layout --> <!-- MAIN PANEL: Grid for Layout -->
<div class="creation-grid max-w-5xl mx-auto"> <div class="creation-grid max-w-5xl mx-auto">
@@ -195,7 +265,7 @@
<div class="card glass-panel section-basic"> <div class="card glass-panel section-basic">
<h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3> <h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3>
{#if basicComplete} {#if basicComplete && !editingBasic}
<!-- Saved View --> <!-- Saved View -->
<div class="read-only-sheet"> <div class="read-only-sheet">
<div class="record-line"><strong>Look:</strong> {state.player.avatar_look}</div> <div class="record-line"><strong>Look:</strong> {state.player.avatar_look}</div>
@@ -203,6 +273,7 @@
<div class="record-line"><strong>First Words:</strong> "{state.player.first_words}"</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 class="record-line"><strong>Good at Math?</strong> {state.player.good_at_math}</div>
</div> </div>
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
{:else} {:else}
<!-- Form View --> <!-- Form View -->
<form on:submit|preventDefault={saveBasicDetails} class="creation-form"> <form on:submit|preventDefault={saveBasicDetails} class="creation-form">
@@ -210,31 +281,36 @@
<label for="avatar_look">What does your Pi-Rat look like?</label> <label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row"> <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"> <input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like?</label> <label for="avatar_smell">What does your Pi-Rat smell like?</label>
<div class="input-row"> <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"> <input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="first_words">What were your first words after transforming?</label> <label for="first_words">What were your first words after transforming?</label>
<div class="input-row"> <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"> <input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button> {#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> </div>
<div class="form-group"> <div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label> <label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row"> <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"> <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">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button> {#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> </div>
<div class="input-row mt-4">
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button> <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> </form>
{/if} {/if}
</div> </div>
@@ -261,7 +337,7 @@
<div class="author-label"> {getPlayerName(state.player.other_hate_from_player_id)}</div> <div class="author-label"> {getPlayerName(state.player.other_hate_from_player_id)}</div>
</div> </div>
{:else} {:else}
<p class="mb-4 text-sm text-gray-400">Assigning crewmates to write your relationships...</p> <p class="mb-4 text-sm text-muted">Assigning crewmates to write your relationships...</p>
{/if} {/if}
</div> </div>
</div> </div>
@@ -272,29 +348,35 @@
<p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p> <p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p>
<div class="inbox-list"> <div class="inbox-list">
{#each state.players as p} {#each state.players as p}
{#if p.other_like_from_player_id === state.player.id && !p.other_like} {#if p.other_like_from_player_id === state.player.id}
<div class="inbox-item"> <div class="inbox-item">
<label>What do you LIKE about {p.name}?</label> <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"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button> {#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> <button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button>
</div> </div>
</div> </div>
{/if} {/if}
{#if p.other_hate_from_player_id === state.player.id && !p.other_hate} {#if p.other_hate_from_player_id === state.player.id}
<div class="inbox-item"> <div class="inbox-item">
<label>What do you HATE about {p.name}?</label> <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"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button> {#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> <button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button>
</div> </div>
</div> </div>
{/if} {/if}
{/each} {/each}
{#if state.players.filter(p => (p.other_like_from_player_id === state.player.id && !p.other_like) || (p.other_hate_from_player_id === state.player.id && !p.other_hate)).length === 0} {#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-gray-400 italic">No pending tasks! Wait for other players to assign you tasks.</p> <p class="inbox-empty-message text-muted italic">No pending tasks! Wait for other players to assign you tasks.</p>
{/if} {/if}
</div> </div>
</div> </div>
@@ -303,7 +385,9 @@
<div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;"> <div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;">
<h3>IV. Secret Techniques {techniquesComplete ? '✅' : '❌'}</h3> <h3>IV. Secret Techniques {techniquesComplete ? '✅' : '❌'}</h3>
{#if createdTechniques.length === 0} {#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> <p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
{#if techError} {#if techError}
<div class="alert alert-danger">{techError}</div> <div class="alert alert-danger">{techError}</div>
@@ -313,168 +397,138 @@
<label for="tech1">Technique #1:</label> <label for="tech1">Technique #1:</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field"> <input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech1 = getSuggestion('techniques', [tech2, tech3])}>Suggest</button> {#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> </div>
<div class="form-group"> <div class="form-group">
<label for="tech2">Technique #2:</label> <label for="tech2">Technique #2:</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field"> <input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech2 = getSuggestion('techniques', [tech1, tech3])}>Suggest</button> {#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> </div>
<div class="form-group"> <div class="form-group">
<label for="tech3">Technique #3:</label> <label for="tech3">Technique #3:</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field"> <input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech3 = getSuggestion('techniques', [tech1, tech2])}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button>{/if}
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button> <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> </form>
{:else if createdTechniques.length === 3 && swappedTechniques.length < 3} {:else if phase === 'character_creation'}
<div class="submitted-techniques text-center mt-4"> <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">#1: {createdTechniques[0]}</div>
<div class="tech-chip">#2: {createdTechniques[1]}</div> <div class="tech-chip">#2: {createdTechniques[1]}</div>
<div class="tech-chip">#3: {createdTechniques[2]}</div> <div class="tech-chip">#3: {createdTechniques[2]}</div>
<p class="waiting-box margin-top flex justify-center mt-4"> <p class="waiting-box margin-top flex justify-center mt-4">
<span class="spinner-small"></span> <span class="spinner-small"></span>
Techniques submitted! Waiting for other players to submit so we can shuffle and swap them. Techniques submitted! Once everyone submits, you'll trade them all away in blind swaps.
</p> </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> </div>
{:else if !techniquesComplete} {:else if !techniquesComplete}
{#if swappedTechniques.length === 3}
<div class="max-w-4xl mx-auto space-y-6"> <div class="max-w-4xl mx-auto space-y-6">
<p class="text-gray-300">Drag and drop your assigned techniques onto your Face Cards:</p> <p>Drag and drop your assigned techniques onto your Face Cards:</p>
{#if faceError} {#if faceError}
<div class="alert alert-danger">{faceError}</div> <div class="alert alert-danger">{faceError}</div>
{/if} {/if}
<div class="available-techniques-container"> <FaceCardAssigner techniques={swappedTechniques} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} />
<h4>Available Swapped Techniques</h4>
<p class="instruction-help">Drag a technique onto a card slot below to assign it.</p>
<div class="techniques-drag-pool">
{#each swappedTechniques as tech}
<div
draggable={tech !== jackTech && tech !== queenTech && tech !== kingTech}
on:dragstart={(e) => e.dataTransfer.setData('text/plain', tech)}
class="draggable-tech-chip {tech === jackTech || tech === queenTech || tech === kingTech ? 'assigned-hidden' : ''}"
>
<span class="drag-icon">⋮⋮</span> {tech}
</div>
{/each}
</div>
</div>
<!-- Visual Card Slots Grid -->
<div class="face-cards-slots-grid">
<!-- JACK SLOT -->
<div class="face-card-slot-wrapper">
<div class="face-card-slot {jackTech ? 'has-assignment' : ''}"
on:dragover|preventDefault
on:drop={(e) => {
const tech = e.dataTransfer.getData('text/plain');
if (tech) {
if (queenTech === tech) queenTech = '';
if (kingTech === tech) kingTech = '';
jackTech = tech;
}
}}>
<div class="card-bg-letter">J</div>
<div class="card-slot-header">
<span class="rank">J</span>
</div>
<div class="card-slot-body">
<div class="card-title">Jack</div>
<div class="drop-zone-placeholder">Drop Technique Here</div>
<div class="assigned-tech-content">
{#if jackTech}
<div class="assigned-tech-chip">
{jackTech}
<span class="remove-tech-btn" on:click={() => jackTech = ''}>×</span>
</div>
{/if}
</div>
</div>
<div class="card-slot-footer">
<span></span>
<span class="rank">J</span>
</div>
</div>
</div>
<!-- QUEEN SLOT -->
<div class="face-card-slot-wrapper">
<div class="face-card-slot {queenTech ? 'has-assignment' : ''}"
on:dragover|preventDefault
on:drop={(e) => {
const tech = e.dataTransfer.getData('text/plain');
if (tech) {
if (jackTech === tech) jackTech = '';
if (kingTech === tech) kingTech = '';
queenTech = tech;
}
}}>
<div class="card-bg-letter">Q</div>
<div class="card-slot-header">
<span class="rank">Q</span>
</div>
<div class="card-slot-body">
<div class="card-title">Queen</div>
<div class="drop-zone-placeholder">Drop Technique Here</div>
<div class="assigned-tech-content">
{#if queenTech}
<div class="assigned-tech-chip">
{queenTech}
<span class="remove-tech-btn" on:click={() => queenTech = ''}>×</span>
</div>
{/if}
</div>
</div>
<div class="card-slot-footer">
<span></span>
<span class="rank">Q</span>
</div>
</div>
</div>
<!-- KING SLOT -->
<div class="face-card-slot-wrapper">
<div class="face-card-slot {kingTech ? 'has-assignment' : ''}"
on:dragover|preventDefault
on:drop={(e) => {
const tech = e.dataTransfer.getData('text/plain');
if (tech) {
if (jackTech === tech) jackTech = '';
if (queenTech === tech) queenTech = '';
kingTech = tech;
}
}}>
<div class="card-bg-letter">K</div>
<div class="card-slot-header">
<span class="rank">K</span>
</div>
<div class="card-slot-body">
<div class="card-title">King</div>
<div class="drop-zone-placeholder">Drop Technique Here</div>
<div class="assigned-tech-content">
{#if kingTech}
<div class="assigned-tech-chip">
{kingTech}
<span class="remove-tech-btn" on:click={() => kingTech = ''}>×</span>
</div>
{/if}
</div>
</div>
<div class="card-slot-footer">
<span></span>
<span class="rank">K</span>
</div>
</div>
</div>
</div>
<div class="mt-6"> <div class="mt-6">
<button <button
@@ -484,15 +538,20 @@
> >
{assigningFace ? 'Assigning...' : 'Assign to Cards'} {assigningFace ? 'Assigning...' : 'Assign to Cards'}
</button> </button>
</div> {#if revisingFace}
</div> <button
{:else} class="btn btn-secondary w-full mt-4"
<div class="text-center italic text-gray-400"> disabled={assigningFace}
Waiting for all players to submit techniques so they can be shuffled... on:click={cancelReviseFaceTechniques}
</div> >Cancel keep my original assignment</button>
{/if} {/if}
</div>
</div>
{:else} {:else}
<div class="assigned-techs text-center mt-4"> <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">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">Q</span> {state.player.tech_queen}</div>
<div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div> <div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div>
@@ -500,32 +559,12 @@
<span class="spinner-small"></span> <span class="spinner-small"></span>
Ready! Waiting for other players to finish J/Q/K assignment... Ready! Waiting for other players to finish J/Q/K assignment...
</p> </p>
{#if phase === 'assign_techniques'}
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseFaceTechniques}>✏️ Revise Assignment</button>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
<!-- SECTION 5: Crew Creation Progress -->
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
<h3>V. Crew Creation Status</h3>
<p class="section-desc">See the creation progress of the entire crew in real time.</p>
<div class="crew-status-list">
<ul class="space-y-2 mt-4">
{#each state.players as p}
<li class="p-3 bg-dark-800 rounded flex justify-between items-center border-l-4 {p.tech_jack ? 'border-green-500' : 'border-gray-500'}">
<span>{p.name}</span>
<span class="text-sm">
{#if p.tech_jack}
<span class="text-green-400">Ready</span>
{:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3}
<span class="text-yellow-400">Assigning Face Cards</span>
{:else}
<span class="text-gray-400">Writing Sheet</span>
{/if}
</span>
</li>
{/each}
</ul>
</div>
</div>
</div> </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

@@ -1,20 +1,19 @@
<script> <script>
export let state; export let state;
$: pirats = state.players;
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3; $: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
</script> </script>
<div class="dashboard-container" style="max-width: 800px; margin: 0 auto; padding: 2rem;"> <div class="gameover-container">
<div class="card glass-panel" style="text-align: center; padding: 2rem;"> <div class="card glass-panel gameover-card">
<h1 style="font-family: var(--font-heading); color: var(--gold);">🎉 The Story Has Ended!</h1> <h1>🎉 The Story Has Ended!</h1>
<p class="section-desc" style="font-size: 1.1rem;"> <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. 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! Tell one last tale of how your Pi-Rat is remembered!
</p> </p>
<div class="sheet-group" style="text-align: left; margin-top: 2rem;"> <div class="sheet-group">
<h3 style="color: var(--gold);">Crew Objectives</h3> <h3 class="text-accent">Crew Objectives</h3>
<div class="objectives-checklist"> <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_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_2} disabled> <span>Choose a Captain</span></label>
@@ -27,24 +26,6 @@
{/if} {/if}
</div> </div>
<div class="sheet-group" style="text-align: left; margin-top: 1.5rem;">
<h3 style="color: var(--gold);">The Crew</h3>
{#each pirats as p}
<div style="background: rgba(0,0,0,0.2); padding: 0.75rem; border-radius: 6px; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<span>
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if}
<strong>{p.name}</strong> (Rank {p.rank})
{#if p.id === state.game.captain_player_id}<span style="color: var(--gold);"> ⭐ Captain</span>{/if}
</span>
<span style="font-size: 0.85rem; color: var(--text-muted);">
{p.completed_personal_1 ? '🔫 Gat' : '— no gat'} ·
{p.completed_personal_2 ? '📛 Named' : '— nameless'} ·
{p.completed_personal_3 ? '⚰️ Story complete' : '⛵ Still sailing'}
</span>
</div>
{/each}
</div>
<p class="info-text" style="margin-top: 2rem;">Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!</p> <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>
</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

@@ -4,6 +4,15 @@
export let state; export let state;
let starting = false; 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() { async function startGame() {
starting = true; starting = true;
@@ -15,59 +24,95 @@
starting = false; 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> </script>
<div class="lobby-view text-center"> <div class="lobby-view text-center">
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2> <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> <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="lobby-status-box glass-panel">
<h3>Joined Crew members</h3>
<div class="player-chips" id="lobby-player-list">
{#each state.players as p}
<div class="player-chip {p.id === state.player.id ? 'current-user' : ''}">
<span class="avatar-icon">🐀</span>
<span class="name">{p.name}</span>
{#if p.is_creator}<span class="creator-badge">Creator</span>{/if}
</div>
{/each}
</div>
</div>
<div class="links-box glass-panel"> <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"> <div class="link-item">
<h4>Share Join Link:</h4> <h4>Share Join Link:</h4>
<div class="copy-container"> <div class="copy-container">
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/join" class="copy-input" id="join-link-copy"> <input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/join" class="copy-input" id="join-link-copy">
<button on:click={() => { navigator.clipboard.writeText(document.getElementById('join-link-copy').value); alert('Join link copied!'); }} class="btn btn-secondary btn-small">Copy</button> <button on:click={() => copyLink('join', document.getElementById('join-link-copy').value)} class="btn btn-secondary btn-small">
{copiedLink === 'join' ? '✓ Copied' : 'Copy'}
</button>
</div> </div>
</div> </div>
{#if state.player.is_creator} {#if state.player.is_admin}
<div class="link-item admin-link-item"> <div class="link-item admin-link-item">
<h4 class="gold-text">⚠️ Creator Admin Magic Link:</h4> <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> <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"> <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"> <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={() => { navigator.clipboard.writeText(document.getElementById('admin-link-copy').value); alert('Admin link copied!'); }} class="btn btn-gold btn-small">Copy Admin Link</button> <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>
<div class="mt-4"> <div class="mt-4">
<a href="/#/game/{state.game.id}/admin" class="text-sm text-silver underline hover:text-white">Access Admin Panel directly</a> <a href="/#/game/{state.game.id}/admin" class="text-sm text-muted underline">Access Admin Panel directly</a>
</div> </div>
</div> </div>
{/if} {/if}
</div> </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"> <div class="lobby-action">
{#if state.player.is_creator} {#if state.player.is_admin}
{#if state.players.length >= 1} {#if state.players.length >= 3 || state.game.dev_mode}
<button on:click={startGame} <button on:click={startGame}
disabled={starting} disabled={starting}
class="btn btn-primary btn-large glow-effect"> class="btn btn-primary btn-large glow-effect">
{starting ? 'Starting...' : 'Start Character Creation'} {starting ? 'Starting...' : 'Start Character Creation'}
</button> </button>
{:else} {:else}
<button class="btn btn-primary btn-large" disabled>Waiting for players...</button> <button class="btn btn-primary btn-large" disabled>Waiting for players... ({state.players.length}/3 minimum)</button>
{/if} {/if}
{:else} {:else}
<div class="waiting-indicator"> <div class="waiting-indicator">

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

@@ -1,775 +1,79 @@
<script> <script>
import { apiRequest } from '../lib/api'; 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; export let state;
let playingCard = false; // The inline Event Log can be toggled from a fixed corner button to declutter.
let error = ''; let logOpen = true;
// Helpers
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; $: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null; $: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
$: isCaptain = state.game.captain_player_id === state.player.id; $: isDeep = state.player.role === 'deep';
$: challenges = state.challenges || []; $: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: openChallenges = challenges.filter(c => c.status === 'open'); // The Challenge area is shown when something is happening there, or to the Deep
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]'))); // (who calls Challenges and ends the scene from it).
$: myOpenChallenge = openChallenges.find(c => c.acting_player_id === state.player.id) || null; $: showChallengeArea = isDeep || openChallenges.length > 0;
$: 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');
function playerName(id) {
return state.players.find(p => p.id === id)?.name || '???';
}
function isJoker(code) {
return !!code && code.startsWith('Joker');
}
async function playCard(obstacleId, cardCode) {
playingCard = true;
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
obstacle_id: obstacleId,
card_code: cardCode
});
} catch(e) {
error = e.message;
} finally {
playingCard = false;
}
}
async function playJoker(obstacleId, cardCode) {
playingCard = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-joker`, 'POST', {
obstacle_id: obstacleId,
card_code: cardCode
});
} catch(e) {
error = e.message;
} finally {
playingCard = false;
}
}
async function endScene() {
try {
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
} catch(e) {
console.error(e);
}
}
async function clearObstacle(obstacleId) {
try {
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
} catch(e) {
console.error(e);
error = e.message;
}
}
async function toggleObjective(targetPlayerId, type) {
try {
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', {
type: type
});
} catch(e) {
console.error(e);
}
}
// --- Challenge controls (Deep) ---
let challengeTargetId = '';
let challengeStakes = '';
let selectedObstacles = {};
async function createChallenge() {
error = '';
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', {
target_player_id: challengeTargetId,
obstacle_ids: JSON.stringify(ids),
stakes: challengeStakes
});
challengeTargetId = '';
challengeStakes = '';
selectedObstacles = {};
} catch(e) {
error = e.message;
}
}
async function resolveChallenge(challengeId) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/resolve`, 'POST');
} catch(e) {
error = e.message;
}
}
async function cancelChallenge(challengeId) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/cancel`, 'POST');
} catch(e) {
error = e.message;
}
}
// --- Captain controls (Deep / current Captain) ---
let captainSelectId = '';
async function setCaptain() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-captain`, 'POST', {
target_player_id: captainSelectId
});
captainSelectId = '';
} catch(e) {
error = e.message;
}
}
// --- Gat/Name Tax ---
let taxTargetId = '';
function taxType(player) {
if (!player.completed_personal_1) return 'Gat';
if (!player.completed_personal_2) return 'Name';
return null;
}
function eligibleTaxTargets(challenge) {
const me = state.player;
const type = taxType(me);
if (!type) return [];
return scenePirats.filter(p =>
p.id !== me.id && !p.is_dead &&
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
);
}
async function requestTax(challengeId) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/request`, 'POST', {
target_player_id: taxTargetId
});
taxTargetId = '';
} catch(e) {
error = e.message;
}
}
async function respondTax(challengeId, accept) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/respond`, 'POST', {
accept: accept ? 'true' : 'false'
});
} catch(e) {
error = e.message;
}
}
// --- Pi-Rat vs Pi-Rat duels ---
let pvpOpponentId = '';
let pvpCard = '';
async function createPvp() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
defender_id: pvpOpponentId,
card_code: pvpCard
});
pvpOpponentId = '';
pvpCard = '';
} catch(e) {
error = e.message;
}
}
async function pvpDefend(challengeId, cardCode) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/pvp/defend`, 'POST', {
card_code: cardCode
});
} catch(e) {
error = e.message;
}
}
let showEventLog = false;
let newNameInput = '';
async function submitNewName() {
if (!newNameInput.trim()) return;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
new_name: newNameInput.trim()
});
newNameInput = '';
} catch(e) {
console.error(e);
error = e.message;
}
}
// Parsing card helpers
function getCardDisplay(code) {
if (!code) return "";
if (isJoker(code)) return "🃏 JOKER";
let rank = code.slice(0, -1);
let suit = code.slice(-1);
let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit;
return `${rank}${suitEmoji}`;
}
</script> </script>
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}> <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 Shared Table --> <!-- LEFT COLUMN: The Crew -->
<CrewColumn {state} />
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
<div class="table-column"> <div class="table-column">
{#if !isDeep}
<!-- Game Deck and Obstacles Roster -->
<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>
<div class="obstacles-container" id="scene-obstacles-container">
<!-- TOP STATUS BAR: Scene Info and Captain -->
<div class="scene-status-banner glass-panel" style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(7, 11, 18, 0.6); border: 1px solid rgba(212, 175, 55, 0.2); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<!-- Captain Badge -->
<div>
{#if captain}
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;">
🏴‍☠️ Captain: {captain.name} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
</span>
{:else}
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
🏴‍☠️ Captain: None{state.game.completed_crew_1 ? '' : ' (steal a ship first!)'}
</span>
{/if}
</div>
<!-- Active Scene Roster Mini-list -->
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-heading); margin-right: 0.25rem;">Active Roster:</span>
{#each state.players as p}
{#if p.role === 'deep'}
<span class="player-chip deep-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem;">
🌊 {p.name} (Deep)
</span>
{:else if p.role === 'pirat'}
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {p.is_ghost ? 'border-color: #7890a8; color: #a0b0c0; background: rgba(120, 150, 180, 0.1);' : ''} {p.is_dead ? 'border-color: var(--red-suit); color: var(--red-suit); background: rgba(255, 42, 95, 0.1);' : ''} {p.id === state.game.captain_player_id ? 'border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);' : ''}">
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {p.name} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
</span>
{/if}
{/each}
</div>
</div>
</div>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
<!-- OPEN CHALLENGES -->
{#each openChallenges as ch}
{@const chPlays = JSON.parse(ch.plays || '[]')}
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
<div class="glass-panel" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red-suit, #ff2a5f); border-radius: 8px; background: rgba(255, 42, 95, 0.06);">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h4 style="margin: 0; font-family: var(--font-heading);">
{#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 style="display: flex; gap: 0.5rem;">
<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}
<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>{getCardDisplay(ch.temp_card)}</strong>{playerName(ch.acting_player_id)} must answer it!
</p>
{:else}
<p class="info-text" style="margin: 0.5rem 0 0 0;">
Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'}
— attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
Other Pi-Rats may assist (assistants don't draw back).
</p>
{/if}
{#if chPlays.length > 0}
<p class="info-text" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;">
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
</p>
{/if}
<!-- Tax: pending request aimed at me -->
{#if ch.tax_state === 'requested'}
{#if myTaxRequest && myTaxRequest.id === ch.id}
<div style="margin-top: 0.75rem; padding: 0.75rem; border: 1px dashed var(--gold); border-radius: 6px;">
<p style="margin: 0 0 0.5rem 0;"><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 style="display: flex; gap: 0.5rem;">
<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(ch).length > 0}
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<span class="info-text" style="font-size: 0.9rem;">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(ch) as p}
<option value={p.id}>{p.name}</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 style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{#each hand.filter(c => !isJoker(c)) as card}
<button class="btn btn-secondary" on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
{/each}
</div>
</div>
{/if}
</div>
{/each}
{#each state.obstacles as obs}
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card}
{@const success_count = column_cards.filter(c => c.success).length}
{@const is_completed = success_count >= state.players.length}
{@const in_challenge = challengedObstacleIds.has(obs.id)}
<div class="obstacle-item suit-{obs.suit.toLowerCase()} {is_completed ? 'completed' : ''}"
style={in_challenge ? 'box-shadow: 0 0 0 2px var(--red-suit, #ff2a5f);' : ''}
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(obs.id, cardCode);
else playCard(obs.id, cardCode);
}
}}>
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
<div class="card-medium suit-{active_card_code.slice(-1).toLowerCase()}" title="Active Card: {getCardDisplay(active_card_code)}">
<div class="card-corner top-left">
<span class="val">{active_card_code.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
</div>
<div class="card-center">
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
</div>
<div class="card-corner bottom-right">
<span class="val">{active_card_code.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
</div>
</div>
</div>
<div class="obstacle-details">
<h4>{obs.title} {#if in_challenge}<span style="color: var(--red-suit, #ff2a5f); font-size: 0.8rem;">⚔️ In Challenge</span>{/if}</h4>
<p class="desc">{obs.description}</p>
</div>
<!-- Value Display -->
<div class="obstacle-value-display text-center">
<span class="val-label">Current Difficulty</span>
<span class="val-number">
{#if ['J', 'Q', 'K'].includes((column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card).slice(0, -1))}
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" style="font-size: 1.5rem;">
{success_count} / {state.players.length}
</span>
</div>
<!-- Played Cards Column -->
<div class="played-column">
<h5>Card History (Column)</h5>
<div class="column-cards-flex">
<div class="card-mini base-card">
<span class="val">{obs.original_card.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[obs.original_card.slice(-1)]}</span>
</div>
{#each column_cards as pc}
<div class="card-mini played-card rotated {pc.success ? 'success' : 'failure'}"
title="{pc.player_name} played {getCardDisplay(pc.card)}">
<span class="val">{pc.card.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
<span class="owner">{pc.player_name.slice(0,4)}</span>
</div>
{/each}
</div>
</div>
{#if is_completed && state.player.role === 'deep'}
<div class="text-center" style="margin-top: 1rem;">
<button class="btn btn-success" on:click={() => clearObstacle(obs.id)} style="width: 100%; border: 1px solid rgba(255,255,255,0.2);">Clear Obstacle</button>
</div>
{/if}
</div>
{:else}
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
{/each}
</div>
</div>
</div>
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
<div class="private-column">
<!-- DEEP CONTROLS: Challenges, End Scene & Objectives -->
{#if state.player.role === "deep"}
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
<!-- Call a Challenge -->
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">⚔️ Call a Challenge</h4>
<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}>{p.name} (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="form-control" placeholder="Stakes (optional): what happens on success/failure?" bind:value={challengeStakes} 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>
<!-- Captain Assignment -->
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">🏴‍☠️ Captaincy</h4>
<p class="info-text" style="font-size: 0.85rem;">The Captain holds power until they step down, lose a duel, die, or the ship is lost. Reassign after a leadership duel or resignation.</p>
<div style="display: flex; gap: 0.5rem;">
<select class="select-field" bind:value={captainSelectId} style="flex: 1;">
<option value="">No Captain</option>
{#each scenePirats.filter(p => !p.is_dead) as p}
<option value={p.id}>{p.name}</option>
{/each}
</select>
<button class="btn btn-secondary" on:click={setCaptain}>Set</button>
</div>
</div>
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">Crew Objectives</h4>
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_1} on:change={() => toggleObjective(state.player.id, 'crew_1')}>
<span>Steal a Ship</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_2} on:change={() => toggleObjective(state.player.id, 'crew_2')}>
<span>Choose a Captain</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_3} on:change={() => toggleObjective(state.player.id, 'crew_3')}>
<span>Commit Piracy</span>
</label>
</div>
</div>
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">Pi-Rat Personal Objectives</h4>
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
{#each state.players.filter(p => p.role === 'pirat') as p}
<div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
<strong>{p.name}</strong>
<div class="objectives-checklist" style="margin-top: 0.25rem;">
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_1} on:change={() => toggleObjective(p.id, 'personal_1')}> <span>1. Gat</span></label>
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_2} on:change={() => toggleObjective(p.id, 'personal_2')}> <span>2. Name</span></label>
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_3} on:change={() => toggleObjective(p.id, 'personal_3')}> <span>3. Die/Retire</span></label>
</div>
</div>
{/each}
</div>
<!-- End Scene Button -->
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
<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}
<!-- Player Hand Card -->
<div class="card glass-panel hand-card"> <div class="card glass-panel hand-card">
<h3>🃏 Your Hand</h3> <span class="hand-label">🃏 Your Hand</span>
<p class="section-desc">Keep your cards secret! {#if state.player.role !== "deep"}When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.{/if}</p>
<div class="hand-flex"> <div class="hand-flex">
{#each hand as card} {#each hand as card}
<div class="card-large suit-{isJoker(card) ? 'joker' : card.slice(-1).toLowerCase()} {isJoker(card) ? 'joker-card' : ''}" <Card {card} techs={playerTechs}
draggable={state.player.role !== "deep"} draggable={true}
on:dragstart={(e) => { on:dragstart={(e) => {
e.dataTransfer.setData('text/plain', card); e.dataTransfer.setData('text/plain', card);
e.currentTarget.classList.add("dragging"); e.currentTarget.classList.add("dragging");
}} }}
on:dragend={(e) => { on:dragend={(e) => {
e.currentTarget.classList.remove("dragging"); e.currentTarget.classList.remove("dragging");
}}> }} />
<div class="card-corner top-left">
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
<div class="card-center">
{#if isJoker(card)}
🃏
{:else}
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
{/if}
</div>
<div class="card-tech-overlay">
{#if !isJoker(card) && card.slice(0, -1) === "J"}
<div class="tech-tag" title="Automatic success when played">J: "{state.player.tech_jack}"</div>
{:else if !isJoker(card) && card.slice(0, -1) === "Q"}
<div class="tech-tag" title="Automatic success when played">Q: "{state.player.tech_queen}"</div>
{:else if !isJoker(card) && card.slice(0, -1) === "K"}
<div class="tech-tag" title="Automatic success when played">K: "{state.player.tech_king}"</div>
{/if}
</div>
<div class="card-corner bottom-right">
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
</div>
{:else} {: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> <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} {/each}
</div> </div>
</div> </div>
{/if}
<!-- Player Character Sheet Card --> {#if showChallengeArea}
{#if state.player.role !== "deep"} <div class="card glass-panel challenge-area-card">
<div class="card glass-panel sheet-card"> <ChallengePanel {state} />
<h3>🐀 {state.player.name}'s Character Sheet</h3>
<div class="character-sheet-details">
{#if state.player.is_dead}
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.1); border: 1px dashed var(--red-suit); padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
<h4 style="color: var(--red-suit); margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">💀 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> </div>
{/if} {/if}
{#if state.player.is_ghost} <div class="card glass-panel obstacle-list-card">
<div class="sheet-group prompt-box" style="background: rgba(120, 150, 180, 0.1); border: 1px dashed #7890a8; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;"> <div class="card-header">
<h4 style="color: #a0b0c0; margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">👻 Playing as a Ghost</h4> <h3>🌊 The Obstacle List</h3>
<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> <span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
</div> </div>
{/if} <ObstacleBoard {state} />
{#if state.player.needs_name}
<div class="sheet-group prompt-box" style="background: rgba(212, 175, 55, 0.1); border: 1px dashed var(--gold); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
<h4 style="color: var(--gold); margin-top: 0;">🏴‍☠️ You Earned a Name!</h4>
<p class="info-text" style="margin-bottom: 0.5rem;">You completed your second objective and ranked up! What is your new Pirate Name?</p>
<div style="display: flex; gap: 0.5rem;">
<input type="text" bind:value={newNameInput} class="form-control" placeholder="Enter new name...">
<button class="btn btn-gold" on:click={submitNewName} disabled={!newNameInput.trim()}>Claim Name</button>
</div>
</div>
{/if}
{#if state.player.tax_banned}
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.08); border: 1px dashed var(--red-suit); padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; text-align: 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> {state.player.avatar_look}</p>
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
</div>
<div class="sheet-group margin-top">
<h4>Assigned Secret Techniques (J/Q/K):</h4>
<ul class="techniques-list-sheet">
<li><strong>Jack (J):</strong> "{state.player.tech_jack}"</li>
<li><strong>Queen (Q):</strong> "{state.player.tech_queen}"</li>
<li><strong>King (K):</strong> "{state.player.tech_king}"</li>
</ul>
</div>
<!-- Duel another Pi-Rat -->
{#if !state.player.is_dead && scenePirats.filter(p => p.id !== state.player.id && !p.is_dead).length > 0}
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">⚔️ Duel a Pi-Rat</h4>
<p class="info-text" style="font-size: 0.85rem;">Challenge a crewmate (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={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge whom?</option>
{#each scenePirats.filter(p => p.id !== state.player.id && !p.is_dead) as p}
<option value={p.id}>{p.name} (Rank {p.rank})</option>
{/each}
</select>
<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}>{getCardDisplay(card)}</option>
{/each}
</select>
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
</div>
{/if}
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">Crew Objectives</h4>
<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> </div>
</div> </div>
<div class="sheet-group margin-top"> <!-- RIGHT COLUMN: The Event Log (collapsible to a corner button) -->
<h4 style="color: var(--gold);">Personal Objectives</h4> {#if logOpen}
<div class="objectives-checklist"> <div class="log-column">
<label class="checkbox-label"> <EventLog {state} inline on:collapse={() => (logOpen = false)} />
<input type="checkbox" checked={state.player.completed_personal_1} disabled>
<span>1. Gat</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_2} disabled>
<span>2. Name</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_3} disabled>
<span>3. Die/Retire</span>
</label>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
<!-- Floating Event Log -->
<div class="floating-event-log {showEventLog ? 'open' : ''}">
<button class="toggle-log-btn" on:click={() => showEventLog = !showEventLog}>
{showEventLog ? '⬇️ Hide Log' : '📜 Event Log'}
</button>
{#if showEventLog}
<div class="log-content font-mono text-sm space-y-2 p-2">
{#each state.events as event}
<div class="p-2 border-l-2 border-gray-600 bg-black text-gray-400" style="border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 0.25rem;">
{event.message}
</div>
{:else}
<div class="p-2 text-gray-500">No events yet.</div>
{/each}
</div> </div>
{/if} {/if}
</div> </div>
<style> <button
.floating-event-log { class="log-reopen-btn"
position: fixed; title={logOpen ? 'Hide the event log' : 'Show the event log'}
bottom: 20px; on:click={() => (logOpen = !logOpen)}
right: 20px; >
width: 350px; {logOpen ? '✕ Close Log' : '📜 Event Log'}
background: rgba(10, 15, 25, 0.95); </button>
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
z-index: 1000;
display: flex;
flex-direction: column;
max-height: 60vh;
backdrop-filter: blur(10px);
}
.toggle-log-btn {
background: var(--dark-bg, #1a1a2e);
color: white;
border: none;
padding: 10px;
text-align: center;
cursor: pointer;
font-weight: bold;
border-radius: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.toggle-log-btn:hover {
background: rgba(255,255,255,0.1);
}
.log-content {
overflow-y: auto;
padding: 10px;
flex: 1;
}
.floating-event-log:not(.open) {
width: auto;
}
</style>

View File

@@ -40,21 +40,40 @@
} }
} }
$: allRolesAssigned = state.players.every(p => p.role !== null); $: allRolesAssigned = state.players.every(p => p.role !== null || p.needs_reroll);
$: deepPlayer = state.players.find(p => p.role === 'deep'); $: deepPlayer = state.players.find(p => p.role === 'deep');
$: piratPlayer = state.players.find(p => p.role === 'pirat'); $: piratPlayer = state.players.find(p => p.role === 'pirat');
$: canStart = allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_creator || state.player.role === 'deep'); $: enoughPlayers = state.players.length >= 3 || state.game.dev_mode;
$: canStart = enoughPlayers && allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_admin || state.player.role === 'deep');
</script> </script>
<div class="scene-setup-view text-center"> <div class="scene-setup-view text-center">
<h2>Scene Setup (Scene #{state.game.current_scene_number})</h2> <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> <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>
<div class="setup-grid"> {#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 --> <!-- Role Selection Card -->
<div class="card glass-panel role-selection-card"> <div class="card glass-panel role-selection-card">
<h3>Select Your Role</h3> <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"> <div class="role-buttons">
<button on:click={() => setRole('pirat')} <button on:click={() => setRole('pirat')}
disabled={settingRole || !allowedRoles.includes('pirat')} disabled={settingRole || !allowedRoles.includes('pirat')}
@@ -70,41 +89,23 @@
</div> </div>
<div class="role-restrictions mt-4"> <div class="role-restrictions mt-4">
{#if !allowedRoles.includes('pirat')} {#if !allowedRoles.includes('pirat') && !state.player.needs_reroll}
{#if state.game.current_scene_number === 1} {#if state.game.current_scene_number === 1}
<p class="text-red-400 text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p> <p class="text-danger text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p>
{:else} {:else}
<p class="text-red-400 text-sm italic">You are the only eligible player for The Deep this scene!</p> <p class="text-danger text-sm italic">You are the only eligible player for The Deep this scene!</p>
{/if} {/if}
{/if} {/if}
{#if !allowedRoles.includes('deep')} {#if !allowedRoles.includes('deep') && !state.player.needs_reroll}
{#if state.game.current_scene_number === 1} {#if state.game.current_scene_number === 1}
<p class="text-red-400 text-sm italic">You start at Rank 1, so you must play your Pi-Rat for the first scene!</p> <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} {:else}
<p class="text-red-400 text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p> <p class="text-danger text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
{/if} {/if}
{/if} {/if}
</div> </div>
</div> </div>
<!-- Live Roster Card -->
<div class="card glass-panel roster-card">
<h3>Live Roster Selection</h3>
<div class="roster-list" id="scene-roster-list">
{#each state.players as p}
<div class="roster-item">
<span class="player-name">{p.name} <span class="text-sm text-gray-400">(Rank {p.rank})</span></span>
{#if p.role === 'pirat'}
<span class="role-badge pirat">Pi-Rat</span>
{:else if p.role === 'deep'}
<span class="role-badge deep">The Deep</span>
{:else}
<span class="role-badge unassigned">Choosing...</span>
{/if}
</div>
{/each}
</div>
</div>
</div> </div>
<div class="action-box margin-top"> <div class="action-box margin-top">
@@ -118,14 +119,16 @@
class="btn btn-primary btn-large glow-effect"> class="btn btn-primary btn-large glow-effect">
{starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'} {starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'}
</button> </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} {:else if !allRolesAssigned}
<p class="text-gray-400 italic mt-4">Waiting for all players to choose a role...</p> <p class="text-muted italic mt-4">Waiting for all players to choose a role...</p>
{:else if !deepPlayer} {:else if !deepPlayer}
<p class="text-red-400 italic mt-4">Someone must play as The Deep!</p> <p class="text-danger italic mt-4">Someone must play as The Deep!</p>
{:else if !piratPlayer} {:else if !piratPlayer}
<p class="text-red-400 italic mt-4">Someone must play as a Pi-Rat!</p> <p class="text-danger italic mt-4">Someone must play as a Pi-Rat!</p>
{:else} {:else}
<p class="text-gray-400 italic mt-4">Waiting for The Deep or Creator to start the scene...</p> <p class="text-muted italic mt-4">Waiting for The Deep or Creator to start the scene...</p>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
<script> <script>
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { getSuggestion } from '../lib/suggestions'; import { displayName } from '../lib/cards';
import Card from './Card.svelte';
export let state; export let state;
@@ -12,22 +13,30 @@
// Helpers // Helpers
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; $: 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 // Drag & Drop State
let keepCards = []; let keepCards = [];
let discardCards = []; let discardCards = [];
let dragNDropInitialized = false; let dragNDropInitialized = false;
// Ghost & Re-rolling fate states // Ghost & Re-rolling fate choice
let isRolling = false;
let reRollDetails = {
avatar_look: '',
avatar_smell: '',
first_words: '',
good_at_math: ''
};
let rollingError = '';
async function becomeGhost() { async function becomeGhost() {
try { try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST'); await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST');
@@ -36,6 +45,14 @@
} }
} }
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() { 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; 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 { try {
@@ -45,30 +62,6 @@
} }
} }
async function submitReRoll() {
if (!reRollDetails.avatar_look.trim() ||
!reRollDetails.avatar_smell.trim() ||
!reRollDetails.first_words.trim() ||
!reRollDetails.good_at_math.trim()) {
rollingError = 'Please fill out all character details.';
return;
}
rollingError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/roll-new-character`, 'POST', reRollDetails);
isRolling = false;
// Clear inputs
reRollDetails = {
avatar_look: '',
avatar_smell: '',
first_words: '',
good_at_math: ''
};
} catch(e) {
rollingError = e.message;
}
}
$: { $: {
if (state && state.game.phase === 'deep_upkeep') { if (state && state.game.phase === 'deep_upkeep') {
if (!dragNDropInitialized) { if (!dragNDropInitialized) {
@@ -180,75 +173,32 @@
<h2>Between Scenes (Scene #{state.game.current_scene_number} Concluded)</h2> <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> <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} {#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll}
<!-- Death Fate Panel --> <!-- Death Fate Panel -->
<div class="card glass-panel death-fate-card" style="border: 2px solid var(--red-suit); background: rgba(255, 42, 95, 0.05); padding: 2.5rem; margin: 2rem auto; max-width: 650px; border-radius: 12px; box-shadow: 0 0 20px rgba(255,42,95,0.15); text-align: center;"> <div class="glass-panel death-fate-card">
<h2 style="color: var(--red-suit); font-family: var(--font-heading); margin-bottom: 1rem; font-size: 2rem;">💀 Your Pi-Rat Has Died!</h2> <h2>💀 Your Pi-Rat Has Died!</h2>
<p class="description" style="font-size: 1.15rem; color: var(--text-primary); margin-bottom: 2rem; line-height: 1.6;"> <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: 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> </p>
<div class="fate-choices">
{#if !isRolling} <button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}>
<div style="display: flex; gap: 1.5rem; justify-content: center; flex-wrap: wrap;">
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost} style="border: 1px solid var(--text-muted); font-size: 1.1rem; padding: 0.8rem 2rem;">
👻 Remain as a Ghost 👻 Remain as a Ghost
</button> </button>
<button class="btn btn-primary btn-large glow-effect" on:click={() => isRolling = true} style="background: var(--gold); border: 1px solid var(--gold); color: black; font-size: 1.1rem; padding: 0.8rem 2rem;"> <button class="btn btn-primary btn-large glow-effect" on:click={chooseReRoll}>
🐀 Roll a New Character 🐀 Roll a New Character
</button> </button>
</div> </div>
<p class="info-text" style="margin-top: 1.5rem; font-style: italic; color: var(--text-muted);"> <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 with surprise Secret Techniques and a name based on their smell. 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> </p>
{:else}
<div class="creation-form-wrapper" style="text-align: left; background: rgba(0,0,0,0.2); padding: 1.5rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.05);">
<h3 style="color: var(--gold); font-family: var(--font-heading); margin-bottom: 1.5rem; border-bottom: 1px solid rgba(212,175,55,0.2); padding-bottom: 0.5rem;">Create Your New Recruit</h3>
{#if rollingError}
<div class="alert alert-danger" style="margin-bottom: 1.5rem;">{rollingError}</div>
{/if}
<div class="form-group" style="margin-bottom: 1.25rem;">
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What does your Pi-Rat look like?</label>
<div class="input-row" style="display: flex; gap: 0.5rem;">
<input type="text" placeholder="e.g. Scruffy snout, wearing a tattered vest" bind:value={reRollDetails.avatar_look} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.avatar_look = getSuggestion('look')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
</div>
</div>
<div class="form-group" style="margin-bottom: 1.25rem;">
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What does your Pi-Rat smell like? (Determines name)</label>
<div class="input-row" style="display: flex; gap: 0.5rem;">
<input type="text" placeholder="e.g. Spiced rum, salty cheese, gunpowder" bind:value={reRollDetails.avatar_smell} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.avatar_smell = getSuggestion('smell')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
</div>
</div>
<div class="form-group" style="margin-bottom: 1.25rem;">
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What were your first words after transforming?</label>
<div class="input-row" style="display: flex; gap: 0.5rem;">
<input type="text" placeholder="e.g. Shiver my decimals!" bind:value={reRollDetails.first_words} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.first_words = getSuggestion('first_words')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
</div>
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">Is your Pi-Rat good at Math?</label>
<div class="input-row" style="display: flex; gap: 0.5rem;">
<input type="text" placeholder="e.g. Thinks Pi is a dessert" bind:value={reRollDetails.good_at_math} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.good_at_math = getSuggestion('good_at_math')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
</div>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1.5rem;">
<button class="btn btn-secondary" on:click={() => isRolling = false} style="flex: 1; padding: 0.75rem;">Cancel</button>
<button class="btn btn-primary" on:click={submitReRoll} style="flex: 2; padding: 0.75rem; background: var(--gold); border: 1px solid var(--gold); color: black;">Submit New Character</button>
</div>
</div>
{/if}
</div> </div>
{:else} {:else}
<div class="between-grid"> {#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 --> <!-- Ranks and Voting Card -->
<div class="card glass-panel voting-card"> <div class="card glass-panel voting-card">
<h3>1. Pirate Ranking (Voting)</h3> <h3>1. Pirate Ranking (Voting)</h3>
@@ -256,21 +206,27 @@
{#if state.game.phase === 'deep_upkeep'} {#if state.game.phase === 'deep_upkeep'}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Voting complete! Results tallied.</p> {#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> </div>
{:else if state.votes.find(v => v.voter_player_id === state.player.id)} {:else if hasVoted}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p> <p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
</div> </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} {:else}
<form on:submit|preventDefault={submitVote} class="vote-form inline-form"> <form on:submit|preventDefault={submitVote} class="vote-form inline-form">
<div class="form-group inline-group"> <div class="form-group inline-group">
<select class="select-field" bind:value={selectedVoteId} required> <select class="select-field" bind:value={selectedVoteId} required>
<option value="">Nominate crewmate...</option> <option value="">Nominate crewmate...</option>
{#each state.players as p} {#each myNominees as p}
{#if p.id !== state.player.id && p.role !== 'deep'} <option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
<option value={p.id}>{p.name} (Rank {p.rank})</option>
{/if}
{/each} {/each}
</select> </select>
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button> <button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button>
@@ -278,24 +234,10 @@
</form> </form>
{/if} {/if}
<!-- Voting Roster Status -->
{#if state.game.phase !== 'deep_upkeep'}
<div class="vote-status-table margin-top">
<h4>Nomination Progress:</h4>
<div class="player-chips list-chips" id="voting-roster">
{#each state.players as p}
{@const voted = state.votes.some(v => v.voter_player_id === p.id)}
<div class="player-chip {voted ? 'voted-chip' : 'not-voted-chip'}">
<span class="name">{p.name}</span>
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span>
</div>
{/each}
</div>
</div>
{/if}
</div> </div>
<!-- Resting Deep Player Card --> <!-- 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"> <div class="card glass-panel deep-rest-card">
<h3>2. Resting Deep Upkeep</h3> <h3>2. Resting Deep Upkeep</h3>
@@ -303,9 +245,20 @@
<div class="deep-rest-panel glass-panel"> <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> <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.game.phase === 'between_scenes'} {#if state.player.is_ready}
<p class="info-text text-center gold-text" style="margin-top: 1rem;">Voting is currently in progress. Hand refresh will begin after voting concludes.</p> <!-- Confirmation: the refresh went through; show the freshly-drawn hand. -->
{:else if state.game.phase === 'deep_upkeep'} <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;"> <p class="info-text" style="margin-top: 1rem;">
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/> <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. Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
@@ -319,28 +272,10 @@
<h4 class="gold-text">Hand (Keep)</h4> <h4 class="gold-text">Hand (Keep)</h4>
<div class="upkeep-flex"> <div class="upkeep-flex">
{#each keepCards as card} {#each keepCards as card}
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}" <Card {card} draggable={true} style="cursor: pointer;"
draggable="true" title="Click or drag to discard"
on:dragstart={(e) => handleDragStart(e, card)} on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToDiscard(card)} on:click={() => moveToDiscard(card)} />
style="cursor: pointer;"
title="Click or drag to discard">
<div class="card-corner top-left">
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
<div class="card-center">
{#if card.startsWith('Joker')}
🃏
{:else}
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
{/if}
</div>
<div class="card-corner bottom-right">
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
</div>
{:else} {:else}
<p class="empty-text text-center w-full">No cards in hand.</p> <p class="empty-text text-center w-full">No cards in hand.</p>
{/each} {/each}
@@ -354,28 +289,10 @@
<h4 class="gold-text">Discard Pile</h4> <h4 class="gold-text">Discard Pile</h4>
<div class="upkeep-flex"> <div class="upkeep-flex">
{#each discardCards as card} {#each discardCards as card}
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}" <Card {card} draggable={true} style="cursor: pointer;"
draggable="true" title="Click or drag to keep"
on:dragstart={(e) => handleDragStart(e, card)} on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToKeep(card)} on:click={() => moveToKeep(card)} />
style="cursor: pointer;"
title="Click or drag to keep">
<div class="card-corner top-left">
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
<div class="card-center">
{#if card.startsWith('Joker')}
🃏
{:else}
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
{/if}
</div>
<div class="card-corner bottom-right">
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
</div>
{:else} {:else}
<p class="empty-text text-center w-full">Drag cards here to discard them.</p> <p class="empty-text text-center w-full">Drag cards here to discard them.</p>
{/each} {/each}
@@ -384,8 +301,8 @@
</div> </div>
{#if isOverLimit} {#if isOverLimit}
<div style="border: 1px solid var(--red-suit); border-radius: 4px; padding: 1rem; margin: 1rem 0; background: rgba(255, 42, 95, 0.1);"> <div class="notice-banner danger" style="margin: 1rem 0; max-width: none;">
<p style="color: var(--red-suit); font-weight: bold; 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> <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> </div>
{/if} {/if}
@@ -395,31 +312,16 @@
{/if} {/if}
</div> </div>
{:else} {:else}
{#if state.game.phase === 'between_scenes'}
<p class="info-text text-center">Your Pi-Rat was active. Your hand cards carry over to the next scene.</p>
{:else if state.game.phase === 'deep_upkeep'}
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p> <p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
{/if} {/if}
</div>
{/if} {/if}
<div class="roster-sheet-summary margin-top text-left glass-panel">
<h4>Crew Rank Ledger:</h4>
<ul class="ranks-ledger">
{#each state.players as p}
<li>
<strong>{p.name}:</strong> Rank {p.rank}
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge" style="background: rgba(120, 150, 180, 0.15); border: 1px solid #7890a8; color: #a0b0c0; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; margin-left: 0.5rem; display: inline-block;">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
</li>
{/each}
</ul>
</div>
</div>
</div> </div>
<!-- Next Scene Ready Up --> <!-- Next Scene Ready Up -->
<div class="action-box margin-top"> <div class="action-box margin-top">
{#if state.game.phase === 'deep_upkeep'} {#if state.game.phase === 'deep_upkeep'}
{#if state.player.role === 'deep'} {#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> <p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
{:else} {:else}
<div class="waiting-box"> <div class="waiting-box">
@@ -428,7 +330,7 @@
</div> </div>
{/if} {/if}
{:else} {:else}
{#if !state.votes.find(v => v.voter_player_id === state.player.id)} {#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> <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> <button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
{:else} {:else}
@@ -447,8 +349,8 @@
{/if} {/if}
{/if} {/if}
{#if state.player.is_creator && state.game.phase === 'between_scenes'} {#if state.player.is_admin && state.game.phase === 'between_scenes'}
<div style="margin-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;"> <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> <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> <button on:click={finishGame} class="btn btn-danger">🏴‍☠️ End the Story</button>
</div> </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>

View File

@@ -1,5 +0,0 @@
<script>
let count = $state(0)
</script>
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>

View File

@@ -17,7 +17,9 @@ export async function apiRequest(endpoint, method = 'GET', data = null) {
if (errBody.error) msg = errBody.error; if (errBody.error) msg = errBody.error;
else if (errBody.detail) msg = errBody.detail; else if (errBody.detail) msg = errBody.detail;
} catch(e) {} } catch(e) {}
throw new Error(msg); const error = new Error(msg);
error.status = res.status;
throw error;
} }
return res.json(); 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,227 +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"
] ]
}; };
export function getSuggestion(type, exclude = []) { function pickFrom(arr, exclude = []) {
let arr = SUGGESTIONS[type];
if (!arr) return "";
const taken = new Set(exclude.map(s => s.trim().toLowerCase())); const taken = new Set(exclude.map(s => s.trim().toLowerCase()));
const available = arr.filter(s => !taken.has(s.trim().toLowerCase())); const available = arr.filter(s => !taken.has(s.trim().toLowerCase()));
if (available.length > 0) arr = available; const pool = available.length > 0 ? available : arr;
return arr[Math.floor(Math.random() * arr.length)]; return pool[Math.floor(Math.random() * pool.length)];
} }
// Draws `count` distinct suggestions from a pool. export function getSuggestion(type, exclude = []) {
export function getSuggestions(type, count) { const arr = SUGGESTIONS[type];
const picked = []; if (!arr) return "";
for (let i = 0; i < count; i++) { return pickFrom(arr, exclude);
const s = getSuggestion(type, picked); }
if (!s) break;
picked.push(s); // 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.
return picked; 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);
},
};
}

View File

@@ -1,6 +1,19 @@
import { mount } from 'svelte' 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.css'
import App from './App.svelte' 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, { const app = mount(App, {
target: document.getElementById('app'), target: document.getElementById('app'),

View File

@@ -1,13 +1,61 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title';
import { getSessions } from '../lib/sessions';
export let params = {}; export let params = {};
let gameId = params.id; let gameId = params.id;
let adminKey = ''; 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 data = null;
let error = ''; 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(() => { onMount(() => {
// Try to load admin_key from local storage // Try to load admin_key from local storage
@@ -27,16 +75,49 @@
data = null; 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> </script>
<div class="max-w-4xl mx-auto p-4 space-y-6"> <div class="admin-page">
<h1 class="text-3xl font-bold text-gold">Admin Panel</h1> <h1>Admin Panel</h1>
{#if !data} {#if !data}
<div class="card p-6"> <div class="card">
<h2 class="text-xl mb-4">Enter Admin Key</h2> <h2 class="mb-4">Enter Admin Key</h2>
<form on:submit|preventDefault={loadAdminData} class="flex gap-4"> <form on:submit|preventDefault={loadAdminData} class="flex gap-4">
<input type="text" bind:value={adminKey} class="form-control flex-grow" placeholder="Admin Key" required/> <input type="text" bind:value={adminKey} class="input-field flex-grow" placeholder="Admin Key" required/>
<button type="submit" class="btn btn-primary">Login</button> <button type="submit" class="btn btn-primary">Login</button>
</form> </form>
{#if error} {#if error}
@@ -44,45 +125,109 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="card p-6"> <div class="card">
<h2 class="text-xl font-bold text-silver mb-4">Game: {data.game.crew_name}</h2> <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>Phase:</strong> {data.game.phase}</p>
<p><strong>Admin Key:</strong> <span class="bg-dark-900 px-2 py-1 font-mono text-sm">{data.game.admin_key}</span></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>
<h3 class="text-xl font-bold text-silver mt-8 mb-4">Players</h3> {#if error}
<table class="w-full text-left bg-dark-900 rounded border border-gray-700"> <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> <thead>
<tr class="border-b border-gray-700"> <tr>
<th class="p-3">Name</th> <th>Pi-Rat</th>
<th class="p-3">Role</th> <th>Player</th>
<th class="p-3">Status</th> <th>Role</th>
<th class="p-3">Rank</th> <th>Status</th>
<th>Rank</th>
<th>Admin</th>
<th>Re-join Link</th>
<th>Kick</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.players as p} {#each data.players as p}
<tr class="border-b border-gray-800"> <tr>
<td class="p-3">{p.name}</td> <td>
<td class="p-3"> {#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 {#if p.role === 'deep'} 🌊 Deep
{:else if p.role === 'pirat'} 🐀 Pi-Rat {:else if p.role === 'pirat'} 🐀 Pi-Rat
{:else} None {:else} None
{/if} {/if}
</td> </td>
<td class="p-3"> <td>
{#if p.is_ghost} 👻 Ghost {#if p.is_ghost} 👻 Ghost
{:else if p.is_dead} 💀 Dead {:else if p.is_dead} 💀 Dead
{:else} Alive {:else} Alive
{/if} {/if}
</td> </td>
<td class="p-3">{p.rank}</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> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
<div class="mt-8"> <div class="mt-8">
<a href="/#/game/{gameId}/join" class="btn btn-secondary">Back to Game Join Page</a> <a href={backLink} class="btn btn-secondary">{backLabel}</a>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -1,13 +1,25 @@
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api'; 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 LobbyPhase from '../components/LobbyPhase.svelte';
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte'; import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
import SceneSetupPhase from '../components/SceneSetupPhase.svelte'; import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
import ScenePhase from '../components/ScenePhase.svelte'; import ScenePhase from '../components/ScenePhase.svelte';
import UpkeepPhase from '../components/UpkeepPhase.svelte'; import UpkeepPhase from '../components/UpkeepPhase.svelte';
import RecruitPhase from '../components/RecruitPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.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 = {}; export let params = {};
let gameId = params.id; let gameId = params.id;
@@ -16,52 +28,244 @@
let state = null; let state = null;
let error = ''; let error = '';
let intervalId; 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() { async function fetchState() {
if (staleSession) return;
try { try {
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`); 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; state = data;
error = ''; 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) { } catch (err) {
error = err.message; error = err.message;
} }
} }
onMount(() => { async function skipCharacterCreation() {
fetchState(); // Suggested values for everyone's free-text fields; the backend only
// Poll every 2 seconds // applies them to fields players haven't filled in themselves.
intervalId = setInterval(fetchState, 2000); 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(() => { onDestroy(() => {
destroyed = true;
if (intervalId) clearInterval(intervalId); if (intervalId) clearInterval(intervalId);
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) ws.close();
extraMenuLinks.set([]);
}); });
</script> </script>
{#if error} {#if error}
<div class="alert alert-danger" style="margin: 20px;"> <div class="alert alert-danger" style="margin: 20px;">
Error connecting to game: {error} 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> </div>
{/if} {/if}
{#if state} {#if state}
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}"> <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'} {#if state.game.phase === 'lobby'}
<LobbyPhase {state} /> <LobbyPhase {state} />
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques'} {:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
<CharacterCreationPhase {state} /> <CharacterCreationPhase {state} />
{:else if state.game.phase === 'scene_setup'} {:else if state.game.phase === 'scene_setup'}
<SceneSetupPhase {state} /> <SceneSetupPhase {state} />
{:else if state.game.phase === 'scene'}
<ScenePhase {state} />
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'} {:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
<UpkeepPhase {state} /> <UpkeepPhase {state} />
{:else if state.game.phase === 'recruit_creation'}
<RecruitPhase {state} />
{:else if state.game.phase === 'ended'} {:else if state.game.phase === 'ended'}
<GameOverPhase {state} /> <GameOverPhase {state} />
{:else} {:else}
<div class="card p-4">Unknown phase: {state.game.phase}</div> <div class="card p-4">Unknown phase: {state.game.phase}</div>
{/if} {/if}
</main>
{#if phaseLogOpen}
<div class="log-column">
<EventLog {state} inline on:collapse={() => (phaseLogOpen = false)} />
</div> </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} {:else if !error}
<div class="loading-container"> <div class="loading-container">
<p>Loading game state...</p> <p>Loading game state...</p>

View File

@@ -1,10 +1,45 @@
<script> <script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api'; 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 crewName = '';
let error = ''; let error = '';
let creating = false; 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() { async function createGame() {
if (!crewName.trim()) return; if (!crewName.trim()) return;
@@ -21,16 +56,96 @@
creating = false; 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> </script>
<div class="welcome-container"> <div class="welcome-container">
<div class="header"> <header class="welcome-hero">
<h1>RATS with GATS</h1> <p class="hero-overline">A Remote Play Companion for</p>
<p class="subtitle">A Remote Play Companion App</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>
<div class="card creation-card"> <div class="glass-panel creation-card">
<h2>Start a New Game</h2> <h2>Muster a New Crew</h2>
<form on:submit|preventDefault={createGame}> <form on:submit|preventDefault={createGame}>
<div class="form-group"> <div class="form-group">
<label for="crewName">Crew Name</label> <label for="crewName">Crew Name</label>
@@ -40,7 +155,7 @@
bind:value={crewName} bind:value={crewName}
required required
placeholder="e.g. The Salty Dogs" placeholder="e.g. The Salty Dogs"
class="form-control input-large" class="input-field input-large"
autocomplete="off" autocomplete="off"
/> />
</div> </div>
@@ -49,13 +164,82 @@
<div class="alert alert-danger">{error}</div> <div class="alert alert-danger">{error}</div>
{/if} {/if}
<button type="submit" class="btn btn-primary btn-large btn-block mt-4" disabled={creating}> <button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
{creating ? 'Creating...' : 'Create Game'} {creating ? 'Creating...' : 'Create Game'}
</button> </button>
</form> </form>
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
</div> </div>
<div class="links mt-4"> <div class="welcome-links">
<a href="/rules" class="text-muted">Read the Rules</a> <a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
</div> </div>
</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

@@ -1,12 +1,23 @@
<script> <script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title';
import { saveSession } from '../lib/sessions';
export let params = {}; export let params = {};
const PLAYER_NAME_KEY = 'pirats-player-name';
let gameId = params.id; let gameId = params.id;
let playerName = ''; let playerName = '';
let error = ''; let error = '';
let joining = false; let joining = false;
onMount(() => {
setPageTitle('Join the Crew');
playerName = localStorage.getItem(PLAYER_NAME_KEY) || '';
});
// We can extract ?creator=true from $querystring if needed, // We can extract ?creator=true from $querystring if needed,
// though the backend determines creator based on if they are the first player. // 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. // For UI purposes, we could show "You are the creator" if we want.
@@ -17,6 +28,10 @@
error = ''; error = '';
try { try {
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName }); 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}`); push(`/game/${gameId}/player/${data.id}`);
} catch (err) { } catch (err) {
error = err.message; error = err.message;
@@ -26,12 +41,17 @@
</script> </script>
<div class="welcome-container"> <div class="welcome-container">
<div class="header"> <header class="welcome-hero">
<h1>Join Crew</h1> <p class="hero-overline">Rats with Gats</p>
</div> <h1 class="wordmark">Join the Crew</h1>
</header>
<div class="card creation-card"> <div class="glass-panel creation-card">
<h2>Enter your Pi-Rat name</h2> <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}> <form on:submit|preventDefault={joinGame}>
<div class="form-group"> <div class="form-group">
<label for="playerName">Your Name</label> <label for="playerName">Your Name</label>
@@ -40,7 +60,7 @@
id="playerName" id="playerName"
bind:value={playerName} bind:value={playerName}
required required
class="form-control input-large" class="input-field input-large"
autocomplete="off" autocomplete="off"
autofocus autofocus
/> />
@@ -50,9 +70,13 @@
<div class="alert alert-danger">{error}</div> <div class="alert alert-danger">{error}</div>
{/if} {/if}
<button type="submit" class="btn btn-primary btn-large btn-block mt-4" disabled={joining}> <button type="submit" class="btn btn-primary btn-large btn-full mt-4" disabled={joining}>
{joining ? 'Joining...' : 'Join Game'} {joining ? 'Joining...' : 'Join Game'}
</button> </button>
</form> </form>
</div> </div>
<div class="welcome-links">
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
</div>
</div> </div>

View File

@@ -9,6 +9,11 @@ export default defineConfig({
'/api': { '/api': {
target: 'http://127.0.0.1:8000', target: 'http://127.0.0.1:8000',
changeOrigin: true, changeOrigin: true,
ws: true,
},
'/rules': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
} }
} }
} }

View File

@@ -9,9 +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",
"websockets",
"python-multipart", "python-multipart",
] ]
@@ -27,7 +29,7 @@ pirats = "pirats.main:main"
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
pirats = ["static/**/*"] 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,50 +74,209 @@ OBSTACLES_DATA = {
} }
} }
# Secret Pirate Technique suggestions for when players need ideas: 20 handcrafted # Secret Pirate Technique suggestions: served to the frontend's Suggest buttons
# names plus the same "{math word} {move} of {target}" combinations the frontend # via GET /api/suggestions/techniques and used to equip re-rolled recruits.
# suggestion pool (frontend/src/lib/suggestions.js) is built from. TECHNIQUE_SUGGESTIONS = [
_HANDCRAFTED_TECHNIQUES = [ "I meant to do that",
"I missed on purpose", "The floor was always a trap",
"Carlos will figure it out", "Carlos owes me one",
"Never trust a hatless captain", "Barry already disarmed it",
"I'm not left handed", "Jaspar took the blame",
"The Albatross is always watching", "Greg always comes back",
"I've got a bomb", "We left Greg on purpose",
"Divide by Pi", "The seagulls work for me",
"Cheese-scented smoke screen", "My whiskers told me so",
"Look behind you, a three-headed parrot!", "I was behind you the whole time",
"Parabolic boarding leap", "I am wearing two eyepatches",
"The Pocket Cannon", "Nobody suspects the small one",
"Aggressive Math-whining", "I licked it, so it's mine",
"Unspeakable Tail Swish", "The grog made me do it",
"Bandana Whip", "I know a guy",
"Double-barrelled cheese blaster", "The guy knows another guy",
"Mathematical Scurvy cure", "I greased the entire ship",
"I was hiding under the carpet", "I hid the cannonball in my cheeks",
"Batten down my feelings", "I taught the parrot everything it knows",
"Wait, is that cheese?", "Hold my grog",
"Sudden pirate flip" "Watch this",
] "I counted to Pi",
"Pi is exactly three when I'm in a hurry",
_TECHNIQUE_MATH_WORDS = [ "I cast Pie, again",
"Geometric", "Parabolic", "Fibonacci", "Trigonometric", "Prime Number", "I memorized the wrong map on purpose",
"Logarithmic", "Hypotenuse", "Algebraic", "Binary", "Vector" "I sneezed at exactly the right moment",
] "I read about this in a book I ate",
_TECHNIQUE_MOVES = [ "I learned this from a ghost",
"Drift", "Leap", "Flurry", "Slash", "Parry", "The barrel was empty on purpose",
"Slam", "Dodge", "Vortex", "Shield", "Barrage" "This is my emotional support cannon",
] "That wasn't even my final form",
_TECHNIQUE_TARGETS = [ "I've been holding my breath since Tuesday",
"the Cheese Reef", "the Stormy Sea", "Euler", "the Abacus", "the Gat", "It's a rental",
"Carlos", "the Deep", "Gauss", "the Coordinate Plane", "Shrapnel" "The ship likes me better",
] "My cousin is a cannonball",
"My scars spell out a map",
TECHNIQUE_SUGGESTIONS = _HANDCRAFTED_TECHNIQUES + [ "My other gat is a crossbow",
f"{math_word} {move} of {target}" "I prepared this speech in advance",
for math_word in _TECHNIQUE_MATH_WORDS "I read the rulebook upside down",
for move in _TECHNIQUE_MOVES "Technically, that's legal",
for target in _TECHNIQUE_TARGETS "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

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

View File

@@ -1,10 +1,16 @@
import json import json
import logging
import os
import random import random
from typing import List, Optional from typing import Any, Dict, List, Optional
from sqlmodel import Session from sqlmodel import Session, select
from .models import Game, Player, Obstacle, GameEvent from .models import Game, Player, GameEvent, new_join_code
from . import cards from . import cards
logger = logging.getLogger(__name__)
FACE_VALUES = ("J", "Q", "K")
# --- Card and Hand Utilities --- # --- Card and Hand Utilities ---
def get_player_hand(player: Player) -> List[str]: def get_player_hand(player: Player) -> List[str]:
@@ -19,6 +25,64 @@ def get_game_deck(game: Game) -> List[str]:
def set_game_deck(game: Game, deck: List[str]): def set_game_deck(game: Game, deck: List[str]):
game.deck_cards = json.dumps(deck) 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: 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, Calculates a player's maximum hand size based on their Rank relative to others,
@@ -50,10 +114,6 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player], capt
return base_size + 1 return base_size + 1
return base_size return base_size
def is_player_captain(player: Player, game: Game) -> bool:
"""The Captain is a persistent position tracked on the Game (set once the crew steals a ship)."""
return game.captain_player_id is not None and game.captain_player_id == player.id
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict: 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.""" """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} return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
@@ -78,6 +138,18 @@ def change_player_rank(db: Session, game: Game, player: Player, delta: int):
db.commit() db.commit()
apply_hand_increases(db, game, old_maxes) 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 = ""): 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.""" """Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
if game.captain_player_id == player_id: if game.captain_player_id == player_id:
@@ -93,9 +165,9 @@ def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str =
msg = f"{new_captain.name} is now the Captain!" msg = f"{new_captain.name} is now the Captain!"
if reason: if reason:
msg += f" ({reason})" msg += f" ({reason})"
add_game_event(db, game.id, msg) add_game_event(db, game.id, msg, kind="captain")
elif reason: elif reason:
add_game_event(db, game.id, f"The Captain's position is vacant. ({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): def reshuffle_discard_pile(db: Session, game: Game):
""" """
@@ -115,6 +187,10 @@ def reshuffle_discard_pile(db: Session, game: Game):
played = json.loads(obs.played_cards) played = json.loads(obs.played_cards)
for entry in played: for entry in played:
active_table_cards.add(entry["card"]) 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 # 3. The full deck of 54 cards
full_deck = [] full_deck = []
@@ -158,50 +234,114 @@ def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -
# --- CRUD Operations --- # --- 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: def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
deck = cards.get_fresh_deck() 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( game = Game(
join_code=join_code,
deck_cards=json.dumps(deck), deck_cards=json.dumps(deck),
phase="lobby", phase="lobby",
current_scene_number=1, current_scene_number=1,
extra_obstacles=0, extra_obstacles=0,
crew_name=crew_name crew_name=crew_name,
dev_mode=dev_mode_default()
) )
db.add(game) db.add(game)
db.commit() db.commit()
db.refresh(game) db.refresh(game)
logger.info("Game %s created (crew_name=%r, dev_mode=%s)", game.id, crew_name, game.dev_mode)
return game return game
def get_game(db: Session, game_id: str) -> Optional[Game]: def get_game(db: Session, game_id: str) -> Optional[Game]:
return db.get(Game, game_id) 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]: def get_player(db: Session, player_id: str) -> Optional[Player]:
return db.get(Player, player_id) return db.get(Player, player_id)
def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player: def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player:
game = db.get(Game, game_id) game = db.get(Game, game_id)
role = None
if game and game.phase == "scene": # Players who join after initial character creation create their Pi-Rat in
role = "pirat" # the between-scenes recruit_creation phase; until then they spectate.
needs_reroll = bool(game and game.phase not in ("lobby", "character_creation"))
player = Player( player = Player(
game_id=game_id, game_id=game_id,
name=name, name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
player_name=name,
is_creator=is_creator, 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) rank=2, # default starting rank (will be set properly when starting character creation)
hand_cards="[]", hand_cards="[]",
is_ready=False, is_ready=False,
role=role role=None,
needs_reroll=needs_reroll
) )
db.add(player) db.add(player)
db.commit() db.commit()
db.refresh(player) db.refresh(player)
add_game_event(db, game_id, f"{name} joined the crew!") 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 return player
def add_game_event(db: Session, game_id: str, message: str): def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
event = GameEvent(game_id=game_id, message=message) event = GameEvent(game_id=game_id, message=message, kind=kind)
db.add(event) db.add(event)
db.commit() 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

@@ -6,9 +6,10 @@ from .models import Game, Player, Obstacle, Challenge
from . import cards from . import cards
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, get_player, get_game, get_player_hand, set_player_hand,
draw_cards_for_player, add_game_event, change_player_rank draw_cards_for_player, add_game_event, change_player_rank, evaluate_card_play
) )
from .crud_scene import resolve_card_against_obstacle from .crud_scene import resolve_card_against_obstacle
from .crud_character import recruit_name
# --- Helpers --- # --- Helpers ---
@@ -25,7 +26,7 @@ def _apply_captain_tax(db: Session, game: Game, acting_player: Player):
"""Captains lose 1 Rank each time they personally fail a Challenge.""" """Captains lose 1 Rank each time they personally fail a Challenge."""
if game.captain_player_id == acting_player.id and acting_player.rank > 1: if game.captain_player_id == acting_player.id and acting_player.rank > 1:
change_player_rank(db, game, acting_player, -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}.") 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 --- # --- Deep Challenges ---
@@ -35,7 +36,8 @@ def create_challenge(
deep_player_id: str, deep_player_id: str,
target_player_id: str, target_player_id: str,
obstacle_ids: List[str], obstacle_ids: List[str],
stakes: str = "" stakes_success: str = "",
stakes_failure: str = ""
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat.""" """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) game = get_game(db, game_id)
@@ -49,6 +51,8 @@ def create_challenge(
return False, "The Deep cannot challenge itself. Pick a Pi-Rat!" return False, "The Deep cannot challenge itself. Pick a Pi-Rat!"
if target.is_dead: if target.is_dead:
return False, f"{target.name} is dead. The Deep has no quarrel with the deceased." 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: if not obstacle_ids:
return False, "Apply at least one Obstacle to the Challenge." return False, "Apply at least one Obstacle to the Challenge."
@@ -71,16 +75,21 @@ def create_challenge(
acting_player_id=target.id, acting_player_id=target.id,
challenger_player_id=deep_player.id, challenger_player_id=deep_player.id,
obstacle_ids=json.dumps(obstacle_ids), obstacle_ids=json.dumps(obstacle_ids),
stakes=stakes.strip(), stakes_success=stakes_success.strip(),
stakes_failure=stakes_failure.strip(),
) )
db.add(challenge) db.add(challenge)
db.commit() db.commit()
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids) 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}." msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
if stakes.strip(): success_text = stakes_success.strip()
msg += f" Stakes: {stakes.strip()}" failure_text = stakes_failure.strip()
add_game_event(db, game.id, msg) 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!" return True, "Challenge called!"
def play_challenge_card( def play_challenge_card(
@@ -156,7 +165,7 @@ def play_challenge_card(
result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)" result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)"
role_note = "" if player.id == challenge.acting_player_id else " (assist)" 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']}") 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["drew_card"] = drew_card
result["is_joker"] = False result["is_joker"] = False
@@ -191,7 +200,7 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
db.commit() db.commit()
outcome = "succeeded" if success else "failed" outcome = "succeeded" if success else "failed"
add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!") 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. # 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: if challenge.tax_state == "refused" and challenge.tax_target_id:
@@ -199,21 +208,27 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
thing = "Gat" if challenge.tax_type == "gat" else "Name" thing = "Gat" if challenge.tax_type == "gat" else "Name"
if refuser: if refuser:
if success: if success:
add_game_event(db, game.id, f"{acting.name} succeeded and keeps {refuser.name}'s {thing}! {refuser.name} must find a new one.") 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: else:
if challenge.tax_type == "gat": if challenge.tax_type == "gat":
acting.completed_personal_1 = False acting.completed_personal_1 = False
refuser.completed_personal_1 = True refuser.completed_personal_1 = True
# The Gat (and its description) returns to its owner.
refuser.gat_description = acting.gat_description
acting.gat_description = ""
else: else:
# Return the stolen name string; the failed thief reverts to
# their smell-based recruit identity.
acting.completed_personal_2 = False acting.completed_personal_2 = False
acting.needs_name = False
refuser.completed_personal_2 = True refuser.completed_personal_2 = True
refuser.name = acting.name
acting.name = recruit_name(acting)
acting.tax_banned = True acting.tax_banned = True
db.add(refuser) db.add(refuser)
db.add(acting) db.add(acting)
db.commit() db.commit()
change_player_rank(db, game, acting, -1) 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!") 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: if not success:
_apply_captain_tax(db, game, acting) _apply_captain_tax(db, game, acting)
@@ -235,7 +250,7 @@ def cancel_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple[
db.delete(challenge) db.delete(challenge)
db.commit() db.commit()
if game and target: if game and target:
add_game_event(db, game.id, f"The Deep withdrew the Challenge against {target.name}.") add_game_event(db, game.id, f"The Deep withdrew the Challenge against {target.name}.", kind="challenge")
return True, "Challenge withdrawn." return True, "Challenge withdrawn."
# --- Gat Tax & Name Tax --- # --- Gat Tax & Name Tax ---
@@ -258,7 +273,7 @@ def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id
return False, "A Tax has already been called on this Challenge." return False, "A Tax has already been called on this Challenge."
if requester.tax_banned: if requester.tax_banned:
return False, "You failed a refused Tax this scene — no more Gat/Name Taxes for you!" 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: 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." return False, "Pick another living Pi-Rat in the scene."
if not requester.completed_personal_1: if not requester.completed_personal_1:
@@ -280,7 +295,7 @@ def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id
game = get_game(db, challenge.game_id) game = get_game(db, challenge.game_id)
thing = "Gat" if tax_type == "gat" else "Name" 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!'") 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}." 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]: def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool) -> Tuple[bool, str]:
@@ -324,7 +339,7 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
challenge.tax_state = "accepted" challenge.tax_state = "accepted"
db.add(challenge) db.add(challenge)
db.commit() db.commit()
add_game_event(db, game.id, f"{responder.name} accepted the {thing} Tax and takes over the Challenge!{card_note}") 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." return True, "Tax accepted."
# Refused: hand over the Gat/Name. The requester attempts the Challenge themselves; # Refused: hand over the Gat/Name. The requester attempts the Challenge themselves;
@@ -332,17 +347,27 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
if challenge.tax_type == "gat": if challenge.tax_type == "gat":
requester.completed_personal_1 = True requester.completed_personal_1 = True
responder.completed_personal_1 = False 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: 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 requester.completed_personal_2 = True
requester.needs_name = True
responder.completed_personal_2 = False 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" challenge.tax_state = "refused"
db.add(requester) db.add(requester)
db.add(responder) db.add(responder)
db.add(challenge) db.add(challenge)
db.commit() db.commit()
change_player_rank(db, game, requester, 1) change_player_rank(db, game, requester, 1)
add_game_event(db, game.id, f"{responder.name} refused the {thing} Tax and must hand over their {thing}! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!") add_game_event(db, game.id, event_msg, kind="tax")
return True, "Tax refused. The prize is on the line!" return True, "Tax refused. The prize is on the line!"
# --- Pi-Rat vs Pi-Rat Challenges --- # --- Pi-Rat vs Pi-Rat Challenges ---
@@ -369,6 +394,8 @@ def create_pvp_challenge(
return False, "You cannot challenge yourself. (Well, you can, but not with cards.)" return False, "You cannot challenge yourself. (Well, you can, but not with cards.)"
if defender.is_dead: if defender.is_dead:
return False, f"{defender.name} is dead. Let them rest." 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: for challenge in game.challenges:
if challenge.status == "open" and defender.id in (challenge.target_player_id, challenge.acting_player_id): if challenge.status == "open" and defender.id in (challenge.target_player_id, challenge.acting_player_id):
@@ -397,7 +424,7 @@ def create_pvp_challenge(
parsed = cards.parse_card(card_code) parsed = cards.parse_card(card_code)
narration = cards.SUITS[parsed["suit"]]["narration"] 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}!") 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!" 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]]: def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code: str) -> Tuple[bool, str, Dict[str, Any]]:
@@ -430,43 +457,10 @@ def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code
set_player_hand(defender, hand) set_player_hand(defender, hand)
db.add(defender) db.add(defender)
temp_parsed = cards.parse_card(challenge.temp_card) # 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"]
success = False result = evaluate_card_play(defender, card_code, challenge.temp_card, temp_value, defender.rank)
details = "" success = result["success"]
is_technique = False
tech_name = ""
if played_parsed["value"] in ["J", "Q", "K"]:
success = True
is_technique = True
tech_name = {
"J": defender.tech_jack or "Jack Secret Technique",
"Q": defender.tech_queen or "Queen Secret Technique",
"K": defender.tech_king or "King Secret Technique",
}[played_parsed["value"]]
details = f"Used Secret Pirate Technique: '{tech_name}'!"
else:
# A face card acting as an Obstacle is worth the challenged Pi-Rat's Rank
if temp_parsed["value"] in ["J", "Q", "K"]:
target_value = defender.rank
obs_detail = f"Rank {defender.rank} (Face Card Obstacle)"
else:
target_value = temp_parsed["numeric_value"]
obs_detail = str(target_value)
privilege_bonus = 0
if defender.completed_personal_1 and played_parsed["suit"] in ["C", "S"]:
privilege_bonus = 1
if defender.completed_personal_2 and played_parsed["suit"] in ["H", "D"]:
privilege_bonus = 1
final_value = played_parsed["numeric_value"] + privilege_bonus
if final_value > target_value:
success = True
details = f"Success! Played {played_parsed['display']} ({final_value}) vs {temp_parsed['display']} ({obs_detail})."
else:
details = f"Failure! Played {played_parsed['display']} ({final_value}) vs {temp_parsed['display']} ({obs_detail})."
plays = json.loads(challenge.plays) plays = json.loads(challenge.plays)
plays.append({ plays.append({
@@ -475,7 +469,7 @@ def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code
"player_id": defender.id, "player_id": defender.id,
"player_name": defender.name, "player_name": defender.name,
"success": success, "success": success,
"details": details "details": result["details"]
}) })
challenge.plays = json.dumps(plays) challenge.plays = json.dumps(plays)
challenge.status = "succeeded" if success else "failed" challenge.status = "succeeded" if success else "failed"
@@ -487,19 +481,14 @@ def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code
drawn = draw_cards_for_player(db, game, defender, 1) drawn = draw_cards_for_player(db, game, defender, 1)
if drawn: if drawn:
drew_card = drawn[0] drew_card = drawn[0]
details += " (Drew a card back due to matching suit colors!)" result["details"] += " (Drew a card back due to matching suit colors!)"
outcome = "wins" if success else "loses" outcome = "wins" if success else "loses"
add_game_event(db, game.id, f"{defender.name} defends against {challenger.name} and {outcome}! {details}") add_game_event(db, game.id, f"{defender.name} defends against {challenger.name} and {outcome}! {result['details']}", kind="challenge")
if not success: if not success:
_apply_captain_tax(db, game, defender) _apply_captain_tax(db, game, defender)
return True, "Duel resolved.", { result["drew_card"] = drew_card
"success": success, result["is_joker"] = False
"details": details, return True, "Duel resolved.", result
"is_technique": is_technique,
"tech_name": tech_name,
"drew_card": drew_card,
"is_joker": False
}

View File

@@ -1,11 +1,38 @@
import json import json
import random import random
from sqlmodel import Session from sqlmodel import Session
from .models import Game, Player from .models import Game
from .crud_base import get_player, get_game, add_game_event from .crud_base import get_player, get_game, add_game_event
# --- Character Creation Operations --- # --- 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): def auto_delegate_all(db: Session, game: Game):
""" """
Assigns the Like/Hate delegated questions for every player at once. Assigns the Like/Hate delegated questions for every player at once.
@@ -86,39 +113,86 @@ def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3
# Check if all players have submitted techniques. If so, trigger the swap! # Check if all players have submitted techniques. If so, trigger the swap!
if not game: if not game:
return None return None
maybe_trigger_technique_swap(db, game)
return None
all_submitted = True def unsubmit_techniques(db: Session, player_id: str):
for p in game.players: """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) techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs): if len(techs) < 3 or not all(techs):
all_submitted = False return
break
if all_submitted:
trigger_technique_swap(db, game) trigger_technique_swap(db, game)
return None
def trigger_technique_swap(db: Session, game: Game): def trigger_technique_swap(db: Session, game: Game):
""" """
Shuffles and distributes 3 techniques created by OTHER players to each player. 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 = game.players players = swap_participants(game)
if not players: if not players:
return return
# Build a pool of all techniques with their creators # Reject the pool if any two techniques collide (case-insensitively)
pool = []
seen_texts = set() seen_texts = set()
has_duplicates = False has_duplicates = False
for p in players: for p in players:
techs = json.loads(p.created_techniques) for t in json.loads(p.created_techniques):
for t in techs:
t_clean = t.strip().lower() t_clean = t.strip().lower()
if t_clean in seen_texts: if t_clean in seen_texts:
has_duplicates = True has_duplicates = True
seen_texts.add(t_clean) seen_texts.add(t_clean)
pool.append({"text": t, "creator_id": p.id})
if has_duplicates: if has_duplicates:
# Force players to resubmit # Force players to resubmit
@@ -126,83 +200,249 @@ def trigger_technique_swap(db: Session, game: Game):
p.created_techniques = "[]" p.created_techniques = "[]"
db.add(p) db.add(p)
db.commit() 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.") 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 return
# 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: for p in players:
valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id] p.held_techniques = json.dumps([{"text": t, "creator_id": p.id} for t in json.loads(p.created_techniques)])
if len(valid_for_p) < 3: p.swapped_techniques = "[]"
# If only 1-2 players are playing, it might be impossible to assign 3 non-owned techniques. p.swap_offer_to_id = None
# In that case, let's allow assigning their own if there are no other options (e.g. 1 player). p.swap_offer_technique = ""
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()
add_game_event(db, game.id, "Phase changed: Swap Techniques")
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 p.is_ready = False
db.add(p) db.add(p)
game.phase = "swap_techniques" game.phase = "swap_techniques"
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Swap Techniques") 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 assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str): 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) player = get_player(db, player_id)
if not player: if not player:
return return False, "Player not found."
player.tech_jack = jack if not player.swap_offer_to_id:
player.tech_queen = queen return False, "You have no pending swap offer."
player.tech_king = king target = get_player(db, player.swap_offer_to_id)
player.is_ready = True 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.add(player)
db.commit() db.commit()
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks! maybe_finish_technique_swap(db, game)
game = get_game(db, player.game_id) return True, "Ready!" if ready else "No longer ready."
if not game:
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 return
if all(p.is_ready for p in game.players):
# 1. Randomly assign starting ranks # 1. Randomly assign starting ranks
players_list = list(game.players)
random.shuffle(players_list) 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 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." # "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." # "All remaining Players start at Rank 2. These Players may choose to play either during the first scene."
@@ -234,117 +474,353 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s
game.phase = "scene_setup" game.phase = "scene_setup"
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup") add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
def roll_new_character( def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
db: Session,
game_id: str,
player_id: str,
avatar_look: str,
avatar_smell: str,
first_words: str,
good_at_math: str
):
player = get_player(db, player_id) player = get_player(db, player_id)
game = get_game(db, game_id) if not player:
if not player or not game: return False, "Player not found."
return 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()
# 1. Return current hand cards to the deck # Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
from .crud_base import get_player_hand, set_player_hand, get_game_deck, set_game_deck, draw_cards_for_player, calculate_max_hand_size 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) hand = get_player_hand(player)
if hand:
deck = get_game_deck(game) deck = get_game_deck(game)
deck.extend(hand) deck.extend(hand)
random.shuffle(deck) random.shuffle(deck)
set_game_deck(game, deck) set_game_deck(game, deck)
set_player_hand(player, []) set_player_hand(player, [])
# 2. Reset properties. New recruits always start at Rank 1.
player.rank = 1
player.is_ready = False
player.completed_personal_1 = False
player.completed_personal_2 = False
player.completed_personal_3 = False
player.needs_name = False
player.needs_rank_3_bonus = False
player.is_dead = False
player.is_ghost = False
player.tax_banned = False
# A dead Captain's position does not pass to their replacement recruit # A dead Captain's position does not pass to their replacement recruit
if game.captain_player_id == player.id: if game.captain_player_id == player.id:
game.captain_player_id = None game.captain_player_id = None
# Save player-provided details # New recruits always start at Rank 1 with a blank sheet
player.avatar_look = avatar_look.strip() player.rank = 1
player.avatar_smell = avatar_smell.strip() player.role = None
player.first_words = first_words.strip() player.is_ready = False
player.good_at_math = good_at_math.strip() 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)
# Scent/Name is "Recruit [Smell]" — strip lead-ins like "smells like" or # Crewmates fill in the social questions and write the recruit's techniques.
# "faintly of" (the suggestion pool uses these) so the name reads naturally. # Prefer helpers who aren't busy creating their own recruit.
clean_smell = avatar_smell.strip() others = [p for p in game.players if p.id != player.id]
lead_ins = [ helpers = [p for p in others if not p.needs_reroll] or others
"suspiciously smelling of", "pleasantly smelling of", "reminiscent of", if helpers:
"smells like", "a mix of", "like a mix of", "strongly of", "faintly of", random.shuffle(helpers)
"like", "of", player.other_like_from_player_id = helpers[0].id
]
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:]
player.name = f"Recruit {clean_smell}"
# 3. Randomly assign 3 new secret techniques, avoiding any technique already
# in play on another player's sheet (collisions make for boring recruits).
from .cards import TECHNIQUE_SUGGESTIONS
in_use = set()
for p in game.players:
if p.id == player.id:
continue
for t in [p.tech_jack, p.tech_queen, p.tech_king] + json.loads(p.created_techniques) + json.loads(p.swapped_techniques):
if t:
in_use.add(t.strip().lower())
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use]
if len(available) < 3:
available = TECHNIQUE_SUGGESTIONS
chosen_techs = random.sample(available, 3)
player.created_techniques = json.dumps(chosen_techs)
player.swapped_techniques = json.dumps(chosen_techs)
player.tech_jack = chosen_techs[0]
player.tech_queen = chosen_techs[1]
player.tech_king = chosen_techs[2]
# 4. Auto-delegate Like/Hate questions to other players
other_players = [p for p in game.players if p.id != player.id]
if other_players:
like_target = random.choice(other_players)
player.other_like_from_player_id = like_target.id
player.other_like = "" player.other_like = ""
player.other_hate_from_player_id = helpers[1 % len(helpers)].id
if len(other_players) >= 2:
remaining = [op for op in other_players if op.id != like_target.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 = "" player.other_hate = ""
slots = [{"from_id": helpers[i % len(helpers)].id, "text": ""} for i in range(3)]
player.incoming_techniques = json.dumps(slots)
else: 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_from_player_id = None
player.other_like = "" player.other_like = ""
player.other_hate_from_player_id = None player.other_hate_from_player_id = None
player.other_hate = "" 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(player)
db.add(game) db.add(game)
db.commit() db.commit()
# 5. Draw cards up to new hand size based on new Rank 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) max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
draw_cards_for_player(db, game, player, max_size) 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")
add_game_event(db, game.id, f"{player.name} has joined the crew as a new Rank {player.rank} recruit!") 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."

View File

@@ -1,19 +1,27 @@
import json import json
import random import logging
from typing import Tuple, Dict, Any, Optional from typing import Tuple, Dict, Any
from sqlmodel import Session from sqlmodel import Session
from .models import Game, Player, Obstacle, Challenge from .models import Game, Player, Obstacle
from . import cards from . import cards
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, set_game_deck, calculate_max_hand_size, draw_cards_for_player, get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
add_game_event, reshuffle_discard_pile, capture_hand_maxes, apply_hand_increases, change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS,
change_player_rank, set_captain is_eligible_nominee
) )
logger = logging.getLogger(__name__)
# --- Scene Setup Operations --- # --- Scene Setup Operations ---
VALID_ROLES = ("pirat", "deep")
def update_scene_role(db: Session, player_id: str, role: str): 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) player = get_player(db, player_id)
if not player: if not player:
return return
@@ -34,13 +42,20 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
if not game: if not game:
return False, "Game not found." 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 players = game.players
# Default any unselected roles to 'pirat' # 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: for p in players:
if p.role is None: if p.role is None and not p.needs_reroll:
p.role = "pirat" p.role = "pirat"
db.add(p) db.add(p)
elif p.needs_reroll:
p.role = None
db.add(p)
db.commit() db.commit()
if game.current_scene_number == 1: if game.current_scene_number == 1:
@@ -64,7 +79,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
# 2. Check single eligible Deep player # 2. Check single eligible Deep player
# If only one player is eligible to play the Deep, they must play the Deep. # If only one player is eligible to play the Deep, they must play the Deep.
if game.current_scene_number > 1: if game.current_scene_number > 1:
eligible_deeps = [p for p in players if p.previous_role != "deep"] eligible_deeps = [p for p in players if p.previous_role != "deep" and not p.needs_reroll]
if len(eligible_deeps) == 1: if len(eligible_deeps) == 1:
must_be_deep_player = eligible_deeps[0] must_be_deep_player = eligible_deeps[0]
if must_be_deep_player.role != "deep": if must_be_deep_player.role != "deep":
@@ -115,7 +130,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
# Joker drawn as an Obstacle: discard it and permanently increase the # Joker drawn as an Obstacle: discard it and permanently increase the
# number of Obstacles required for following scenes by 1. # number of Obstacles required for following scenes by 1.
game.extra_obstacles += 1 game.extra_obstacles += 1
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.") add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.", kind="joker")
continue continue
info = cards.get_obstacle_info(card) info = cards.get_obstacle_info(card)
@@ -135,6 +150,8 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
# players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw. # players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw.
if game.current_scene_number == 1: if game.current_scene_number == 1:
for p in players: for p in players:
if p.needs_reroll:
continue
max_size = calculate_max_hand_size(p, players, game.captain_player_id) max_size = calculate_max_hand_size(p, players, game.captain_player_id)
current_hand = get_player_hand(p) current_hand = get_player_hand(p)
while len(current_hand) < max_size and deck: while len(current_hand) < max_size and deck:
@@ -148,7 +165,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
db.commit() db.commit()
total_obstacles = active_count + drawn_count 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).") 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!" return True, "Scene started successfully!"
@@ -161,6 +178,10 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
players = game.players players = game.players
allowed = ["pirat", "deep"] 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: if game.current_scene_number == 1:
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either. # Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either.
if len(players) >= 2: if len(players) >= 2:
@@ -175,7 +196,7 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
allowed.remove("deep") allowed.remove("deep")
# Rule 2: If only 1 eligible Deep, they MUST play Deep # Rule 2: If only 1 eligible Deep, they MUST play Deep
eligible_deeps = [p for p in players if p.previous_role != "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 len(eligible_deeps) == 1 and eligible_deeps[0].id == player_id:
if "pirat" in allowed: if "pirat" in allowed:
allowed.remove("pirat") allowed.remove("pirat")
@@ -193,83 +214,30 @@ def resolve_card_against_obstacle(
acting_rank: int acting_rank: int
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Resolves one (non-Joker) card from a player against an Obstacle: Resolves one (non-Joker) card from a player against an Obstacle via the shared
determines success (Secret Pirate Techniques, Gat/Name privileges, face-card rules in evaluate_card_play, appends the card to the Obstacle's column, and
Obstacle values), appends the card to the Obstacle's column, and updates updates the Obstacle's current value. Does not touch hands or draws.
the Obstacle's current value. Does not touch hands or draws.
""" """
played_parsed = cards.parse_card(card_code) # 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
success = False result = evaluate_card_play(player, card_code, obstacle_card, obstacle.current_value, acting_rank)
details = ""
is_technique = False
tech_name = ""
# Face Cards (Jack, Queen, King) played by a Pi-Rat trigger a Secret Pirate Technique: automatic success!
if played_parsed["value"] in ["J", "Q", "K"] and player.role != "deep":
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}'!"
else:
# Regular card play vs Obstacle value. The last card in the column (or the
# original Obstacle card) determines the value; face cards count as the
# Rank of the challenged Pi-Rat.
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 = acting_rank
obs_detail = f"Rank {acting_rank} (Face Card Obstacle)"
else:
target_value = obstacle.current_value
obs_detail = str(target_value)
played_val = played_parsed["numeric_value"]
# Gat privilege: Black cards +1. Name privilege: Red cards +1.
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
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}."
# Place the card in the Obstacle's column; it becomes the Obstacle's new value. # Place the card in the Obstacle's column; it becomes the Obstacle's new value.
played_list = json.loads(obstacle.played_cards)
played_list.append({ played_list.append({
"card": card_code, "card": card_code,
"player_id": player.id, "player_id": player.id,
"player_name": player.name, "player_name": player.name,
"success": success, "success": result["success"],
"details": details "details": result["details"]
}) })
obstacle.played_cards = json.dumps(played_list) obstacle.played_cards = json.dumps(played_list)
obstacle.current_value = played_parsed["numeric_value"] obstacle.current_value = cards.parse_card(card_code)["numeric_value"]
db.add(obstacle) db.add(obstacle)
db.commit() db.commit()
return { return result
"success": success,
"details": details,
"is_technique": is_technique,
"tech_name": tech_name
}
def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]: def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]:
""" """
@@ -318,7 +286,7 @@ def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) ->
new_parsed = cards.parse_card(new_card) new_parsed = cards.parse_card(new_card)
if new_parsed["is_joker"]: if new_parsed["is_joker"]:
game.extra_obstacles += 1 game.extra_obstacles += 1
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.") add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.", kind="joker")
continue continue
info = cards.get_obstacle_info(new_card) info = cards.get_obstacle_info(new_card)
@@ -340,39 +308,12 @@ def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) ->
msg = f"{player.name} played a Joker! '{obstacle_title}' is discarded" 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)." msg += f" and replaced by '{new_title}'." if new_title else " (no replacement left in the deck)."
add_game_event(db, game.id, msg) add_game_event(db, game.id, msg, kind="joker")
return True, msg return True, msg
def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool): 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.""" """Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly."""
if obj_type.startswith("crew_"):
game = get_game(db, game_id)
if not game: return
if obj_type == "crew_1":
game.completed_crew_1 = status
db.add(game)
db.commit()
if status and not game.captain_player_id:
# "Once the crew controls a ship, the highest-Ranked Pi-Rat becomes the de facto Captain."
pi_rats = [p for p in game.players if p.role != "deep" and not p.is_dead and not p.is_ghost]
if pi_rats:
max_rank = max(p.rank for p in pi_rats)
candidates = [p for p in pi_rats if p.rank == max_rank]
set_captain(db, game, random.choice(candidates).id, "Highest-ranked Pi-Rat aboard the stolen ship")
elif not status and game.captain_player_id:
# The ship is lost: the Captain's position goes with it.
set_captain(db, game, None, "The ship was lost")
elif obj_type == "crew_2":
game.completed_crew_2 = status
db.add(game)
db.commit()
elif obj_type == "crew_3":
game.completed_crew_3 = status
db.add(game)
db.commit()
return
player = get_player(db, target_id) player = get_player(db, target_id)
if not player: if not player:
return return
@@ -380,12 +321,34 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
if not game: if not game:
return 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 rank_diff = 0
if obj_type == "personal_1": if obj_type == "personal_1":
if status and not player.completed_personal_1: if status and not player.completed_personal_1:
rank_diff = 1 rank_diff = 1
player.needs_gat_description = True
elif not status and player.completed_personal_1: elif not status and player.completed_personal_1:
rank_diff = -1 rank_diff = -1
player.needs_gat_description = False
player.gat_description = ""
player.completed_personal_1 = status player.completed_personal_1 = status
elif obj_type == "personal_2": elif obj_type == "personal_2":
@@ -418,60 +381,31 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
obj_name = obj_type.replace('_', ' ').title() obj_name = obj_type.replace('_', ' ').title()
status_text = "completed" if status else "unmarked" status_text = "completed" if status else "unmarked"
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.") add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]: def grant_story_bonus_rank(db: Session, game: Game, granter: Player, target):
obstacle = db.get(Obstacle, obstacle_id) """A Pi-Rat who finished their story (3rd Personal Objective) grants another
if not obstacle: eligible Pi-Rat +1 Rank. `target=None` forfeits the bonus (e.g. no eligible
return False, "Obstacle not found." 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."""
played_list = json.loads(obstacle.played_cards) if not granter.needs_rank_3_bonus:
if not played_list: return False, "There is no story Rank bonus to grant."
return False, "No cards to roll back." 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."
last_play = played_list.pop() if target is not None:
card_code = last_play["card"] change_player_rank(db, game, target, 1)
player_id = last_play["player_id"] 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")
# Update obstacle's value
if played_list:
prev_card_code = played_list[-1]["card"]
obstacle.current_value = cards.parse_card(prev_card_code)["numeric_value"]
else: else:
obstacle.current_value = cards.get_obstacle_info(obstacle.original_card)["initial_value"] 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
obstacle.played_cards = json.dumps(played_list) db.add(granter)
db.add(obstacle)
# Remove the matching play from any open challenge that applied this obstacle
player = get_player(db, player_id)
if player:
game = get_game(db, player.game_id)
if game:
for challenge in game.challenges:
if challenge.status != "open":
continue
plays = json.loads(challenge.plays)
for i in range(len(plays) - 1, -1, -1):
p = plays[i]
if p.get("obstacle_id") == obstacle_id and p.get("card") == card_code and p.get("player_id") == player_id:
plays.pop(i)
challenge.plays = json.dumps(plays)
db.add(challenge)
break
# Return card to player's hand
hand = get_player_hand(player)
hand.append(card_code)
set_player_hand(player, hand)
db.add(player)
db.commit() db.commit()
return True, "Rolled back the last played card." return True, "Rank granted."
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str): def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
obstacle = db.get(Obstacle, obstacle_id) obstacle = db.get(Obstacle, obstacle_id)
if obstacle: if obstacle:
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.") add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.", kind="obstacle")
db.delete(obstacle) db.delete(obstacle)
db.commit() db.commit()
@@ -483,4 +417,5 @@ def finish_game(db: Session, game_id: str):
game.phase = "ended" game.phase = "ended"
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉") 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)

View File

@@ -1,4 +1,4 @@
import json import logging
import random import random
from typing import List, Tuple from typing import List, Tuple
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -6,24 +6,31 @@ from .models import Game, Player, Vote
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event, get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
calculate_max_hand_size, change_player_rank calculate_max_hand_size, change_player_rank, set_captain, is_eligible_nominee
) )
logger = logging.getLogger(__name__)
# --- Between Scenes Operations --- # --- 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]: 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 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. 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) voter = get_player(db, voter_id)
nominee = get_player(db, nominated_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: if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
return False, "Player not found." return False, "Player not found."
if voter_id == nominated_id: if not is_eligible_nominee(voter, nominee):
return False, "You cannot nominate yourself. Nice try." return False, "You can only nominate a living crewmate who isn't this scene's Deep — and not yourself."
if nominee.role == "deep":
return False, "Deep Players from the previous scene cannot be nominated."
# Remove any existing vote by this voter for this game # Remove any existing vote by this voter for this game
existing = db.exec( existing = db.exec(
@@ -42,11 +49,32 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
db.commit() db.commit()
return True, "Vote submitted." 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): def end_scene_and_transition(db: Session, game_id: str):
"""Moves game phase to between_scenes.""" """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) game = get_game(db, game_id)
if not game: if not game:
return 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" game.phase = "between_scenes"
# Clear ready flags for players # Clear ready flags for players
@@ -56,7 +84,8 @@ def end_scene_and_transition(db: Session, game_id: str):
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.") 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): def process_between_scenes_votes(db: Session, game: Game):
""" """
@@ -64,7 +93,11 @@ def process_between_scenes_votes(db: Session, game: Game):
Then sets players who were Deep to discard and redraw. Then sets players who were Deep to discard and redraw.
""" """
votes = game.votes 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: if not votes:
db.commit()
return return
# Count votes # Count votes
@@ -82,13 +115,43 @@ def process_between_scenes_votes(db: Session, game: Game):
winner = get_player(db, winner_id) winner = get_player(db, winner_id)
if winner: if winner:
change_player_rank(db, game, winner, 1) change_player_rank(db, game, winner, 1)
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.") 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 # Clear all votes
for v in votes: for v in votes:
db.delete(v) db.delete(v)
db.commit() 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): def transition_to_deep_upkeep(db: Session, game_id: str):
game = get_game(db, game_id) game = get_game(db, game_id)
if not game: if not game:
@@ -101,26 +164,19 @@ def transition_to_deep_upkeep(db: Session, game_id: str):
for p in game.players: for p in game.players:
p.is_ready = False p.is_ready = False
db.add(p) db.add(p)
db.commit()
# Check if we have resting Deep players (previous_role was 'deep') # Check if we have resting Deep players (previous_role was 'deep')
has_resting_deep = any(p.previous_role == "deep" for p in game.players) has_resting_deep = any(p.previous_role == "deep" for p in game.players)
if has_resting_deep: if has_resting_deep:
game.phase = "deep_upkeep" game.phase = "deep_upkeep"
add_game_event(db, game.id, "Phase changed: Deep Upkeep")
else:
# Fallback in case there were no Deep players
# Just advance directly to scene_setup
game.current_scene_number += 1
game.phase = "scene_setup"
add_game_event(db, game.id, "Phase changed: Scene Setup")
for p in game.players:
p.role = None
p.is_ready = False
db.add(p)
db.add(game) db.add(game)
db.commit() 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]): def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
player = get_player(db, player_id) player = get_player(db, player_id)
@@ -157,17 +213,94 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
db.add(player) db.add(player)
db.commit() db.commit()
# Check if all resting deep players are ready # 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"] resting_deep = [p for p in game.players if p.previous_role == "deep"]
if all(p.is_ready for p in resting_deep): if all(p.is_ready for p in resting_deep):
# All resting deep players have refreshed their hands! advance_after_upkeep(db, game)
# Transition to scene_setup!
game.current_scene_number += 1 # --- Kicking a player ---
game.phase = "scene_setup"
add_game_event(db, game.id, "Phase changed: Scene Setup") def recheck_phase_completion(db: Session, game: Game):
for p in game.players: """Re-evaluate the current phase's "everyone is done" gate and advance if it
p.is_ready = False is now satisfied. Called after the roster changes (a kick) so a vanished
p.role = None player can't leave the table stuck on a step they'll never complete."""
db.add(p) from . import crud_character as cc
db.add(game) 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() 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,17 +15,29 @@ 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
# Ensure crew_name column exists (migration for existing DBs) # create_all + ad-hoc ALTER TABLE era.
from sqlalchemy import text BASELINE_REVISION = "0001"
with Session(engine) as session:
try:
session.execute(text("ALTER TABLE game ADD COLUMN crew_name VARCHAR"))
except Exception:
pass # Column already exists or table doesn't exist yet
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 = [ columns = [
("game", "crew_name", "VARCHAR"),
("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"), ("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"),
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"), ("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"), ("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
@@ -31,16 +47,37 @@ def create_db_and_tables():
("player", "is_dead", "BOOLEAN DEFAULT FALSE"), ("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"), ("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
("player", "tax_banned", "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: for table, col, def_type in columns:
try: try:
session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}")) session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}"))
except Exception: except Exception:
pass 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() 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:
yield session yield session

View File

@@ -1,25 +1,235 @@
import asyncio
import contextlib
from contextlib import asynccontextmanager
from typing import Optional from typing import Optional
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status, APIRouter from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlmodel import Session, select from sqlmodel import Session
import logging
from logging.handlers import RotatingFileHandler
import re
import sys
import uvicorn import uvicorn
import os import os
from pathlib import Path from pathlib import Path
from . import crud from . import crud
from .database import create_db_and_tables, get_session from . import maintenance
from .models import GameEvent from .database import run_migrations, get_session, engine
from .validation import sanitize_name
from .ws import manager
logger = logging.getLogger(__name__)
EVENT_PAGE_SIZE = 50
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
app = FastAPI(title="Rats with Gats Remote Play API") _logging_configured = False
def configure_logging() -> None:
"""Set up backend logging: always to stderr, plus a rotating file when
PIRATS_LOG_FILE is set (the NixOS service points it at /var/log/pirats/).
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
level_name = os.environ.get("PIRATS_LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
root = logging.getLogger()
root.setLevel(level)
fmt = logging.Formatter(
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setFormatter(fmt)
root.addHandler(stderr_handler)
log_file = os.environ.get("PIRATS_LOG_FILE")
if log_file:
try:
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
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)
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() api = APIRouter()
# Register startup event to create tables # Every state mutation is a POST under /api/game/{game_id}/..., so one
@app.on_event("startup") # middleware can notify a game's websocket clients after any successful write.
def on_startup(): GAME_PATH_RE = re.compile(r"^/api/game/([^/]+)/")
create_db_and_tables() # 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 Core Route Handlers ---
@@ -28,7 +238,16 @@ def create_game_route(
crew_name: str = Form(...), crew_name: str = Form(...),
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
game = crud.create_game(db, crew_name=crew_name.strip()) 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} return {"id": game.id}
@api.post("/game/{game_id}/join") @api.post("/game/{game_id}/join")
@@ -43,9 +262,21 @@ 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 {"id": player.id}
@api.post("/game/{game_id}/player/{player_id}/leave")
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") @api.get("/game/{game_id}/player/{player_id}/state")
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)): 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)
@@ -53,16 +284,41 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi
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")
events = db.exec(select(GameEvent).where(GameEvent.game_id == game_id).order_by(GameEvent.timestamp.asc())).all() events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
return { return {
"game": game.model_dump(), # admin_key stays hidden except from Admins, who need it to open the admin panel
"game": game.model_dump(exclude=(set() if player.is_admin else {"admin_key"})),
"player": player.model_dump(), "player": player.model_dump(),
"players": [p.model_dump() for p in game.players], # A swap offer must reveal nothing but its existence: only the offerer
# gets to see which technique they put on the table.
"players": [p.model_dump(exclude=(set() if p.id == player.id else {"swap_offer_technique"})) for p in game.players],
"obstacles": [o.model_dump() for o in game.obstacles], "obstacles": [o.model_dump() for o in game.obstacles],
"challenges": [c.model_dump() for c in game.challenges], "challenges": [c.model_dump() for c in game.challenges],
"votes": [v.model_dump() for v in game.votes], "votes": [v.model_dump() for v in game.votes],
"events": [e.model_dump() for e in events], "events": [e.model_dump() for e in events],
"events_have_more": events_have_more,
# The newest checkpoint id, so the log can tell which event is "you are here".
"latest_checkpoint_id": crud.latest_checkpoint_id(db, game_id),
}
@api.get("/game/{game_id}/events")
def get_game_events(
game_id: str,
before: Optional[float] = None,
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)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
limit = max(1, min(limit, 200))
events, have_more = crud.get_events_page(db, game_id, before=before, limit=limit)
return {
"events": [e.model_dump() for e in events],
"have_more": have_more,
} }
# --- Register Modular Phase Routers --- # --- Register Modular Phase Routers ---
@@ -72,6 +328,7 @@ from .routes_scene import router as scene_router
from .routes_challenge import router as challenge_router from .routes_challenge import router as challenge_router
from .routes_upkeep import router as upkeep_router from .routes_upkeep import router as upkeep_router
from .routes_admin import router as admin_router from .routes_admin import router as admin_router
from .routes_rollback import router as rollback_router
api.include_router(lobby_router) api.include_router(lobby_router)
api.include_router(character_router) api.include_router(character_router)
@@ -79,16 +336,23 @@ api.include_router(scene_router)
api.include_router(challenge_router) api.include_router(challenge_router)
api.include_router(upkeep_router) api.include_router(upkeep_router)
api.include_router(admin_router) api.include_router(admin_router)
api.include_router(rollback_router)
# Mount API at /api # Mount API at /api
app.include_router(api, prefix="/api") app.include_router(api, prefix="/api")
# Static rulebook page (registered before the SPA mount so it takes precedence)
@app.get("/rules", include_in_schema=False)
def rules_page():
return FileResponse(BASE_DIR / "rules.html", media_type="text/html")
# Mount SPA # Mount SPA
static_dir = BASE_DIR / "static" static_dir = BASE_DIR / "static"
if os.path.exists(static_dir): if os.path.exists(static_dir):
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
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,37 +1,53 @@
import time 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()))
join_code: str = Field(default_factory=new_join_code, index=True, unique=True)
crew_name: Optional[str] = Field(default=None) crew_name: Optional[str] = Field(default=None)
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "ended" 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) # Permanent increase to required obstacles (from Jokers drawn as Obstacles) extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles)
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...] deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
rollback_timeline_version: int = Field(default=0) # Bumped whenever a rollback truncates events; lets the frontend reset its accumulated log. NEVER snapshotted (see crud_rollback.SNAPSHOT_EXCLUDE)
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_1: bool = Field(default=False) # Steal a Ship
completed_crew_2: bool = Field(default=False) # Choose a Captain completed_crew_2: bool = Field(default=False) # Choose a Captain
completed_crew_3: bool = Field(default=False) # Commit Piracy completed_crew_3: bool = Field(default=False) # Commit Piracy
captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship 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)
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True) votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True) events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True)
challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True) challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True)
checkpoints: List["Checkpoint"] = Relationship(back_populates="game", cascade_delete=True)
class Player(SQLModel, table=True): class Player(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
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="")
@@ -47,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="")
@@ -57,13 +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 # States for Milestones & Death
needs_name: bool = Field(default=False) 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) needs_rank_3_bonus: bool = Field(default=False)
is_dead: bool = Field(default=False) is_dead: bool = Field(default=False)
is_ghost: 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 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")
@@ -97,7 +122,9 @@ class Challenge(SQLModel, table=True):
challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges) 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 temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
stakes: str = Field(default="") 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"}] plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
status: str = Field(default="open") # "open", "succeeded", "failed" status: str = Field(default="open") # "open", "succeeded", "failed"
@@ -121,5 +148,22 @@ class GameEvent(SQLModel, table=True):
game_id: str = Field(foreign_key="game.id", index=True) game_id: str = Field(foreign_key="game.id", index=True)
timestamp: float = Field(default_factory=time.time) timestamp: float = Field(default_factory=time.time)
message: str = Field(...) message: str = Field(...)
kind: str = Field(default="info") # category for frontend icons/styling: "join", "phase", "scene", "captain", "challenge", "card", "tax", "objective", "vote", "obstacle", "joker", "victory", "warning", "rank", "info"
# Links an event to the Checkpoint captured by its action (see crud_rollback):
# None = in-flight (created since the last capture; next capture tags it)
# > 0 = a live rollback target within the current scene
# 0 = sealed/historical (a past scene or non-scene event; never rollback-able)
checkpoint_id: Optional[int] = Field(default=None, index=True)
game: Optional[Game] = Relationship(back_populates="events") game: Optional[Game] = Relationship(back_populates="events")
class Checkpoint(SQLModel, table=True):
"""A full snapshot of a game's gameplay state, captured after each action during
a scene so it can be rolled back to. The event log itself is NOT snapshotted (it
is an append-only spine); rollback truncates it separately. See crud_rollback."""
id: Optional[int] = Field(default=None, primary_key=True) # autoincrement; the rollback target the frontend sends
game_id: str = Field(foreign_key="game.id", index=True)
created_at: float = Field(default_factory=time.time)
state_json: str = Field(...) # serialized gameplay snapshot (Game minus control cols + Players/Obstacles/Challenges/Votes)
game: Optional[Game] = Relationship(back_populates="checkpoints")

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Request, Depends, HTTPException import json
from fastapi import APIRouter, Request, Form, Depends, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlmodel import Session from sqlmodel import Session
from .database import get_session from .database import get_session
@@ -6,6 +7,102 @@ from . import crud
router = APIRouter() 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 # Creator Admin Panel
@router.get("/game/{game_id}/admin") @router.get("/game/{game_id}/admin")
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)): def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
@@ -22,10 +119,13 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
players_data.append({ players_data.append({
"id": p.id, "id": p.id,
"name": p.name, "name": p.name,
"player_name": p.player_name,
"rank": p.rank, "rank": p.rank,
"is_dead": p.is_dead, "is_dead": p.is_dead,
"is_ghost": p.is_ghost, "is_ghost": p.is_ghost,
"role": p.role, "role": p.role,
"is_creator": p.is_creator,
"is_admin": p.is_admin,
}) })
return { return {
@@ -33,9 +133,60 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
"game": { "game": {
"id": game.id, "id": game.id,
"crew_name": game.crew_name, "crew_name": game.crew_name,
"join_code": game.join_code,
"phase": game.phase, "phase": game.phase,
"admin_key": game.admin_key "admin_key": game.admin_key
}, },
"players": players_data, "players": players_data,
"base_url": base_url "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

@@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse
from sqlmodel import Session from sqlmodel import Session
from .database import get_session from .database import get_session
from . import crud from . import crud
from .validation import sanitize_text
router = APIRouter() router = APIRouter()
@@ -14,7 +15,8 @@ def create_challenge_route(
player_id: str, player_id: str,
target_player_id: str = Form(...), target_player_id: str = Form(...),
obstacle_ids: str = Form(...), # JSON list of obstacle ids obstacle_ids: str = Form(...), # JSON list of obstacle ids
stakes: str = Form(""), stakes_success: str = Form(""),
stakes_failure: str = Form(""),
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
try: try:
@@ -22,7 +24,8 @@ def create_challenge_route(
assert isinstance(ids, list) assert isinstance(ids, list)
except Exception: except Exception:
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400) return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes) 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: if not ok:
return JSONResponse({"error": msg}, status_code=400) return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"} return {"status": "ok"}

View File

@@ -1,12 +1,18 @@
from typing import Optional from fastapi import APIRouter, Form, Depends, HTTPException
from fastapi import APIRouter, Form, Depends, HTTPException, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlmodel import Session from sqlmodel import Session
from .database import get_session from .database import get_session
from . import crud from . import crud
from .cards import TECHNIQUE_SUGGESTIONS
from .validation import sanitize_text, sanitize_name
router = APIRouter() router = APIRouter()
# Secret Pirate Technique name pool (for the frontend's Suggest buttons)
@router.get("/suggestions/techniques")
def technique_suggestions():
return {"techniques": TECHNIQUE_SUGGESTIONS}
# Save basic character details # Save basic character details
@router.post("/game/{game_id}/player/{player_id}/save-basic") @router.post("/game/{game_id}/player/{player_id}/save-basic")
def player_save_basic_details( def player_save_basic_details(
@@ -22,10 +28,13 @@ def player_save_basic_details(
if not player: if not player:
raise HTTPException(status_code=404, detail="Player not found") raise HTTPException(status_code=404, detail="Player not found")
player.avatar_look = avatar_look.strip() player.avatar_look = sanitize_text(avatar_look)
player.avatar_smell = avatar_smell.strip() player.avatar_smell = sanitize_name(avatar_smell) # seeds the "Recruit {smell}" name
player.first_words = first_words.strip() player.first_words = sanitize_text(first_words)
player.good_at_math = good_at_math.strip() player.good_at_math = sanitize_text(good_at_math)
# Until a Pi-Rat earns a Name they are identified by their smell
if not player.completed_personal_2:
player.name = crud.recruit_name(player)
db.add(player) db.add(player)
db.commit() db.commit()
@@ -80,28 +89,6 @@ def player_delegate_question(
crud.delegate_question(db, player_id, question_type, target_player_id) crud.delegate_question(db, player_id, question_type, target_player_id)
return {"status": "ok"} return {"status": "ok"}
# Revoke a delegated question task
@router.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}")
def player_revoke_delegation(
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()
return {"status": "ok"}
# Answer a delegated question for another player # Answer a delegated question for another player
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}") @router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
def player_submit_delegated_answer( def player_submit_delegated_answer(
@@ -112,7 +99,7 @@ def player_submit_delegated_answer(
answer: str = Form(...), answer: str = Form(...),
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id) crud.submit_delegated_answer(db, target_player_id, question_type, sanitize_text(answer), player_id)
return {"status": "ok"} return {"status": "ok"}
# Submit 3 Secret Pirate Techniques # Submit 3 Secret Pirate Techniques
@@ -125,11 +112,89 @@ def player_submit_techniques(
tech3: str = Form(...), tech3: str = Form(...),
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
error = crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip()) error = crud.submit_techniques(db, player_id, sanitize_text(tech1), sanitize_text(tech2), sanitize_text(tech3))
if error: if error:
return JSONResponse({"error": error}, status_code=400) return JSONResponse({"error": error}, status_code=400)
return {"status": "ok"} return {"status": "ok"}
# Unlock your submitted techniques for revision (before swapping begins)
@router.post("/game/{game_id}/player/{player_id}/unsubmit-techniques")
def player_unsubmit_techniques(
game_id: str,
player_id: str,
db: Session = Depends(get_session)
):
ok, msg = crud.unsubmit_techniques(db, player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Unlock your J/Q/K assignment for revision (while assignment is in progress)
@router.post("/game/{game_id}/player/{player_id}/unassign-face-techniques")
def player_unassign_face_techniques(
game_id: str,
player_id: str,
db: Session = Depends(get_session)
):
ok, msg = crud.unassign_face_techniques(db, player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Offer one of your held techniques to another player as a blind swap
@router.post("/game/{game_id}/player/{player_id}/offer-swap")
def player_offer_swap(
game_id: str,
player_id: str,
target_player_id: str = Form(...),
technique: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.offer_swap(db, player_id, target_player_id, technique)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Withdraw your pending swap offer
@router.post("/game/{game_id}/player/{player_id}/cancel-swap-offer")
def player_cancel_swap_offer(
game_id: str,
player_id: str,
db: Session = Depends(get_session)
):
ok, msg = crud.cancel_swap_offer(db, player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Accept (trading back one of your techniques) or decline an incoming swap offer
@router.post("/game/{game_id}/player/{player_id}/respond-swap-offer")
def player_respond_swap_offer(
game_id: str,
player_id: str,
offerer_player_id: str = Form(...),
accept: bool = Form(...),
technique: str = Form(""),
db: Session = Depends(get_session)
):
ok, msg = crud.respond_swap_offer(db, player_id, offerer_player_id, accept, technique)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Declare yourself done (or not done) swapping techniques
@router.post("/game/{game_id}/player/{player_id}/swap-ready")
def player_swap_ready(
game_id: str,
player_id: str,
ready: bool = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.set_swap_ready(db, player_id, ready)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Assign Swapped Techniques to Face Cards (Jack, Queen, King) # Assign Swapped Techniques to Face Cards (Jack, Queen, King)
@router.post("/game/{game_id}/player/{player_id}/assign-face-techniques") @router.post("/game/{game_id}/player/{player_id}/assign-face-techniques")
def player_assign_face_techniques( def player_assign_face_techniques(
@@ -143,27 +208,51 @@ def player_assign_face_techniques(
if len({jack, queen, king}) < 3: if len({jack, queen, king}) < 3:
return JSONResponse({"error": "Each card must be assigned a unique technique."}, status_code=400) return JSONResponse({"error": "Each card must be assigned a unique technique."}, status_code=400)
crud.assign_face_card_techniques(db, player_id, jack, queen, king) ok, msg = crud.assign_face_card_techniques(db, player_id, jack, queen, king)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"} return {"status": "ok"}
# Roll a new character after death # Dead Pi-Rat opts to come back as a fresh recruit (created between scenes)
@router.post("/game/{game_id}/player/{player_id}/roll-new-character") @router.post("/game/{game_id}/player/{player_id}/choose-reroll")
def roll_new_character_route( def choose_reroll_route(
game_id: str, game_id: str,
player_id: str, player_id: str,
avatar_look: str = Form(...),
avatar_smell: str = Form(...),
first_words: str = Form(...),
good_at_math: str = Form(...),
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
crud.roll_new_character( ok, msg = crud.choose_reroll(db, player_id)
db, if not ok:
game_id, return JSONResponse({"error": msg}, status_code=400)
player_id, return {"status": "ok"}
avatar_look=avatar_look,
avatar_smell=avatar_smell, # Write (or revise) one of your assigned technique slots for a pending recruit
first_words=first_words, @router.post("/game/{game_id}/player/{player_id}/contribute-technique")
good_at_math=good_at_math def contribute_technique_route(
) game_id: str,
player_id: str,
recruit_id: str = Form(...),
slot: int = Form(...),
text: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.contribute_recruit_technique(db, player_id, recruit_id, slot, sanitize_text(text))
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Recruit assigns their crew-written techniques to J/Q/K and joins the crew
@router.post("/game/{game_id}/player/{player_id}/finalize-recruit")
def finalize_recruit_route(
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:
return JSONResponse({"error": "Each card must be assigned a unique technique."}, status_code=400)
ok, msg = crud.finalize_recruit(db, player_id, jack, queen, king)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"} return {"status": "ok"}

View File

@@ -1,5 +1,5 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlmodel import Session from sqlmodel import Session
from .database import get_session from .database import get_session
from . import crud from . import crud
@@ -11,6 +11,8 @@ def lobby_start_character_creation(game_id: str, db: Session = Depends(get_sessi
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")
if not crud.has_min_players(game):
return JSONResponse({"error": f"You need at least {crud.MIN_PLAYERS} players to start a game."}, status_code=400)
game.phase = "character_creation" game.phase = "character_creation"
db.add(game) db.add(game)
db.commit() db.commit()

View File

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

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