Compare commits

...

22 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
46 changed files with 1708 additions and 390 deletions

View File

@@ -20,6 +20,8 @@ Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list
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`).

1
CLAUDE.md Normal file
View File

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

View File

@@ -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).
#### 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
@@ -99,10 +113,22 @@ services.pirats = {
host = "127.0.0.1";
port = 8000;
databasePath = "/var/lib/pirats/rats_with_gats.db";
logFile = "/var/log/pirats/pirats.log"; # rotating; stderr also goes to the journal
logLevel = "info";
openFirewall = false; # Set to true to open ports in the firewall
purge = {
enable = true; # periodically remove old finished/inactive games
intervalHours = 24;
finishedDays = 14; # purge games finished more than 14 days ago
inactiveDays = 30; # ...or with no activity for 30 days
};
};
```
The service writes its log to `logFile` (under the systemd-managed `/var/log/pirats`
`LogsDirectory`, so the sandboxed `DynamicUser` can write to it) and additionally
to the systemd journal via stderr (`journalctl -u pirats`).
---
## Running Tests
@@ -147,8 +173,16 @@ pirats/
### Frontend Development
Run the backend (`pirats --reload`) and the Vite dev server side by side; Vite proxies `/api` to port 8000:
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.

18
TODO.md
View File

@@ -1,15 +1,15 @@
## Polish
- [x] On the home page, rejoin a crew should be above muster a crew, and there should be a gap between the two boxes
- [x] **Version number and change log** Start keeping track of version number. It's fine to just start with v1 and increment on every commit. In the hamburger menu, add an "About" item that pops up a modal with the current version number and a change log. The change log should only cover user-facing changes. Add a note to AGENTS.md about maintaining the change log.
- [x] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
- [x] **Condense Obstacle card display**: Change how the cards in an Obstacle are laid out. Instead of being one big card and a bunch of little ones under it in a row, they should be a single column, with the cards overlapping so that you can just see the numbers and suits of the previous cards peeking out behind the latest card. This is more compact, puts the focus on the most relevant information, and is in line with the rulebook describing the card layout as a column. This is probably also how the cards in the example play section should be laid out.
- [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
- [ ] **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.
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
## Security
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates.
- [ ] **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.

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

@@ -112,6 +112,27 @@
default = "/var/lib/pirats/rats_with_gats.db";
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 {
type = lib.types.bool;
default = false;
@@ -122,6 +143,28 @@
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 {
@@ -137,15 +180,26 @@
# 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 = {
ExecStart = "${cfg.package}/bin/pirats --host ${cfg.host} --port ${toString cfg.port}";
Restart = "always";
# Sandboxing and security
DynamicUser = true;
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";
ProtectSystem = "strict";

View File

@@ -23,6 +23,83 @@
font-size: 0.875rem;
}
.admin-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
}
.admin-summary-details {
min-width: 0;
}
.admin-join-code {
flex: 0 0 auto;
min-width: 10.5rem;
padding: 0.85rem 1.1rem 0.95rem;
background: color-mix(in srgb, var(--accent) 12%, var(--well));
color: var(--text);
border: 2px solid var(--accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
font: inherit;
text-align: center;
cursor: pointer;
transition: var(--transition-smooth);
}
.admin-join-code:hover {
background: color-mix(in srgb, var(--accent) 20%, var(--well));
transform: translateY(-1px);
}
.admin-join-code:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent);
outline-offset: 2px;
}
.admin-join-code.copied {
border-color: var(--success);
}
.admin-join-code-label {
display: block;
margin-bottom: 0.2rem;
color: var(--text-muted);
font-family: var(--font-heading);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.admin-join-code strong {
display: block;
color: var(--accent);
font-family: ui-monospace, monospace;
font-size: 2rem;
line-height: 1;
letter-spacing: 0.16em;
}
.admin-join-code-hint {
display: block;
margin-top: 0.35rem;
color: var(--text-muted);
font-size: 0.75rem;
}
@media (max-width: 600px) {
.admin-summary {
flex-direction: column;
}
.admin-join-code {
width: 100%;
}
}
.admin-table {
width: 100%;
text-align: left;
@@ -51,6 +128,16 @@
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;
@@ -75,20 +162,3 @@
text-align: left;
margin-top: 1.5rem;
}
.crew-epitaph-row {
background: var(--well);
padding: 0.75rem;
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.crew-epitaph-row .objectives-summary {
font-size: 0.85rem;
color: var(--text-muted);
}

View File

@@ -70,6 +70,29 @@ input[type="checkbox"] {
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 File

@@ -104,25 +104,27 @@
flex-shrink: 0;
}
/* Crew creation progress rows */
.status-row {
background: var(--well);
border-left: 4px solid var(--edge);
border-radius: var(--radius-sm);
padding: 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
list-style: none;
}
.status-row.is-ready {
border-left-color: var(--success);
}
/* --- Character sheet layout --- */
.character-sheet-details {
text-align: left;
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.character-sheet-details {
flex-direction: row;
align-items: flex-start;
gap: 1.5rem;
}
.sheet-main-column {
flex: 2;
min-width: 0;
}
.sheet-side-column {
flex: 1;
min-width: 0;
}
}
.techniques-list-sheet {

View File

@@ -167,6 +167,10 @@
font-size: 0.95rem;
}
.checkbox-label:has(input:disabled) {
cursor: default;
}
.objectives-checklist {
display: flex;
flex-direction: column;
@@ -289,49 +293,6 @@
color: color-mix(in srgb, var(--danger) 75%, var(--text));
}
/* --- Player chips --- */
.player-chips {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
}
.player-chip {
background: color-mix(in srgb, var(--text) 6%, transparent);
border: 1px solid var(--edge);
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: color-mix(in srgb, var(--accent) 14%, transparent);
border-color: var(--accent);
}
.player-chip.voted-chip {
border-color: var(--success);
background-color: color-mix(in srgb, var(--success) 6%, transparent);
}
.player-chip.not-voted-chip {
border-color: var(--edge);
}
.list-chips {
justify-content: flex-start;
}
.list-chips .player-chip {
padding: 0.4rem 1rem;
font-size: 0.9rem;
}
.deep-chip {
border-color: var(--deep);
background: color-mix(in srgb, var(--deep) 5%, transparent);
@@ -342,24 +303,6 @@
background: color-mix(in srgb, var(--pirat) 5%, transparent);
}
.player-chip.is-ghost {
border-color: var(--ghost);
color: var(--ghost);
background: color-mix(in srgb, var(--ghost) 10%, transparent);
}
.player-chip.is-dead {
border-color: var(--danger);
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.player-chip.is-captain {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
/* --- Badges --- */
.creator-badge {
background: var(--accent);
@@ -443,6 +386,10 @@
text-align: left;
}
.modal-box.character-sheet-modal {
max-width: 1260px;
}
.modal-close {
position: absolute;
top: 0.6rem;
@@ -540,4 +487,3 @@
color: var(--accent);
font-weight: 700;
}

View File

@@ -4,10 +4,6 @@
margin: 2rem auto;
}
.lobby-status-box {
margin: 2rem 0;
}
.links-box {
text-align: left;
margin-bottom: 2rem;
@@ -38,6 +34,11 @@
margin-bottom: 0.5rem;
}
.lobby-join-code-item {
display: flex;
justify-content: center;
}
.copy-container {
display: flex;
gap: 0.5rem;

View File

@@ -17,6 +17,34 @@
align-self: stretch;
}
/* When the inline log is collapsed, reclaim its column (3-col layout only). */
@media (min-width: 1101px) {
.scene-view-layout.log-collapsed {
grid-template-columns: 180px minmax(0, 1fr);
}
}
/* Corner button that toggles the inline Event Log without moving. */
.log-reopen-btn {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
padding: 10px 14px;
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
color: var(--text);
font-weight: bold;
font-family: var(--font-heading);
cursor: pointer;
backdrop-filter: blur(10px);
}
.log-reopen-btn:hover {
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
}
@media (max-width: 1100px) {
.scene-view-layout {
grid-template-columns: 1fr;
@@ -37,6 +65,7 @@
}
.crew-bubble {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
@@ -44,22 +73,33 @@
width: 100%;
text-align: left;
padding: 0.55rem 0.7rem;
background: var(--well);
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(--text) 8%, transparent);
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) 6%, transparent);
background: color-mix(in srgb, var(--deep) 12%, var(--surface-raised));
}
.crew-bubble.is-you {
@@ -70,6 +110,33 @@
.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;
}
@@ -125,23 +192,13 @@
font-size: 0.85rem;
}
/* On narrow screens the crew column becomes a horizontal strip above the board */
/* Keep the crew roster vertical at every width, matching the other phases. */
@media (max-width: 1100px) {
.crew-bubbles {
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
}
.crew-bubble {
width: auto;
flex: 1 1 140px;
width: 100%;
}
.crew-objectives-toggle {
width: auto;
flex: 1 1 100%;
}
.crew-objectives-detail {
flex: 1 1 100%;
width: 100%;
}
}
@@ -407,6 +464,16 @@
}
/* --- Hand --- */
.hand-label {
display: block;
margin-bottom: 0.6rem;
font-family: var(--font-heading);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.hand-flex {
display: flex;
flex-wrap: wrap;

View File

@@ -6,6 +6,11 @@
margin: 2rem 0;
}
.setup-grid.single {
grid-template-columns: minmax(0, 700px);
justify-content: center;
}
@media (max-width: 800px) {
.setup-grid {
grid-template-columns: 1fr;
@@ -63,19 +68,3 @@
border: 1px dashed var(--edge);
color: var(--text-muted);
}
.roster-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.roster-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
background: var(--well);
border-radius: var(--radius-sm);
padding: 0.6rem 0.9rem;
}

View File

@@ -48,7 +48,7 @@
</div>
{#if techName}
<div class="card-tech-overlay">
<div class="tech-tag" use:tooltip={"Automatic success when played"}>{cardValue(card)}: "{techName}"</div>
<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">

View File

@@ -566,39 +566,5 @@
{/if}
</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="status-row {p.tech_jack ? 'is-ready' : ''}">
<span>{displayName(p)}</span>
<span class="text-sm">
{#if p.needs_reroll}
<span class="text-muted">Spectating</span>
{:else if phase === 'swap_techniques'}
{#if p.is_ready}
<span class="text-success">Done Swapping</span>
{:else}
{@const ownLeft = JSON.parse(p.held_techniques || '[]').filter(t => t.creator_id === p.id).length}
<span class="text-warning">Swapping ({ownLeft} of their own left)</span>
{/if}
{:else if p.tech_jack}
<span class="text-success">Ready</span>
{:else if phase === 'assign_techniques'}
<span class="text-warning">Assigning Face Cards</span>
{:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3}
<span class="text-warning">Techniques Submitted</span>
{:else}
<span class="text-muted">Writing Sheet</span>
{/if}
</span>
</li>
{/each}
</ul>
</div>
</div>
</div>
</div>

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

@@ -1,7 +1,9 @@
<script>
import { tick, onMount } from 'svelte';
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.
@@ -194,7 +196,10 @@
{/key}
{/if}
{#if inline}
<div class="log-header">📜 Event Log</div>
<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'}
@@ -266,14 +271,18 @@
.floating-event-log:not(.open) {
width: auto;
}
/* Inline mode: pinned as the scene's third column instead of floating. */
/* 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: 1rem;
top: 3.5rem;
right: auto;
bottom: auto;
width: 100%;
max-height: calc(100vh - 2rem);
max-height: calc(100vh - 4.5rem);
}
@media (max-width: 1100px) {
.floating-event-log.inline {
@@ -282,6 +291,7 @@
}
}
.log-header {
position: relative;
padding: 10px;
text-align: center;
font-weight: bold;
@@ -289,6 +299,24 @@
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);

View File

@@ -1,9 +1,6 @@
<script>
import { displayName } from '../lib/cards';
export let state;
$: pirats = state.players;
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
</script>
@@ -29,24 +26,6 @@
{/if}
</div>
<div class="sheet-group">
<h3 class="text-accent">The Crew</h3>
{#each pirats as p}
<div class="crew-epitaph-row">
<span>
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if}
<strong>{displayName(p)}</strong> (Rank {p.rank})
{#if p.id === state.game.captain_player_id}<span class="text-accent"> ⭐ Captain</span>{/if}
</span>
<span class="objectives-summary">
{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>
</div>
</div>

View File

@@ -43,21 +43,23 @@
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
<div class="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>
{:else if p.is_admin}<span class="creator-badge">Admin</span>{/if}
</div>
{/each}
</div>
</div>
<div class="links-box glass-panel">
{#if state.player.is_admin}
<div class="link-item lobby-join-code-item">
<button
type="button"
class="admin-join-code"
class:copied={copiedLink === 'code'}
aria-label="Copy join code {state.game.join_code}"
on:click={() => copyLink('code', state.game.join_code)}
>
<span class="admin-join-code-label">Join Code</span>
<strong>{state.game.join_code}</strong>
<span class="admin-join-code-hint">{copiedLink === 'code' ? '✓ Copied!' : 'Click to copy'}</span>
</button>
</div>
{/if}
<div class="link-item">
<h4>Share Join Link:</h4>
<div class="copy-container">

View File

@@ -135,16 +135,6 @@
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);
function recruitProgress(p) {
const techs = incomingTechs(p);
const parts = [];
parts.push(p.avatar_look && p.avatar_smell && p.first_words && p.good_at_math ? '✅ Records' : '❌ Records');
const likeOk = !p.other_like_from_player_id || p.other_like;
const hateOk = !p.other_hate_from_player_id || p.other_hate;
parts.push(likeOk && hateOk ? '✅ Like/Hate' : '❌ Like/Hate');
parts.push(`${techs.filter(s => s.text).length}/3 Techniques`);
return parts.join(' · ');
}
</script>
<div class="recruit-phase-view">
@@ -321,22 +311,5 @@
</div>
</div>
<!-- RECRUIT PROGRESS -->
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
<h3>{iAmRecruit ? 'V.' : 'II.'} Recruit Progress</h3>
<div class="crew-status-list">
<ul class="space-y-2 mt-4">
{#each recruits as p}
<li class="status-row">
<span>{displayName(p)}</span>
<span class="text-sm text-muted">{recruitProgress(p)}</span>
</li>
{/each}
{#if recruits.length === 0}
<li class="status-row"><span class="text-success">All recruits are aboard! Heading to the next scene...</span></li>
{/if}
</ul>
</div>
</div>
</div>
</div>

View File

@@ -7,6 +7,9 @@
export let state;
// The inline Event Log can be toggled from a fixed corner button to declutter.
let logOpen = true;
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
$: isDeep = state.player.role === 'deep';
@@ -16,7 +19,7 @@
$: showChallengeArea = isDeep || openChallenges.length > 0;
</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 Crew -->
<CrewColumn {state} />
@@ -24,8 +27,7 @@
<div class="table-column">
{#if !isDeep}
<div class="card glass-panel hand-card">
<h3>🃏 Your Hand</h3>
<p class="section-desc">Keep your cards secret! 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.</p>
<span class="hand-label">🃏 Your Hand</span>
<div class="hand-flex">
{#each hand as card}
@@ -60,8 +62,18 @@
</div>
</div>
<!-- RIGHT COLUMN: The Event Log -->
<div class="log-column">
<EventLog {state} inline />
</div>
<!-- RIGHT COLUMN: The Event Log (collapsible to a corner button) -->
{#if logOpen}
<div class="log-column">
<EventLog {state} inline on:collapse={() => (logOpen = false)} />
</div>
{/if}
</div>
<button
class="log-reopen-btn"
title={logOpen ? 'Hide the event log' : 'Show the event log'}
on:click={() => (logOpen = !logOpen)}
>
{logOpen ? '✕ Close Log' : '📜 Event Log'}
</button>

View File

@@ -1,7 +1,6 @@
<script>
import { onMount } from 'svelte';
import { apiRequest } from '../lib/api';
import { displayName } from '../lib/cards';
export let state;
@@ -64,7 +63,7 @@
</div>
{/if}
<div class="setup-grid">
<div class="setup-grid single">
<!-- Role Selection Card -->
<div class="card glass-panel role-selection-card">
<h3>Select Your Role</h3>
@@ -107,24 +106,6 @@
</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">{displayName(p)} <span class="text-sm text-muted">(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 class="action-box margin-top">

View File

@@ -234,34 +234,6 @@
</form>
{/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)}
{@const skips = eligibleNomineesFor(p).length === 0}
<div class="player-chip {voted || skips ? 'voted-chip' : 'not-voted-chip'}">
<span class="name">{displayName(p)}</span>
<span class="status-badge">{voted ? 'Done' : skips ? 'No vote' : 'Voting...'}</span>
</div>
{/each}
</div>
</div>
{/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>{displayName(p)}:</strong> Rank {p.rank}
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
</li>
{/each}
</ul>
</div>
</div>
<!-- Resting Deep Player Card — only the actual hand refresh, during deep_upkeep -->

View File

@@ -123,7 +123,7 @@
{/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) }}>{getCardDisplay(ch.temp_card)}</strong>{playerName(ch.acting_player_id)} must answer it!
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">
@@ -173,7 +173,7 @@
<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) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
<button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable, true) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
{/each}
</div>
</div>

View File

@@ -1,7 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte';
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName, crewLabel, captainName, cardObstacleInfo, obstacleTable } from '../../lib/cards';
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
@@ -26,7 +26,7 @@
&& 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 = cardObstacleInfo(pvpCard, $obstacleTable);
$: pvpCardObstacle = pvpObstacleInfo(pvpCard);
function getPlayerName(id) {
if (!id) return 'a crewmate';
@@ -72,7 +72,7 @@
<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">
<div class="modal-box sheet-modal character-sheet-modal glass-panel">
<button class="modal-close" on:click={close} aria-label="Close">×</button>
<h3>
@@ -86,6 +86,7 @@
{/if}
<div class="character-sheet-details">
<div class="sheet-main-column">
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
@@ -159,7 +160,7 @@
<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={cardObstacleInfo(card, $obstacleTable) ? `${cardObstacleInfo(card, $obstacleTable).title} ${cardObstacleInfo(card, $obstacleTable).description}` : ''}>{getCardDisplay(card)}</option>
<option value={card} title={pvpObstacleInfo(card) ? `${pvpObstacleInfo(card).title} ${pvpObstacleInfo(card).description}` : ''}>{getCardDisplay(card)}</option>
{/each}
</select>
{#if pvpCardObstacle}
@@ -183,27 +184,30 @@
{/if}
</div>
{/if}
</div> <!-- end sheet-main-column -->
<div class="sheet-group margin-top">
<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 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>
</div> <!-- end sheet-side-column -->
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
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;
@@ -12,6 +13,17 @@
$: 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 = '';
@@ -51,6 +63,15 @@
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">

View File

@@ -17,7 +17,9 @@ export async function apiRequest(endpoint, method = 'GET', data = null) {
if (errBody.error) msg = errBody.error;
else if (errBody.detail) msg = errBody.detail;
} catch(e) {}
throw new Error(msg);
const error = new Error(msg);
error.status = res.status;
throw error;
}
return res.json();
}

View File

@@ -69,7 +69,7 @@ function esc(s) {
// 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) {
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>'
@@ -83,7 +83,14 @@ export function cardTooltipHtml(code, table = OBSTACLE_TABLE) {
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 (FACE_VALUES.includes(val)) {
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 {
@@ -95,6 +102,19 @@ export function cardTooltipHtml(code, table = OBSTACLE_TABLE) {
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');
}

View File

@@ -6,10 +6,128 @@
// - 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 = 4;
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',

View File

@@ -23,6 +23,8 @@
let copiedTimer = null;
let copiedInvite = false;
let copiedInviteTimer = null;
let copiedJoinCode = false;
let copiedJoinCodeTimer = null;
$: setPageTitle('Admin', data?.game?.crew_name);
@@ -48,6 +50,13 @@
copiedInviteTimer = setTimeout(() => { copiedInvite = false; }, 2000);
}
function copyJoinCode() {
navigator.clipboard.writeText(data.game.join_code);
copiedJoinCode = true;
clearTimeout(copiedJoinCodeTimer);
copiedJoinCodeTimer = setTimeout(() => { copiedJoinCode = false; }, 2000);
}
onMount(() => {
// Try to load admin_key from local storage
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
@@ -117,9 +126,24 @@
</div>
{:else}
<div class="card">
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
<p><strong>Phase:</strong> {data.game.phase}</p>
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
<div class="admin-summary">
<div class="admin-summary-details">
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
<p><strong>Phase:</strong> {data.game.phase}</p>
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
</div>
<button
type="button"
class="admin-join-code"
class:copied={copiedJoinCode}
aria-label="Copy join code {data.game.join_code}"
on:click={copyJoinCode}
>
<span class="admin-join-code-label">Join Code</span>
<strong>{data.game.join_code}</strong>
<span class="admin-join-code-hint">{copiedJoinCode ? '✓ Copied!' : 'Click to copy'}</span>
</button>
</div>
{#if error}
<div class="alert alert-danger mt-4">{error}</div>
@@ -152,7 +176,13 @@
<tbody>
{#each data.players as p}
<tr>
<td>{p.name}</td>
<td>
{#if p.name && p.name !== p.player_name}
{p.name}
{:else}
<span class="text-muted">Not chosen yet</span>
{/if}
</td>
<td>{p.player_name || p.name}</td>
<td>
{#if p.role === 'deep'} 🌊 Deep

View File

@@ -15,6 +15,7 @@
import UpkeepPhase from '../components/UpkeepPhase.svelte';
import RecruitPhase from '../components/RecruitPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.svelte';
import CrewSidebar from '../components/CrewSidebar.svelte';
import EventLog from '../components/EventLog.svelte';
import NameModal from '../components/NameModal.svelte';
import GatModal from '../components/GatModal.svelte';
@@ -31,14 +32,38 @@
let reconnectTimer = null;
let reconnectDelay = 1000;
let destroyed = false;
let staleSession = false;
// Non-scene phases use the same pinned Event Log column and fixed corner
// toggle that ScenePhase owns for the main play screen.
let phaseLogOpen = true;
function discardStaleSession(message) {
staleSession = true;
removeSession(gameId, playerId);
state = null;
error = message;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) ws.close();
}
async function fetchState() {
if (staleSession) return;
try {
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
// Preserve the Game Over screen for players who were already present
// when play ended, but discard stale home-screen rejoin attempts.
if (!state && data.game.phase === 'ended') {
discardStaleSession('This game has ended and can no longer be rejoined.');
return;
}
state = data;
error = '';
} catch (err) {
error = err.message;
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;
}
}
}
@@ -53,7 +78,7 @@
};
ws.onmessage = () => fetchState();
ws.onclose = () => {
if (destroyed) return;
if (destroyed || staleSession) return;
reconnectTimer = setTimeout(connectSocket, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
};
@@ -188,33 +213,55 @@
{#if error}
<div class="alert alert-danger" style="margin: 20px;">
Error connecting to game: {error}
{#if staleSession}
<div class="mt-4">
<a href="#/" class="btn btn-secondary btn-small">Back to Home</a>
</div>
{/if}
</div>
{/if}
{#if state}
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
{#if state.game.phase === 'lobby'}
<LobbyPhase {state} />
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
<CharacterCreationPhase {state} />
{:else if state.game.phase === 'scene_setup'}
<SceneSetupPhase {state} />
{:else if state.game.phase === 'scene'}
{#if state.game.phase === 'scene'}
<ScenePhase {state} />
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
<UpkeepPhase {state} />
{:else if state.game.phase === 'recruit_creation'}
<RecruitPhase {state} />
{:else if state.game.phase === 'ended'}
<GameOverPhase {state} />
{:else}
<div class="card p-4">Unknown phase: {state.game.phase}</div>
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
<CrewSidebar {state} />
<main class="phase-main-column">
{#if state.game.phase === 'lobby'}
<LobbyPhase {state} />
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
<CharacterCreationPhase {state} />
{:else if state.game.phase === 'scene_setup'}
<SceneSetupPhase {state} />
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
<UpkeepPhase {state} />
{:else if state.game.phase === 'recruit_creation'}
<RecruitPhase {state} />
{:else if state.game.phase === 'ended'}
<GameOverPhase {state} />
{:else}
<div class="card p-4">Unknown phase: {state.game.phase}</div>
{/if}
</main>
{#if phaseLogOpen}
<div class="log-column">
<EventLog {state} inline on:collapse={() => (phaseLogOpen = false)} />
</div>
{/if}
</div>
{/if}
</div>
<!-- The scene phase renders its own inline Event Log as the third column. -->
{#if state.game.phase !== 'scene'}
<EventLog {state} />
<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} />

View File

@@ -15,6 +15,9 @@
let crewName = '';
let error = '';
let creating = false;
let joinCode = '';
let joinCodeError = '';
let joiningByCode = false;
function sessionRat(s) {
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
@@ -53,6 +56,26 @@
creating = false;
}
}
function updateJoinCode(event) {
joinCode = event.currentTarget.value
.toUpperCase()
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, '')
.slice(0, 5);
}
async function joinByCode() {
if (joinCode.length !== 5) return;
joiningByCode = true;
joinCodeError = '';
try {
const data = await apiRequest(`/game/code/${joinCode}`);
push(`/game/${data.id}/join`);
} catch (err) {
joinCodeError = err.message;
joiningByCode = false;
}
}
</script>
<div class="welcome-container">
@@ -91,6 +114,36 @@
</div>
{/if}
<div class="glass-panel creation-card join-code-card">
<h2>Join a Crew</h2>
<p class="info-text">Enter the five-character code shared by your games Admin.</p>
<form class="join-code-form" on:submit|preventDefault={joinByCode}>
<label for="joinCode">Join Code</label>
<div class="join-code-action">
<input
type="text"
id="joinCode"
value={joinCode}
on:input={updateJoinCode}
required
minlength="5"
maxlength="5"
placeholder="ABCDE"
class="input-field input-large join-code-input"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
/>
<button type="submit" class="btn btn-primary btn-large" disabled={joiningByCode || joinCode.length !== 5}>
{joiningByCode ? 'Joining...' : 'Join'}
</button>
</div>
</form>
{#if joinCodeError}
<div class="alert alert-danger mt-4">{joinCodeError}</div>
{/if}
</div>
<div class="glass-panel creation-card">
<h2>Muster a New Crew</h2>
<form on:submit|preventDefault={createGame}>
@@ -128,6 +181,25 @@
.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;

View File

@@ -6,12 +6,18 @@
import { saveSession } from '../lib/sessions';
export let params = {};
onMount(() => setPageTitle('Join the Crew'));
const PLAYER_NAME_KEY = 'pirats-player-name';
let gameId = params.id;
let playerName = '';
let error = '';
let joining = false;
onMount(() => {
setPageTitle('Join the Crew');
playerName = localStorage.getItem(PLAYER_NAME_KEY) || '';
});
// We can extract ?creator=true from $querystring if needed,
// though the backend determines creator based on if they are the first player.
// For UI purposes, we could show "You are the creator" if we want.
@@ -22,6 +28,7 @@
error = '';
try {
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
localStorage.setItem(PLAYER_NAME_KEY, playerName.trim());
// Remember this session so the home page can offer a rejoin link;
// the dashboard enriches it with crew/rat names once state loads.
saveSession({ gameId, playerId: data.id, playerName });

View File

@@ -1,11 +1,14 @@
import json
import logging
import os
import random
from typing import Any, Dict, List, Optional
from sqlmodel import Session, select
from .models import Game, Player, GameEvent
from .models import Game, Player, GameEvent, new_join_code
from . import cards
logger = logging.getLogger(__name__)
FACE_VALUES = ("J", "Q", "K")
# --- Card and Hand Utilities ---
@@ -250,7 +253,16 @@ def has_min_players(game: Game) -> bool:
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
deck = cards.get_fresh_deck()
join_code = ""
for _ in range(100):
candidate = new_join_code()
if not db.exec(select(Game).where(Game.join_code == candidate)).first():
join_code = candidate
break
if not join_code:
raise RuntimeError("Could not allocate a unique join code")
game = Game(
join_code=join_code,
deck_cards=json.dumps(deck),
phase="lobby",
current_scene_number=1,
@@ -261,11 +273,15 @@ def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
db.add(game)
db.commit()
db.refresh(game)
logger.info("Game %s created (crew_name=%r, dev_mode=%s)", game.id, crew_name, game.dev_mode)
return game
def get_game(db: Session, game_id: str) -> Optional[Game]:
return db.get(Game, game_id)
def get_game_by_join_code(db: Session, join_code: str) -> Optional[Game]:
return db.exec(select(Game).where(Game.join_code == join_code)).first()
def get_player(db: Session, player_id: str) -> Optional[Player]:
return db.get(Player, player_id)
@@ -308,6 +324,10 @@ def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -
msg += " They'll create their Pi-Rat with the crew between scenes."
add_game_event(db, game_id, msg, kind="join")
logger.info(
"Player %s (%r) joined game %s (phase=%s, creator=%s)",
player.id, name, game_id, game.phase if game else "?", is_creator,
)
return player
def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):

View File

@@ -1,4 +1,5 @@
import json
import logging
from typing import Tuple, Dict, Any
from sqlmodel import Session
from .models import Game, Player, Obstacle
@@ -10,9 +11,17 @@ from .crud_base import (
is_eligible_nominee
)
logger = logging.getLogger(__name__)
# --- Scene Setup Operations ---
VALID_ROLES = ("pirat", "deep")
def update_scene_role(db: Session, player_id: str, role: str):
# Constrain to the two real roles so the endpoint can't stash an arbitrary
# string in the column (defensive: it only ever drives gameplay branches).
if role not in VALID_ROLES:
return
player = get_player(db, player_id)
if not player:
return
@@ -305,26 +314,6 @@ def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) ->
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."""
if obj_type.startswith("crew_"):
game = get_game(db, game_id)
if not game:
return
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
db.add(game)
db.commit()
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)
if not player:
return
@@ -332,6 +321,25 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
if not game:
return
if obj_type.startswith("crew_"):
if obj_type == "crew_1":
# Stealing a ship no longer auto-anoints a Captain — the crew has to
# earn and choose one themselves (Crew Objective 2 + the Captaincy UI).
game.completed_crew_1 = status
elif obj_type == "crew_2":
game.completed_crew_2 = status
elif obj_type == "crew_3":
game.completed_crew_3 = status
db.add(game)
db.commit()
obj_name = obj_type.replace('_', ' ').title()
status_text = "completed" if status else "unmarked"
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
return
rank_diff = 0
if obj_type == "personal_1":
if status and not player.completed_personal_1:
@@ -410,3 +418,4 @@ def finish_game(db: Session, game_id: str):
db.add(game)
db.commit()
add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉", kind="victory")
logger.info("Game %s ended after %s scene(s)", game_id, game.current_scene_number)

View File

@@ -1,3 +1,4 @@
import logging
import random
from typing import List, Tuple
from sqlmodel import Session, select
@@ -8,6 +9,8 @@ from .crud_base import (
calculate_max_hand_size, change_player_rank, set_captain, is_eligible_nominee
)
logger = logging.getLogger(__name__)
# --- Between Scenes Operations ---
def eligible_vote_nominees(game: Game, voter) -> list:
@@ -46,21 +49,43 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
db.commit()
return True, "Vote submitted."
def unchallenged_pirats(game: Game):
"""Living Pi-Rats who have not yet been the target of a Deep Challenge this scene."""
challenged_ids = {c.target_player_id for c in game.challenges if c.challenge_type == "deep"}
return [p for p in game.players
if p.role == "pirat" and not p.is_dead and p.id not in challenged_ids]
def end_scene_and_transition(db: Session, game_id: str):
"""Moves game phase to between_scenes."""
"""Moves game phase to between_scenes.
Unless Dev Mode is on, the scene can't end until every living Pi-Rat has
faced at least one Deep Challenge, or the Obstacle List has been cleared.
(Whether each Pi-Rat had a chance at their Objective is left to table talk.)
"""
game = get_game(db, game_id)
if not game:
return
return False, "Game not found."
if not game.dev_mode and game.obstacles:
waiting = unchallenged_pirats(game)
if waiting:
names = ", ".join(p.name for p in waiting)
return False, (
f"Every Pi-Rat must face a Challenge before the scene can end "
f"(still waiting on: {names}). Clear the Obstacle List to end early."
)
game.phase = "between_scenes"
# Clear ready flags for players
for p in game.players:
p.is_ready = False
db.add(p)
db.add(game)
db.commit()
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
return True, "Scene ended."
def process_between_scenes_votes(db: Session, game: Game):
"""
@@ -230,6 +255,7 @@ def _remove_player(db: Session, game: Game, target: Player, event_message: str,
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)

View File

@@ -1,3 +1,5 @@
import asyncio
import contextlib
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
@@ -6,13 +8,17 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session
import logging
from logging.handlers import RotatingFileHandler
import re
import sys
import uvicorn
import os
from pathlib import Path
from . import crud
from . import maintenance
from .database import run_migrations, get_session, engine
from .validation import sanitize_name
from .ws import manager
logger = logging.getLogger(__name__)
@@ -21,11 +27,156 @@ EVENT_PAGE_SIZE = 50
BASE_DIR = Path(__file__).parent
_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()
@@ -64,6 +215,10 @@ async def broadcast_state_changes(request, call_next):
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)
@@ -83,10 +238,18 @@ def create_game_route(
crew_name: str = Form(...),
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}
@api.post("/game/{game_id}/join")
def join_game_submit(
game_id: str,
@@ -99,7 +262,7 @@ def join_game_submit(
# First player is automatically the creator
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}
@api.post("/game/{game_id}/player/{player_id}/leave")
@@ -189,6 +352,7 @@ if os.path.exists(static_dir):
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
def main():
configure_logging()
import argparse
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")

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,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

@@ -1,11 +1,19 @@
import time
import uuid
import secrets
from typing import Optional, List
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):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
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)
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

View File

@@ -133,6 +133,7 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
"game": {
"id": game.id,
"crew_name": game.crew_name,
"join_code": game.join_code,
"phase": game.phase,
"admin_key": game.admin_key
},

View File

@@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse
from sqlmodel import Session
from .database import get_session
from . import crud
from .validation import sanitize_text
router = APIRouter()
@@ -23,7 +24,8 @@ def create_challenge_route(
assert isinstance(ids, list)
except Exception:
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes_success, stakes_failure)
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids,
sanitize_text(stakes_success), sanitize_text(stakes_failure))
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}

View File

@@ -4,6 +4,7 @@ from sqlmodel import Session
from .database import get_session
from . import crud
from .cards import TECHNIQUE_SUGGESTIONS
from .validation import sanitize_text, sanitize_name
router = APIRouter()
@@ -27,10 +28,10 @@ def player_save_basic_details(
if not player:
raise HTTPException(status_code=404, detail="Player not found")
player.avatar_look = avatar_look.strip()
player.avatar_smell = avatar_smell.strip()
player.first_words = first_words.strip()
player.good_at_math = good_at_math.strip()
player.avatar_look = sanitize_text(avatar_look)
player.avatar_smell = sanitize_name(avatar_smell) # seeds the "Recruit {smell}" name
player.first_words = sanitize_text(first_words)
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)
@@ -98,7 +99,7 @@ def player_submit_delegated_answer(
answer: str = Form(...),
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"}
# Submit 3 Secret Pirate Techniques
@@ -111,7 +112,7 @@ def player_submit_techniques(
tech3: str = Form(...),
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:
return JSONResponse({"error": error}, status_code=400)
return {"status": "ok"}
@@ -234,7 +235,7 @@ def contribute_technique_route(
text: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.contribute_recruit_technique(db, player_id, recruit_id, slot, text)
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"}

View File

@@ -4,6 +4,7 @@ from sqlmodel import Session
from .database import get_session
from . import crud
from .cards import OBSTACLES_DATA
from .validation import sanitize_text, sanitize_name
router = APIRouter()
@@ -124,7 +125,7 @@ def set_name_route(
):
player = crud.get_player(db, player_id)
if player and player.needs_name:
player.name = new_name
player.name = sanitize_name(new_name)
player.needs_name = False
db.add(player)
db.commit()
@@ -140,7 +141,7 @@ def set_gat_description_route(
):
player = crud.get_player(db, player_id)
if player and player.needs_gat_description:
player.gat_description = description
player.gat_description = sanitize_text(description)
player.needs_gat_description = False
db.add(player)
db.commit()
@@ -184,7 +185,9 @@ def become_ghost_route(
# Deep ends the scene
@router.post("/game/{game_id}/scene/end")
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
crud.end_scene_and_transition(db, game_id)
ok, msg = crud.end_scene_and_transition(db, game_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Deep clears a completed obstacle

41
src/pirats/validation.py Normal file
View File

@@ -0,0 +1,41 @@
"""Light input hardening for free-text fields submitted by players.
The app is deliberately cooperative and light on auth (see the trust model), but
it's exposed on the open internet, so player-authored free text gets a basic
scrub before it is stored: drop control characters and unpaired surrogates that
could corrupt JSON or the display, trim surrounding whitespace, and cap the
length. SQL injection is already handled by SQLModel's parameterized queries —
this is only about content hygiene and bounding payload size.
Only fields whose value is authored-and-stored go through here. Identifiers
(player/obstacle ids), enums (role, objective type), and values matched against
existing stored text (a swap offer, a face-card technique assignment) are left
untouched so they still compare equal.
"""
import unicodedata
MAX_NAME_LENGTH = 120 # crew / player / Pi-Rat names and the smell that seeds them
MAX_TEXT_LENGTH = 2000 # everything else: catchphrases, techniques, stakes, descriptions
def sanitize_text(value: str, max_length: int = MAX_TEXT_LENGTH) -> str:
"""`value` with control characters and unpaired surrogates removed,
surrounding whitespace trimmed, and length capped at `max_length`.
Tabs and newlines are kept; emoji (incl. ZWJ sequences) survive."""
if not value:
return ""
cleaned = "".join(
ch for ch in value
if ch in ("\t", "\n")
or (unicodedata.category(ch) != "Cc" and not 0xD800 <= ord(ch) <= 0xDFFF)
)
cleaned = cleaned.strip()
if len(cleaned) > max_length:
cleaned = cleaned[:max_length].rstrip()
return cleaned
def sanitize_name(value: str) -> str:
"""Sanitize a single-line, name-like field: the tighter name cap, plus
collapsing any internal whitespace runs to single spaces."""
return " ".join(sanitize_text(value, max_length=MAX_NAME_LENGTH).split())

View File

@@ -3,7 +3,7 @@ import json
from sqlmodel import SQLModel, create_engine, Session, select
from pirats import cards
from pirats import crud
from pirats.models import Obstacle, Player, Challenge, GameEvent, Checkpoint
from pirats.models import Game, Obstacle, Player, Challenge, GameEvent, Checkpoint
# In-memory database for testing
@pytest.fixture(name="session")
@@ -443,6 +443,11 @@ def test_game_creation_with_crew_name(session):
assert game.id is not None
assert game.crew_name == "The Black Gats"
assert game.phase == "lobby"
assert len(game.join_code) == 5
assert set(game.join_code) <= set("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
another = crud.create_game(session, crew_name="The Other Gats")
assert another.join_code != game.join_code
def test_create_game_endpoint():
from fastapi.testclient import TestClient
@@ -475,6 +480,17 @@ def test_create_game_endpoint():
games = session.exec(select(Game)).all()
assert len(games) == 1
assert games[0].crew_name == "The Math Mutineers"
# Codes are case-insensitive at the API boundary and resolve only
# active games without exposing the UUID in the code itself.
lookup = client.get(f"/api/game/code/{games[0].join_code.lower()}")
assert lookup.status_code == 200
assert lookup.json() == {"id": games[0].id}
games[0].phase = "ended"
session.add(games[0])
session.commit()
assert client.get(f"/api/game/code/{games[0].join_code}").status_code == 404
finally:
app.dependency_overrides.clear()
@@ -981,6 +997,53 @@ def test_pvp_challenge(session):
session.refresh(duel)
assert duel.status == "succeeded"
def test_end_scene_requires_every_pirat_challenged(session):
game, deep, (p2,) = make_scene_game(session)
game.dev_mode = False
session.add(game)
session.commit()
# Obstacles remain and p2 has not been challenged yet -> blocked.
ok, msg = crud.end_scene_and_transition(session, game.id)
assert not ok
assert p2.name in msg
session.refresh(game)
assert game.phase == "scene"
# Once p2 has faced a Deep Challenge, the scene can end.
obs = game.obstacles[0]
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
assert ok, msg
ok, msg = crud.end_scene_and_transition(session, game.id)
assert ok, msg
session.refresh(game)
assert game.phase == "between_scenes"
def test_end_scene_allowed_when_obstacle_list_empty(session):
game, deep, (p2,) = make_scene_game(session)
game.dev_mode = False
session.add(game)
# Clear the Obstacle List; nobody has been challenged.
for obs in list(game.obstacles):
session.delete(obs)
session.commit()
ok, msg = crud.end_scene_and_transition(session, game.id)
assert ok, msg
session.refresh(game)
assert game.phase == "between_scenes"
def test_end_scene_dev_mode_bypasses_challenge_requirement(session):
game, deep, (p2,) = make_scene_game(session)
assert game.dev_mode # defaults on for a local checkout
ok, msg = crud.end_scene_and_transition(session, game.id)
assert ok, msg
session.refresh(game)
assert game.phase == "between_scenes"
def test_obstacles_persist_across_scenes(session):
game, deep, (p2,) = make_scene_game(session)
assert len(game.obstacles) == 2
@@ -2363,3 +2426,162 @@ def test_rollback_route_enforces_permission():
assert r.json()["status"] == "ok"
finally:
app.dependency_overrides.clear()
def _seed_game(session, crew_name, phase, last_event_ts):
"""A game whose most-recent GameEvent is at `last_event_ts`."""
game = Game(crew_name=crew_name, phase=phase)
session.add(game)
session.commit()
session.refresh(game)
session.add(GameEvent(game_id=game.id, message="move", timestamp=last_event_ts - 10))
session.add(GameEvent(game_id=game.id, message="move", timestamp=last_event_ts))
session.commit()
return game
def test_purge_old_games(session):
from pirats import maintenance
now = 2_000_000_000.0
DAY = 86400.0
active = _seed_game(session, "Active", "scene", now - 2 * DAY)
finished_old = _seed_game(session, "DoneOld", "ended", now - 20 * DAY)
finished_recent = _seed_game(session, "DoneNew", "ended", now - 3 * DAY)
inactive = _seed_game(session, "Ghosted", "scene", now - 40 * DAY)
# A game with no events at all is undatable and must be left alone.
empty = Game(crew_name="Empty", phase="lobby")
session.add(empty)
session.commit()
session.refresh(empty)
# Give the finished-old game a player + checkpoint to prove children are cleared.
session.add(Player(game_id=finished_old.id, name="Crewmate"))
session.add(Checkpoint(game_id=finished_old.id, state_json="{}"))
session.commit()
purged = maintenance.purge_old_games(session, finished_days=14, inactive_days=30, now=now)
purged_ids = {p["id"] for p in purged}
assert finished_old.id in purged_ids # ended > 14 days ago
assert inactive.id in purged_ids # no activity for > 30 days
assert active.id not in purged_ids # recently active
assert finished_recent.id not in purged_ids # ended only 3 days ago
assert empty.id not in purged_ids # undatable
remaining = {g.id for g in session.exec(select(Game)).all()}
assert finished_old.id not in remaining
assert inactive.id not in remaining
assert active.id in remaining
assert empty.id in remaining
# Children of a purged game are gone.
assert session.exec(select(GameEvent).where(GameEvent.game_id == finished_old.id)).first() is None
assert session.exec(select(Player).where(Player.game_id == finished_old.id)).first() is None
assert session.exec(select(Checkpoint).where(Checkpoint.game_id == finished_old.id)).first() is None
# Summaries carry the reason and a positive byte estimate.
summary = next(p for p in purged if p["id"] == finished_old.id)
assert summary["reason"] == "finished"
assert summary["crew_name"] == "DoneOld"
assert summary["estimated_bytes"] > 0
def test_sanitize_text_and_name():
from pirats.validation import (
sanitize_text, sanitize_name, MAX_TEXT_LENGTH, MAX_NAME_LENGTH,
)
# Control characters dropped, surrounding whitespace trimmed.
assert sanitize_text(" hi\x00\x07there ") == "hithere"
# Tabs/newlines kept inside the text.
assert sanitize_text("a\nb\tc") == "a\nb\tc"
# Zero-width joiner (emoji sequences) survives.
assert sanitize_text("ab") == "ab"
# Lone surrogate removed without raising.
assert sanitize_text("a" + chr(0xD800) + "b") == "ab"
# Length capped.
assert len(sanitize_text("x" * (MAX_TEXT_LENGTH + 50))) == MAX_TEXT_LENGTH
# Empty / falsy in -> empty out.
assert sanitize_text("") == ""
# Names collapse internal whitespace and use the tighter cap.
assert sanitize_name(" Captain Redbeard\n ") == "Captain Redbeard"
assert len(sanitize_name("y" * (MAX_NAME_LENGTH + 10))) == MAX_NAME_LENGTH
def test_create_and_join_sanitize_names():
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from pirats.main import app
from pirats.database import get_session
from sqlmodel import SQLModel, create_engine, Session
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
def override():
yield session
app.dependency_overrides[get_session] = override
client = TestClient(app)
try:
r = client.post("/api/game", data={"crew_name": " The\x00 Salty\x07 Dogs "})
gid = r.json()["id"]
assert session.get(Game, gid).crew_name == "The Salty Dogs"
r = client.post(f"/api/game/{gid}/join", data={"name": " Bad\x01name "})
player = session.get(Player, r.json()["id"])
assert player.player_name == "Badname"
assert "\x01" not in player.name
finally:
app.dependency_overrides.clear()
def test_request_body_size_limit_middleware():
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from pirats.main import LimitRequestBodyMiddleware
app = FastAPI()
app.add_middleware(LimitRequestBodyMiddleware, max_bytes=1000)
@app.post("/echo")
async def echo(request: Request):
body = await request.body()
return {"len": len(body)}
client = TestClient(app)
# Under the limit: passes through and the handler sees the full body.
r = client.post("/echo", content=b"x" * 500)
assert r.status_code == 200
assert r.json()["len"] == 500
# Over the limit with a declared Content-Length: rejected up front.
r = client.post("/echo", content=b"x" * 5000)
assert r.status_code == 413
# Over the limit via a streamed (length-omitting) body: rejected once the
# counted bytes exceed the cap.
def oversized_chunks():
for _ in range(10):
yield b"x" * 500 # 5000 bytes total
r = client.post("/echo", content=oversized_chunks())
assert r.status_code == 413
def test_request_body_size_limit_on_real_app():
from fastapi.testclient import TestClient
from pirats.main import app, DEFAULT_MAX_BODY_BYTES
# No DB override needed: the middleware rejects before routing/dependencies.
client = TestClient(app)
r = client.post("/api/game", content=b"x" * (DEFAULT_MAX_BODY_BYTES + 1))
assert r.status_code == 413