Compare commits

...
28 Commits
Author SHA1 Message Date
tim.mccarthy 83b8ed0411 Renumber consolidated release to v26 after v25 2026-09-04 20:33:09 -07:00
tim.mccarthy 79f3d40fcd Hide empty testing batches in changelog 2026-09-04 20:32:35 -07:00
tim.mccarthy 4eb9e6c96a Consolidate September 4 changes into grouped version 50 2026-09-04 20:31:27 -07:00
tim.mccarthy 6e5d57e4fc Batch testing changes and document explicit release workflow 2026-09-04 20:28:52 -07:00
tim.mccarthy c4f5980fbb Apply refreshed themes to both rules pages 2026-09-04 20:23:12 -07:00
tim.mccarthy 8b40864cc4 Refresh nautical themes and use solid notebook panels 2026-09-04 20:19:17 -07:00
tim.mccarthy 145f526634 Add per-player autosaving game notebooks 2026-09-04 20:13:14 -07:00
tim.mccarthy 0ae1701b82 Improve character suggestions and serve pools from the API 2026-09-04 19:58:35 -07:00
tim.mccarthy 1dac2141e6 Add standalone TL;DR rulebook reference 2026-09-04 19:51:15 -07:00
tim.mccarthy 5c761b079d Require visual verification for aesthetic changes 2026-09-04 19:44:25 -07:00
tim.mccarthy ad9f450343 Join crew objectives toggle and checklist in one panel 2026-09-04 19:42:11 -07:00
tim.mccarthy 9b673cb8bc Keep crew roster order stable across role changes 2026-09-04 19:40:10 -07:00
tim.mccarthy 53df97e3a5 Require Gat descriptions and keep them consistent across ownership changes 2026-09-04 19:38:20 -07:00
tim.mccarthy a5e3969d0a Let players dismiss completed duel feedback 2026-09-04 19:35:49 -07:00
tim.mccarthy 811a80a4c9 Mark played-card outcomes with corner checks and crosses 2026-09-04 19:34:26 -07:00
tim.mccarthy 22880cc466 Keep completed duel outcomes visible throughout the scene 2026-09-04 19:33:39 -07:00
tim.mccarthy fcbc617fe3 Show live player presence and Captain/Gat roster badges 2026-09-04 19:32:22 -07:00
tim.mccarthy 8b67d78f25 Defer challenge redraws until resolution and limit card plays 2026-09-04 19:28:20 -07:00
tim.mccarthy cf63ffed47 Verify and explain defender-only PvP redraws 2026-09-04 19:27:25 -07:00
tim.mccarthy 2f8f67a24c Award personal objectives through group votes 2026-09-04 19:26:37 -07:00
tim.mccarthy 8fdd10f7a3 Limit assistance to challenges with multiple active obstacles 2026-09-04 19:23:35 -07:00
tim.mccarthy 673a26f271 Align crew hand sizes and card refresh with the rules 2026-09-04 19:22:52 -07:00
tim.mccarthy 749bef6ff1 Discard completed obstacles automatically and verify scene refresh rules 2026-09-04 19:21:35 -07:00
tim.mccarthy 1837bf04e6 Advance after final rank vote and allow ballot changes 2026-09-04 19:18:38 -07:00
tim.mccarthy 88f8781dce Show visual voting progress and submitted nominee 2026-09-04 19:15:19 -07:00
tim.mccarthy d78d23d269 Remove deck size from the table 2026-09-04 19:13:19 -07:00
tim.mccarthy edcb3c08a2 Stabilize event log toggle and omit dev mode events 2026-09-04 19:13:06 -07:00
tim.mccarthy 376765dd0f Condense UI labels and align obstacle statistics 2026-09-04 19:12:36 -07:00
51 changed files with 2076 additions and 1637 deletions
+26 -3
View File
@@ -16,19 +16,42 @@ SQLite does not support adding a `NOT NULL` column without a default value to an
Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list exists only to upgrade pre-Alembic databases to the baseline and must not grow. Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list exists only to upgrade pre-Alembic databases to the baseline and must not grow.
## Visual verification
Always visually verify fixes or changes expected to have an aesthetic impact in the running app before marking them complete. Inspect browser screenshots of the affected UI, including relevant interaction states (such as expanded and collapsed panels) and screen sizes when layout is affected. Code review, DOM inspection, automated tests, and a successful build do not replace visual verification. Correct any visual issues found and inspect the result again. If visual verification is blocked, explicitly report the blocker and do not claim the appearance is verified.
## Learning during testing ## Learning during testing
When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions. 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`. - 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`.
- Before browser testing, check whether the default Vite port belongs to this project. If it is occupied by another app, start this frontend with `npm --prefix frontend run dev -- --host 127.0.0.1 --port 5174 --strictPort` and use that URL. Local server binds may require sandbox escalation.
- In-memory SQLite tests that use FastAPI TestClient need `poolclass=StaticPool` as well as `check_same_thread=False`, so request threads share the database after commits. Otherwise they can fail with `no such table`.
- Rollback tests must fetch Player/Obstacle/Challenge/Vote rows again by ID after `apply_game_state`; it deletes and recreates those rows, so old ORM instances cannot be refreshed.
- The project `.venv` uses Python 3.9. Use `Optional[T]` (or postponed annotations) instead of evaluated `T | None` annotations, which fail during import.
- Multi-socket TestClient tests must use `with TestClient(app) as client` so sockets share one event loop; separate portals can hang on cross-socket broadcasts. Close sockets explicitly before asserting disconnect messages. Override lifespan when using an isolated test database.
- For browser QA, use a temporary database (for example, `DATABASE_URL=sqlite:////tmp/pirats-qa.db`) and `PIRATS_PURGE_ENABLED=false`. Normal app startup migrates the selected database and automatically purges stale games.
## Versioning & changelog ## 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`). The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
- Bump `VERSION` by one on **every** commit. - Ordinary commits do **not** bump `VERSION` or the Nix package versions.
- When a commit changes something players can see, add an entry to the **top** of `CHANGELOG` (`{ version, date, changes: [...] }`) describing it in player-facing terms. Group everything shipping under one version into a single entry. - Accumulate player-visible changes in one entry at the **top** of `CHANGELOG` with `version: null` and `changes: [...]`. The About dialog labels it **In testing**. Append to or consolidate this batch across commits; do not create a separate entry per commit or assign a release date yet.
- The changelog is for players: skip refactors, tests, tooling, and other internal-only changes. A commit with no user-facing change bumps `VERSION` but adds no entry. - Omit the In testing entry when there are no unreleased player-facing changes; empty batches are hidden. Do not add placeholder entries just to describe changelog maintenance.
- The changelog is for players: skip refactors, tests, tooling, and other internal-only changes. Internal-only commits need no changelog entry.
- Release labeling is a separate process, performed only when explicitly requested. At release time:
1. Consolidate the In testing batch into meaningful player-facing groups using `groups: [{ title, changes: [...] }]`, replacing its flat `changes` list. Remove duplicates and describe the final behavior.
2. Bump `VERSION` by one and give the batch that numeric `version` plus the release `date` (`YYYY-MM-DD`).
3. Bump **both** package `version` values in `flake.nix` (`pirats-frontend` and `pirats`) to the same new Nix release version. Preserve the Nix version scheme; do not equate its semantic version with the app's integer V number.
4. Commit the release metadata together. The next player-visible development change starts a fresh In testing entry.
- Preserve already numbered history unless the user explicitly requests reorganizing past releases.
## Work order ## Work order
+46 -10
View File
@@ -1,15 +1,51 @@
## Features
- [x] Add an online indicator that displays if a player is connected
- [x] PvP challenges should linger in the UI after completion so that players can see the result, then dismiss it for themselves
- [x] Add hat/gun icons next to player names on the player list to indicate if they're the captain and/or have a gat
- [x] Replace red/green borders of successful/failed cards with checks and crosses in the upper corner of the card.
- [x] Add a note taking area to store game state between sessions
## Polish ## Polish
- [x] The character sheet modal popup is narrower than it needs to be on wide screens. I think it could safely be like 90% as wide as the non-modal UI. - [x] Hide In testing when there are no unreleased changes and remove the placeholder batch.
- [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] Consolidate the September 4 changelog entries into one grouped version, v26 (following the previous v25 release).
- [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] Accumulate In testing changelog batches and reserve app/Nix version bumps for grouped releases.
- [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] Apply the refreshed light and dark themes to the full rulebook and TL;DR rules.
- [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
- [x] Refresh both themes with legible texture and nautical flair, and give the Event Log / My Notes the regular panel surface.
- [x] Do a pass on condensing overly verbose UI elements. For example, "Current Difficulty" could be replaced by "Difficulty" and "The Obstacle List" could be replaced by "Obstacles"
- [x] Event log button should be the same size and position when expanded and collapsed in all screens
- [x] Align the "Current Difficulty" and "Successes" boxes
- [x] Don't have dev mode toggling appear in the event log
- [x] Remove display of current deck size
- [x] Voting status in the between-scenes phase should be visual, not text-based
- [x] Players should be able to see who they voted for
- [x] The "ready" button between scenes is redundant. Allow players to vote, and allow them to change their vote, but once all players have voted, display the result and move on
## Rules
- [x] Check against rules on correct quantity of of obstacles and obstacle refresh rules
- [x] Check against the rules on pi-rat hand size and conditions for drawing more cards — replacements arrive after resolution; one card per applied obstacle.
- [x] Assisting other pi-rats should only be available if there are multiple obstacles in the current challenge
- [x] Checking off personal objectives (gat/name/death) should be handled by group vote rather than adjudication by the deep
- [x] Double check if PvP obstacles should allow card re-draw — only the defender, on a suit-color match (including failure).
## Unknown - need to clarify what these mean
- [ ] Temporary names
- [x] Consistent player list order
- [x] Crew objectives reveal area should be connected to button
## Fixes
- [x] Make sure that gat descriptions are a) required and b) displayed on the UI
## Words Words Words ## Words Words Words
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient - [x] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint. - [x] **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.
+7 -3
View File
@@ -6,9 +6,13 @@
} }
body { body {
background: background-color: var(--bg-deep);
radial-gradient(ellipse at 50% -20%, color-mix(in srgb, var(--accent) 7%, transparent) 0%, transparent 55%), background-image:
radial-gradient(circle at 50% 60%, var(--bg) 0%, var(--bg-deep) 100%); radial-gradient(ellipse at 15% 0%, var(--chart-glow), transparent 55%),
repeating-linear-gradient(30deg, transparent 0 159px, var(--chart-line) 159px 160px),
repeating-linear-gradient(150deg, transparent 0 159px, var(--chart-line) 159px 160px),
repeating-radial-gradient(circle at 85% 15%, transparent 0 119px, var(--chart-line) 119px 120px, transparent 120px 240px),
radial-gradient(ellipse at 50% 20%, var(--bg), var(--bg-deep));
background-attachment: fixed; background-attachment: fixed;
color: var(--text); color: var(--text);
font-family: var(--font-body); font-family: var(--font-body);
+33 -4
View File
@@ -46,12 +46,41 @@
margin: 0 4px; margin: 0 4px;
} }
.card-mini.success { .card-mini.success, .card-mini.failure {
box-shadow: 0 0 0 2px var(--success); box-shadow: none;
} }
.card-mini.failure { /* Remains visible in the exposed top strip of overlapping played cards. */
box-shadow: 0 0 0 2px var(--danger); .card-result {
position: absolute;
top: 3px;
right: 4px;
display: grid;
place-items: center;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: var(--card-face);
color: var(--card-ink-black);
font-size: 1rem;
font-weight: 900;
line-height: 1;
}
.card-mini .card-result {
top: 1px;
right: 1px;
width: 0.7rem;
height: 0.7rem;
font-size: 0.65rem;
}
.card-mini.rotated .card-result {
top: auto;
right: auto;
bottom: 1px;
left: 1px;
transform: rotate(-90deg);
} }
.card-mini .val { .card-mini .val {
+4 -4
View File
@@ -1,10 +1,10 @@
/* --- Panels --- */ /* --- Panels --- */
.glass-panel { .glass-panel {
background: color-mix(in srgb, var(--surface) 88%, transparent); background: var(--panel-background);
border: 1px solid var(--edge); border: 1px solid var(--edge);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 2rem; padding: 2rem;
box-shadow: var(--shadow-soft); box-shadow: var(--panel-highlight), var(--shadow-soft);
transition: var(--transition-smooth); transition: var(--transition-smooth);
} }
@@ -13,12 +13,12 @@
} }
.card { .card {
background: var(--surface); background: var(--panel-background);
border: 1px solid var(--edge); border: 1px solid var(--edge);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
box-shadow: var(--shadow-soft); box-shadow: var(--panel-highlight), var(--shadow-soft);
} }
.card h3 { .card h3 {
+31 -49
View File
@@ -24,27 +24,6 @@
} }
} }
/* Corner button that toggles the inline Event Log without moving. */
.log-reopen-btn {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
padding: 10px 14px;
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
color: var(--text);
font-weight: bold;
font-family: var(--font-heading);
cursor: pointer;
backdrop-filter: blur(10px);
}
.log-reopen-btn:hover {
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.scene-view-layout { .scene-view-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -153,7 +132,14 @@
color: var(--text-muted); color: var(--text-muted);
} }
/* Crew Objectives toggle at the end of the roster */ /* The toggle and revealed objectives share one continuous panel. */
.crew-objectives-panel {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid var(--accent);
border-radius: var(--radius-md);
overflow: hidden;
}
.crew-objectives-toggle { .crew-objectives-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -162,9 +148,9 @@
width: 100%; width: 100%;
text-align: left; text-align: left;
padding: 0.55rem 0.7rem; padding: 0.55rem 0.7rem;
background: color-mix(in srgb, var(--accent) 12%, transparent); background: transparent;
border: 1px solid var(--accent); border: 0;
border-radius: var(--radius-md); border-radius: 0;
color: var(--text); color: var(--text);
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: 0.85rem; font-size: 0.85rem;
@@ -176,6 +162,11 @@
background: color-mix(in srgb, var(--accent) 22%, transparent); background: color-mix(in srgb, var(--accent) 22%, transparent);
} }
.crew-objectives-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -3px;
}
.crew-objectives-toggle .co-count { .crew-objectives-toggle .co-count {
font-weight: 700; font-weight: 700;
color: var(--accent); color: var(--accent);
@@ -183,9 +174,7 @@
.crew-objectives-detail { .crew-objectives-detail {
padding: 0.6rem 0.7rem; padding: 0.6rem 0.7rem;
background: var(--well); border-top: 1px solid var(--edge-accent);
border: 1px solid var(--edge);
border-radius: var(--radius-md);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
@@ -217,11 +206,7 @@
padding-bottom: 0; padding-bottom: 0;
} }
.deck-counter {
font-size: 0.85rem;
color: var(--accent);
font-weight: 700;
}
/* --- Obstacles --- */ /* --- Obstacles --- */
.obstacles-container { .obstacles-container {
@@ -236,7 +221,7 @@
padding: 1.25rem; padding: 1.25rem;
background: var(--well); background: var(--well);
display: grid; display: grid;
grid-template-columns: 0.8fr 2fr 1fr 1fr; grid-template-columns: 0.8fr 2fr 2fr;
gap: 1rem; gap: 1rem;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -264,8 +249,8 @@
/* Overlapping card column for an Obstacle. Cards stack with a heavy negative /* Overlapping card column for an Obstacle. Cards stack with a heavy negative
margin so each earlier card shows only its top strip (rank + suit); the most margin so each earlier card shows only its top strip (rank + suit); the most
recent play sits fully visible at the bottom. Rings: accent = the original recent play sits fully visible at the bottom. The original has an accent ring;
card, green/red = a play that did / didn't beat the difficulty. */ played cards show a result mark in the exposed upper corner. */
.obstacle-stack { .obstacle-stack {
--peek: 34px; /* visible strip of each earlier card */ --peek: 34px; /* visible strip of each earlier card */
--card-h: 112px; /* medium card height (card.css) */ --card-h: 112px; /* medium card height (card.css) */
@@ -290,14 +275,6 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
} }
.stack-card.is-success {
box-shadow: 0 0 0 2px var(--success);
}
.stack-card.is-failure {
box-shadow: 0 0 0 2px var(--danger);
}
.suit-badge { .suit-badge {
font-weight: 900; font-weight: 900;
font-size: 1.1rem; font-size: 1.1rem;
@@ -335,11 +312,17 @@
color: var(--text-muted); color: var(--text-muted);
} }
.obstacle-value-display { .obstacle-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-self: start; align-self: start;
gap: 0.5rem;
}
.obstacle-value-display {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: flex-start;
align-items: center; align-items: center;
background: color-mix(in srgb, var(--accent) 5%, transparent); background: color-mix(in srgb, var(--accent) 5%, transparent);
border: 1px dashed var(--edge-accent); border: 1px dashed var(--edge-accent);
@@ -348,10 +331,9 @@
} }
.obstacle-successes-display { .obstacle-successes-display {
align-self: start;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: flex-start;
align-items: center; align-items: center;
background: color-mix(in srgb, var(--success) 5%, transparent); background: color-mix(in srgb, var(--success) 5%, transparent);
border: 1px dashed color-mix(in srgb, var(--success) 30%, transparent); border: 1px dashed color-mix(in srgb, var(--success) 30%, transparent);
@@ -361,7 +343,7 @@
.obstacle-successes-display .val-number { .obstacle-successes-display .val-number {
color: var(--success); color: var(--success);
font-size: 1.5rem; font-size: 1.8rem;
} }
.val-label { .val-label {
+27 -9
View File
@@ -18,19 +18,19 @@
:root { :root {
/* --- "Fair Winds" raw palette (light, default) ----------- */ /* --- "Fair Winds" raw palette (light, default) ----------- */
--sky: #cde6f5; /* page background: midday sky */ --sky: #c9e4e5; /* page background: midday sky */
--sky-deep: #9fc9e2; /* page edges: deeper blue toward the horizon */ --sky-deep: #8dbcc4; /* page edges: deeper blue toward the horizon */
--sail: #f8f1dd; /* panel surface: weathered sailcloth */ --sail: #f8f1dd; /* panel surface: weathered sailcloth */
--sail-bright: #fffaec; /* raised surface */ --sail-bright: #fffaec; /* raised surface */
--map-ink: #33291a; /* primary text: sepia chart ink */ --map-ink: #33291a; /* primary text: sepia chart ink */
--map-faded: #7d6e54; /* muted text */ --map-faded: #6e604b; /* muted text */
--doubloon: #a8770f; /* primary accent: antique gold */ --doubloon: #8c6010; /* primary accent: antique gold */
--doubloon-bright: #c08a13; --doubloon-bright: #c08a13;
--doubloon-dark: #7c560a; --doubloon-dark: #7c560a;
--lagoon: #0c8d96; /* the Deep: tropical shallows */ --lagoon: #08747d; /* the Deep: tropical shallows */
--palm: #2f8f4e; /* Pi-Rats, success */ --palm: #2f8f4e; /* Pi-Rats, success */
--x-red: #c92a35; /* danger: the X that marks the spot */ --x-red: #c92a35; /* danger: the X that marks the spot */
--cannonball: #2c3245; /* black suits: dark navy iron */ --cannonball: #2c3245; /* black suits: dark navy iron */
@@ -75,6 +75,19 @@
--edge-soft: color-mix(in srgb, var(--map-ink) 11%, transparent); --edge-soft: color-mix(in srgb, var(--map-ink) 11%, transparent);
--edge-accent: color-mix(in srgb, var(--accent) 40%, transparent); --edge-accent: color-mix(in srgb, var(--accent) 40%, transparent);
/* Static chart lines stay on the page; fine weave stays on surfaces.
Every panel retains an opaque base beneath its decorative layers. */
--chart-line: color-mix(in srgb, var(--deep) 9%, transparent);
--chart-glow: color-mix(in srgb, var(--sail-bright) 48%, transparent);
--weave: color-mix(in srgb, var(--text) 3%, transparent);
--panel-sheen: color-mix(in srgb, var(--accent) 7%, transparent);
--panel-texture:
linear-gradient(135deg, var(--panel-sheen), transparent 48%),
repeating-linear-gradient(0deg, var(--weave) 0 1px, transparent 1px 4px),
repeating-linear-gradient(90deg, var(--weave) 0 1px, transparent 1px 5px);
--panel-background: var(--panel-texture), var(--surface);
--panel-highlight: inset 0 1px 0 color-mix(in srgb, var(--surface-raised) 70%, transparent);
/* --- Type ------------------------------------------------ */ /* --- Type ------------------------------------------------ */
--font-display: 'Pirata One', 'Alegreya SC', serif; --font-display: 'Pirata One', 'Alegreya SC', serif;
--font-heading: 'Alegreya SC', serif; --font-heading: 'Alegreya SC', serif;
@@ -94,10 +107,10 @@
:root[data-theme="dark"] { :root[data-theme="dark"] {
/* --- "Lantern & Brine" raw palette ----------------------- */ /* --- "Lantern & Brine" raw palette ----------------------- */
--ink-deep: #0a0d13; /* darkest: page edges, sunken wells */ --ink-deep: #09131c; /* darkest: page edges, sunken wells */
--ink: #131822; /* page background */ --ink: #101f27; /* page background */
--hull: #1b212e; /* panel surface */ --hull: #1c2c32; /* panel surface */
--hull-light: #262e3f; /* raised surface */ --hull-light: #293c42; /* raised surface */
--parchment: #efe7d3; /* primary text */ --parchment: #efe7d3; /* primary text */
--driftwood: #a89e8a; /* muted text */ --driftwood: #a89e8a; /* muted text */
@@ -147,6 +160,11 @@
--edge-soft: color-mix(in srgb, var(--parchment) 8%, transparent); --edge-soft: color-mix(in srgb, var(--parchment) 8%, transparent);
--edge-accent: color-mix(in srgb, var(--accent) 35%, transparent); --edge-accent: color-mix(in srgb, var(--accent) 35%, transparent);
--chart-line: color-mix(in srgb, var(--deep) 6%, transparent);
--chart-glow: color-mix(in srgb, var(--brass) 12%, transparent);
--weave: color-mix(in srgb, var(--text) 2%, transparent);
--panel-sheen: color-mix(in srgb, var(--accent) 9%, transparent);
/* --- Shadows --------------------------------------------- */ /* --- Shadows --------------------------------------------- */
--shadow-soft: 0 4px 14px rgba(0, 0, 0, 0.35); --shadow-soft: 0 4px 14px rgba(0, 0, 0, 0.35);
--shadow-deep: 0 10px 30px rgba(0, 0, 0, 0.5); --shadow-deep: 0 10px 30px rgba(0, 0, 0, 0.5);
+1 -1
View File
@@ -1,7 +1,7 @@
/* --- Between-scenes upkeep --- */ /* --- Between-scenes upkeep --- */
.between-grid { .between-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: minmax(0, 1fr);
gap: 2rem; gap: 2rem;
margin: 2rem 0; margin: 2rem 0;
} }
+18 -4
View File
@@ -2,6 +2,12 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { VERSION, CHANGELOG } from '../lib/changelog'; import { VERSION, CHANGELOG } from '../lib/changelog';
const visibleEntries = CHANGELOG.filter((entry) =>
entry.version !== null ||
(entry.groups ?? [{ changes: entry.changes ?? [] }])
.some((group) => group.changes.some((change) => change.trim()))
);
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
function close() { dispatch('close'); } function close() { dispatch('close'); }
</script> </script>
@@ -17,17 +23,20 @@
<h4 class="about-heading">What's New</h4> <h4 class="about-heading">What's New</h4>
<ul class="changelog"> <ul class="changelog">
{#each CHANGELOG as entry (entry.version)} {#each visibleEntries as entry (entry.version)}
<li class="changelog-entry"> <li class="changelog-entry">
<div class="changelog-head"> <div class="changelog-head">
<span class="changelog-ver">v{entry.version}</span> <span class="changelog-ver">{entry.version === null ? 'In testing' : `v${entry.version}`}</span>
<span class="changelog-date text-muted">{entry.date}</span> {#if entry.date}<span class="changelog-date text-muted">{entry.date}</span>{/if}
</div> </div>
{#each entry.groups ?? [{ title: null, changes: entry.changes }] as group}
{#if group.title}<h5 class="changelog-group">{group.title}</h5>{/if}
<ul class="changelog-changes"> <ul class="changelog-changes">
{#each entry.changes as change} {#each group.changes as change}
<li>{change}</li> <li>{change}</li>
{/each} {/each}
</ul> </ul>
{/each}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -72,6 +81,11 @@
.changelog-date { .changelog-date {
font-size: 0.85rem; font-size: 0.85rem;
} }
.changelog-group {
margin: 0.75rem 0 0.3rem;
font-size: 1rem;
color: var(--text);
}
.changelog-changes { .changelog-changes {
margin: 0; margin: 0;
padding-left: 1.2rem; padding-left: 1.2rem;
+8 -2
View File
@@ -6,7 +6,7 @@
export let size = 'large'; // 'large' | 'medium' | 'mini' export let size = 'large'; // 'large' | 'medium' | 'mini'
export let draggable = false; export let draggable = false;
export let rotated = false; // mini: rendered as a played (rotated) column card export let rotated = false; // mini: rendered as a played (rotated) column card
export let success = null; // mini: true/false adds success/failure styling export let success = null; // true/false displays a success/failure corner mark
export let owner = ''; // mini: short label of who played the card export let owner = ''; // mini: short label of who played the card
export let title = ''; // explicit override; otherwise the card describes itself export let title = ''; // explicit override; otherwise the card describes itself
export let techs = null; // large: {J, Q, K} technique names for the face-card overlay export let techs = null; // large: {J, Q, K} technique names for the face-card overlay
@@ -21,7 +21,7 @@
// tooltip describing the card itself (suit theme + Obstacle meaning), which // tooltip describing the card itself (suit theme + Obstacle meaning), which
// recomputes once the obstacle table loads. // recomputes once the obstacle table loads.
$: tipContent = title ? title : { html: cardTooltipHtml(card, $obstacleTable) }; $: tipContent = title ? title : { html: cardTooltipHtml(card, $obstacleTable) };
$: ariaLabel = title || cardTooltip(card, $obstacleTable); $: ariaLabel = (title || cardTooltip(card, $obstacleTable)) + (success === true ? ' — Success' : success === false ? ' — Failure' : '');
</script> </script>
{#if size === 'mini'} {#if size === 'mini'}
@@ -29,6 +29,9 @@
use:tooltip={tipContent} aria-label={ariaLabel}> use:tooltip={tipContent} aria-label={ariaLabel}>
<span class="val">{val}</span> <span class="val">{val}</span>
<span class="suit">{suit}</span> <span class="suit">{suit}</span>
{#if success !== null}
<span class="card-result" aria-hidden="true">{success ? '✓' : '✕'}</span>
{/if}
{#if owner}<span class="owner">{owner}</span>{/if} {#if owner}<span class="owner">{owner}</span>{/if}
</div> </div>
{:else} {:else}
@@ -39,6 +42,9 @@
<span class="val">{val}</span> <span class="val">{val}</span>
<span class="suit">{suit}</span> <span class="suit">{suit}</span>
</div> </div>
{#if success !== null}
<span class="card-result" aria-hidden="true">{success ? '✓' : '✕'}</span>
{/if}
<div class="card-center"> <div class="card-center">
{#if joker} {#if joker}
🃏 🃏
@@ -7,6 +7,17 @@
export let state; export let state;
let suggestionError = '';
async function suggest(type, apply) {
suggestionError = '';
try {
apply(await getSuggestion(type));
} catch (error) {
suggestionError = 'Could not load suggestions. Try again.';
}
}
// Form states // Form states
let basicDetails = { let basicDetails = {
avatar_look: state.player.avatar_look || '', avatar_look: state.player.avatar_look || '',
@@ -252,6 +263,8 @@
</script> </script>
{#if suggestionError}<p role="alert">{suggestionError}</p>{/if}
<div class="character-creation-view"> <div class="character-creation-view">
<div class="view-header text-center"> <div class="view-header text-center">
<h2>Character Sheet Creation</h2> <h2>Character Sheet Creation</h2>
@@ -281,28 +294,28 @@
<label for="avatar_look">What does your Pi-Rat look like?</label> <label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field"> <input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('look', value => basicDetails.avatar_look = value)}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like?</label> <label for="avatar_smell">What does your Pi-Rat smell like?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field"> <input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('smell', value => basicDetails.avatar_smell = value)}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="first_words">What were your first words after transforming?</label> <label for="first_words">What were your first words after transforming?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field"> <input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('first_words', value => basicDetails.first_words = value)}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label> <label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field"> <input type="text" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('good_at_math', value => basicDetails.good_at_math = value)}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="input-row mt-4"> <div class="input-row mt-4">
@@ -356,7 +369,7 @@
</label> </label>
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('like', value => inboxAnswers[`${p.id}:like`] = value)}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button> <button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button>
</div> </div>
</div> </div>
@@ -369,7 +382,7 @@
</label> </label>
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('hate', value => inboxAnswers[`${p.id}:hate`] = value)}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button> <button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button>
</div> </div>
</div> </div>
+19 -11
View File
@@ -1,4 +1,5 @@
<script> <script>
import PlayerBadges from './PlayerBadges.svelte';
import { displayName, crewLabel } from '../lib/cards'; import { displayName, crewLabel } from '../lib/cards';
import CharacterSheet from './scene/CharacterSheet.svelte'; import CharacterSheet from './scene/CharacterSheet.svelte';
@@ -7,7 +8,6 @@
let openTargetId = null; let openTargetId = null;
$: captainId = state.game.captain_player_id; $: 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; $: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
function iconFor(p) { function iconFor(p) {
@@ -21,6 +21,12 @@
try { return JSON.parse(value || '[]'); } catch { return []; } try { return JSON.parse(value || '[]'); } catch { return []; }
} }
function voteStatus(p) {
if ((state.votes || []).some(v => v.voter_player_id === p.id)) return { icon: '✓', label: 'Voted', done: true };
const canVote = state.players.some(candidate => candidate.id !== p.id && candidate.role !== 'deep' && !candidate.is_dead && !candidate.needs_reroll);
return canVote ? { icon: '○', label: 'Choosing a nominee', done: false } : { icon: '—', label: 'No eligible nominee', done: true };
}
function statusFor(p) { function statusFor(p) {
const phase = state.game.phase; const phase = state.game.phase;
if (phase === 'lobby') { if (phase === 'lobby') {
@@ -44,13 +50,7 @@
if (p.role === 'pirat') return `Pi-Rat · Rank ${p.rank}`; if (p.role === 'pirat') return `Pi-Rat · Rank ${p.rank}`;
return 'Choosing a role…'; return 'Choosing a role…';
} }
if (phase === 'between_scenes') { if (phase === 'between_scenes') return `Rank ${p.rank}`;
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 === '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 === 'recruit_creation') return p.needs_reroll ? 'Creating a new Pi-Rat' : `Rank ${p.rank} · Helping recruits`;
if (phase === 'ended') { if (phase === 'ended') {
@@ -63,14 +63,14 @@
<aside class="crew-column phase-crew-column" aria-label="Crew roster"> <aside class="crew-column phase-crew-column" aria-label="Crew roster">
<div class="crew-bubbles"> <div class="crew-bubbles">
{#each crew as p (p.id)} {#each state.players as p (p.id)}
{#if state.game.phase === 'lobby'} {#if state.game.phase === 'lobby'}
<div <div
class="crew-bubble is-static" class="crew-bubble is-static"
class:is-you={p.id === state.player.id} class:is-you={p.id === state.player.id}
> >
<span class="bubble-icon">{iconFor(p)}</span> <span class="bubble-icon">{iconFor(p)}</span>
<span class="bubble-name">{displayName(p)}</span> <span class="bubble-name">{displayName(p)} <PlayerBadges player={p} {state} /></span>
<span class="bubble-meta">{statusFor(p)}</span> <span class="bubble-meta">{statusFor(p)}</span>
</div> </div>
{:else} {:else}
@@ -83,8 +83,11 @@
title="View {p.name}'s character sheet" title="View {p.name}'s character sheet"
on:click={() => openTargetId = p.id} on:click={() => openTargetId = p.id}
> >
{#if state.game.phase === 'between_scenes'}
<span class="vote-status" class:done={voteStatus(p).done} title={voteStatus(p).label} aria-label={voteStatus(p).label}>{voteStatus(p).icon}</span>
{/if}
<span class="bubble-icon">{iconFor(p)}</span> <span class="bubble-icon">{iconFor(p)}</span>
<span class="bubble-name">{crewLabel(p, captainId)}</span> <span class="bubble-name">{crewLabel(p, captainId)} <PlayerBadges player={p} {state} /></span>
<span class="bubble-meta">{statusFor(p)}</span> <span class="bubble-meta">{statusFor(p)}</span>
</button> </button>
{/if} {/if}
@@ -95,3 +98,8 @@
{#if openTarget} {#if openTarget}
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} /> <CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
{/if} {/if}
<style>
.vote-status { position: absolute; top: 8px; right: 10px; display: grid; place-items: center; width: 1.5rem; height: 1.5rem; border: 1px solid currentColor; border-radius: 50%; color: var(--text-muted); font-size: 1rem; }
.vote-status.done { color: var(--success); }
</style>
+5 -6
View File
@@ -8,6 +8,7 @@
// Inline mode pins the log open as a column (scene phase) instead of the // Inline mode pins the log open as a column (scene phase) instead of the
// floating, collapsible corner panel used in every other phase. // floating, collapsible corner panel used in every other phase.
export let inline = false; export let inline = false;
export let headerless = false;
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away" const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
@@ -195,12 +196,12 @@
</button> </button>
{/key} {/key}
{/if} {/if}
{#if inline} {#if inline && !headerless}
<div class="log-header"> <div class="log-header">
📜 Event Log 📜 Event Log
<button class="log-hide-btn" title="Collapse the event log" on:click={() => dispatch('collapse')}>✕</button> <button class="log-hide-btn" title="Collapse the event log" on:click={() => dispatch('collapse')}>✕</button>
</div> </div>
{:else} {:else if !inline}
<button class="toggle-log-btn" on:click={toggleOpen}> <button class="toggle-log-btn" on:click={toggleOpen}>
{open ? '⬇️ Hide Log' : '📜 Event Log'} {open ? '⬇️ Hide Log' : '📜 Event Log'}
</button> </button>
@@ -258,7 +259,7 @@
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
width: 380px; width: 380px;
background: color-mix(in srgb, var(--bg-deep) 95%, transparent); background: var(--panel-background);
border: 1px solid var(--edge); border: 1px solid var(--edge);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-deep); box-shadow: var(--shadow-deep);
@@ -266,7 +267,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: 60vh; max-height: 60vh;
backdrop-filter: blur(10px);
} }
.floating-event-log:not(.open) { .floating-event-log:not(.open) {
width: auto; width: auto;
@@ -463,12 +463,11 @@
gap: 8px; gap: 8px;
text-align: left; text-align: left;
padding: 10px 12px; padding: 10px 12px;
background: color-mix(in srgb, var(--bg-deep) 95%, transparent); background: var(--panel-background);
border: 1px solid var(--edge); border: 1px solid var(--edge);
border-left: 4px solid var(--accent); border-left: 4px solid var(--accent);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-deep); box-shadow: var(--shadow-deep);
backdrop-filter: blur(10px);
color: var(--text); color: var(--text);
font-family: var(--font-body); font-family: var(--font-body);
cursor: pointer; cursor: pointer;
+148
View File
@@ -0,0 +1,148 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { apiRequest } from '../lib/api';
import EventLog from './EventLog.svelte';
export let state;
export let open = true;
let tab = 'log';
let draft = '';
let saved = '';
let loaded = false;
let error = '';
let saving = false;
let timer;
let pendingSave;
const endpoint = `/game/${state.game.id}/player/${state.player.id}/notes`;
async function load() {
error = '';
try {
const data = await apiRequest(endpoint);
draft = saved = data.text;
loaded = true;
} catch {
error = 'Couldnt load notes';
}
}
// Serialize writes so a slow earlier request cannot overwrite a newer draft.
async function save() {
clearTimeout(timer);
if (pendingSave) {
await pendingSave;
if (error) return;
}
if (!loaded || draft === saved) return;
const text = draft;
saving = true;
error = '';
pendingSave = (async () => {
try {
await apiRequest(endpoint, 'PUT', { text });
saved = text;
} catch {
error = 'Couldnt save notes';
} finally {
saving = false;
pendingSave = null;
}
})();
await pendingSave;
if (!error && draft !== saved) await save();
}
function edited() {
clearTimeout(timer);
timer = setTimeout(save, 1000);
}
function selectTab(next) {
if (tab === 'notes') save();
tab = next;
open = true;
}
function tabKey(event) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 'log' : event.key === 'End' ? 'notes' : tab === 'log' ? 'notes' : 'log';
selectTab(next);
document.getElementById(`${next}-tab`)?.focus();
}
function close() {
save();
open = false;
}
onMount(load);
onDestroy(() => { clearTimeout(timer); save(); });
</script>
<svelte:window on:keydown={(event) => { if (event.key === 'Escape' && open && tab === 'notes') close(); }} />
<div class="log-column" class:closed={!open}>
<section class="notebook-panel" class:notes-active={tab === 'notes'} aria-label="Game notebook">
<div class="panel-header">
<div class="panel-tabs" role="tablist" aria-label="Game notebook sections">
<button id="log-tab" role="tab" aria-selected={tab === 'log'} aria-controls="log-content" tabindex={tab === 'log' ? 0 : -1} on:keydown={tabKey} on:click={() => selectTab('log')}>📜 Event Log</button>
<button id="notes-tab" role="tab" aria-selected={tab === 'notes'} aria-controls="notes-content" tabindex={tab === 'notes' ? 0 : -1} on:keydown={tabKey} on:click={() => selectTab('notes')}> My Notes</button>
</div>
<button class="close-panel" title="Close panel" aria-label="Close panel" on:click={close}>✕</button>
</div>
<div id="log-content" role="tabpanel" aria-labelledby="log-tab" hidden={tab !== 'log'}>
<EventLog {state} inline headerless />
</div>
<div id="notes-content" class="notes-content" role="tabpanel" aria-labelledby="notes-tab" hidden={tab !== 'notes'}>
<p class="notes-hint">Your private notes for this game.</p>
<textarea aria-label="My notes" bind:value={draft} on:input={edited} disabled={!loaded} maxlength="50000" placeholder="Where did we leave off? Names, plans, loose ends…"></textarea>
<div class="save-status" role="status">
{#if error}
<span class="save-error">{error}</span>
<button on:click={() => loaded ? save() : load()}>Retry</button>
{:else}
{ !loaded ? 'Loading…' : saving ? 'Saving…' : draft !== saved ? 'Unsaved changes' : 'Saved' }
{/if}
</div>
</div>
</section>
</div>
<div class="notebook-controls">
{#if error}<span class="control-error" role="status">{error} · <button on:click={() => selectTab('notes')}>Open notes</button></span>{/if}
<button aria-expanded={open && tab === 'log'} on:click={() => open && tab === 'log' ? close() : selectTab('log')}>{open && tab === 'log' ? '✕ Close Log' : '📜 Event Log'}</button>
<button aria-expanded={open && tab === 'notes'} on:click={() => open && tab === 'notes' ? close() : selectTab('notes')}>{open && tab === 'notes' ? '✕ Close Notes' : '✎ My Notes'}</button>
</div>
<style>
.closed { display: none; }
.notebook-panel { position: sticky; top: 3.5rem; background: var(--panel-background); border: 1px solid var(--edge); border-radius: var(--radius-md); box-shadow: var(--shadow-deep); overflow: hidden; }
.panel-header, .panel-tabs { display: flex; align-items: stretch; }
.panel-header { border-bottom: 1px solid var(--edge-soft); }
.panel-tabs { flex: 1; }
button { cursor: pointer; color: var(--text); font-family: var(--font-heading); font-weight: bold; }
.panel-tabs button, .close-panel { background: transparent; border: 0; padding: 12px 10px; }
.panel-tabs button { flex: 1; border-bottom: 2px solid transparent; }
.panel-tabs button[aria-selected=true] { color: var(--accent); border-bottom-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
button:hover { background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.notes-content { padding: 12px; }
.notes-content[hidden] { display: none; }
.notes-hint { margin: 0 0 10px; color: var(--text-muted); font-size: .85rem; }
textarea { display: block; box-sizing: border-box; width: 100%; height: min(55vh, 520px); min-height: 180px; resize: vertical; padding: 12px; font: inherit; line-height: 1.5; color: var(--text); background: var(--surface-raised); border: 1px solid var(--edge); border-radius: var(--radius-sm); }
textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.save-status { display: flex; align-items: center; gap: 8px; min-height: 30px; padding-top: 8px; color: var(--text-muted); font-size: .8rem; }
.save-status button { border: 1px solid var(--edge); border-radius: 4px; background: var(--surface-raised); }
.save-error, .control-error { color: var(--danger); }
.notebook-controls { position: fixed; bottom: 20px; right: 20px; z-index: 1000; display: flex; gap: 8px; }
.notebook-controls > button { width: 132px; height: 44px; background: var(--panel-background); border: 1px solid var(--edge); border-radius: var(--radius-md); box-shadow: var(--shadow-deep); }
.control-error { position: absolute; bottom: 52px; right: 0; width: max-content; max-width: 90vw; background: var(--panel-background); padding: 8px; border: 1px solid var(--danger); border-radius: var(--radius-sm); font-size: .85rem; }
.control-error button { color: var(--text); background: transparent; border: 0; text-decoration: underline; }
.notebook-panel :global(.floating-event-log.inline) { position: static; border: none; border-radius: 0; box-shadow: none; max-height: calc(100dvh - 12rem); }
@media (max-width: 1100px) {
.notebook-panel { position: static; }
.notebook-panel.notes-active { position: fixed; z-index: 1002; top: auto; bottom: 0; left: 0; right: 0; border-radius: var(--radius-md) var(--radius-md) 0 0; max-height: 90dvh; }
.notes-active textarea { height: 55dvh; min-height: 100px; resize: none; }
.notebook-controls { right: 12px; bottom: 12px; }
}
</style>
+4 -1
View File
@@ -28,7 +28,7 @@
} }
</script> </script>
{#if state.player.needs_gat_description} {#if state.player.completed_personal_1 && (state.player.needs_gat_description || !state.player.gat_description?.trim())}
<div class="modal-backdrop"> <div class="modal-backdrop">
<div class="modal-box glass-panel"> <div class="modal-box glass-panel">
<h3>🔫 You Got a Gat!</h3> <h3>🔫 You Got a Gat!</h3>
@@ -45,6 +45,9 @@
placeholder="e.g. A pearl-handled flintlock that smells of cheese" placeholder="e.g. A pearl-handled flintlock that smells of cheese"
on:keydown={onKeydown} on:keydown={onKeydown}
autofocus autofocus
required
maxlength="2000"
aria-label="Gat description"
> >
<button <button
class="btn btn-gold btn-full" class="btn btn-gold btn-full"
@@ -0,0 +1,18 @@
<script>
export let player;
export let state;
$: online = state.onlinePlayerIds?.includes(player.id);
$: connectionLabel = state.onlinePlayerIds == null ? 'Connection unknown' : online ? 'Online' : 'Offline';
</script>
<span class="player-badges">
<span class="presence" class:online title={connectionLabel} aria-label={connectionLabel}>{online ? '●' : '○'}</span>
{#if player.id === state.game.captain_player_id}<span title="Captain" aria-label="Captain">🎩</span>{/if}
{#if player.completed_personal_1}<span title={player.gat_description?.trim() || 'Gat: waiting for a description'} aria-label={player.gat_description?.trim() ? `Gat: ${player.gat_description}` : 'Gat: waiting for a description'}>🔫</span>{/if}
</span>
<style>
.player-badges { display: inline-flex; align-items: center; gap: 0.2rem; white-space: nowrap; }
.presence { color: var(--text-muted); font-size: 0.85rem; }
.presence.online { color: var(--success); }
</style>
+19 -6
View File
@@ -6,6 +6,17 @@
export let state; export let state;
let suggestionError = '';
async function suggest(type, apply) {
suggestionError = '';
try {
apply(await getSuggestion(type));
} catch (error) {
suggestionError = 'Could not load suggestions. Try again.';
}
}
function getPlayerName(id) { function getPlayerName(id) {
if (!id) return 'None'; if (!id) return 'None';
const p = state.players.find(x => x.id === id); const p = state.players.find(x => x.id === id);
@@ -137,6 +148,8 @@
</script> </script>
{#if suggestionError}<p role="alert">{suggestionError}</p>{/if}
<div class="recruit-phase-view"> <div class="recruit-phase-view">
<div class="view-header text-center"> <div class="view-header text-center">
<h2>🐀 New Recruits</h2> <h2>🐀 New Recruits</h2>
@@ -162,28 +175,28 @@
<label for="avatar_look">What does your Pi-Rat look like?</label> <label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field"> <input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('look', value => basicDetails.avatar_look = value)}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like? (Determines name)</label> <label for="avatar_smell">What does your Pi-Rat smell like? (Determines name)</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field"> <input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('smell', value => basicDetails.avatar_smell = value)}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="first_words">What were your first words after transforming?</label> <label for="first_words">What were your first words after transforming?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field"> <input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('first_words', value => basicDetails.first_words = value)}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label> <label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="good_at_math" placeholder="e.g. Thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field"> <input type="text" id="good_at_math" placeholder="e.g. Thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('good_at_math', value => basicDetails.good_at_math = value)}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="input-row mt-4"> <div class="input-row mt-4">
@@ -264,7 +277,7 @@
</label> </label>
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:like`]} placeholder="e.g. They always share their cheese"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:like`]} placeholder="e.g. They always share their cheese"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:like`] = getSuggestion('like')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('like', value => inboxAnswers[`${task.recruit.id}:like`] = value)}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', task.recruit.id)}>Submit</button> <button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', task.recruit.id)}>Submit</button>
</div> </div>
</div> </div>
@@ -277,7 +290,7 @@
</label> </label>
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:hate`]} placeholder="e.g. They snore too loudly"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:hate`]} placeholder="e.g. They snore too loudly"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('hate', value => inboxAnswers[`${task.recruit.id}:hate`] = value)}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', task.recruit.id)}>Submit</button> <button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', task.recruit.id)}>Submit</button>
</div> </div>
</div> </div>
+30 -24
View File
@@ -1,30 +1,47 @@
<script> <script>
import Card from './Card.svelte'; import Card from './Card.svelte';
import ObjectiveVotes from './scene/ObjectiveVotes.svelte';
import ChallengePanel from './scene/ChallengePanel.svelte'; import ChallengePanel from './scene/ChallengePanel.svelte';
import ObstacleBoard from './scene/ObstacleBoard.svelte'; import ObstacleBoard from './scene/ObstacleBoard.svelte';
import CrewColumn from './scene/CrewColumn.svelte'; import CrewColumn from './scene/CrewColumn.svelte';
import EventLog from './EventLog.svelte';
export let state; export let state;
// The inline Event Log can be toggled from a fixed corner button to declutter.
let logOpen = true;
let dismissedDuelIds = [];
$: dismissalKey = `dismissed-duels:${state.game.id}:${state.player.id}:${state.game.current_scene_number}`;
$: dismissedDuelIds = loadDismissals(dismissalKey);
function loadDismissals(key) {
try {
const ids = JSON.parse(localStorage.getItem(key) || '[]');
return Array.isArray(ids) ? ids : [];
} catch { return []; }
}
function dismissDuel(id) {
dismissedDuelIds = [...dismissedDuelIds, id];
try { localStorage.setItem(dismissalKey, JSON.stringify(dismissedDuelIds)); } catch { /* Still dismiss when storage is unavailable. */ }
}
$: completedDuels = (state.challenges || []).filter(c =>
c.challenge_type === 'pvp' && ['succeeded', 'failed'].includes(c.status) && !dismissedDuelIds.includes(c.id)
);
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; $: 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 }; $: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
$: isDeep = state.player.role === 'deep'; $: isDeep = state.player.role === 'deep';
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open'); $: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
// The Challenge area is shown when something is happening there, or to the Deep // Keep results visible until this player dismisses them.
// (who calls Challenges and ends the scene from it). $: showChallengeArea = isDeep || openChallenges.length > 0 || completedDuels.length > 0;
$: showChallengeArea = isDeep || openChallenges.length > 0;
</script> </script>
<div class="scene-view-layout" class:log-collapsed={!logOpen} id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}> <div class="scene-content" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
<!-- LEFT COLUMN: The Crew --> <!-- LEFT COLUMN: The Crew -->
<CrewColumn {state} /> <CrewColumn {state} />
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list --> <!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
<div class="table-column"> <div class="table-column">
<ObjectiveVotes {state} />
{#if !isDeep} {#if !isDeep}
<div class="card glass-panel hand-card"> <div class="card glass-panel hand-card">
<span class="hand-label">🃏 Your Hand</span> <span class="hand-label">🃏 Your Hand</span>
@@ -41,7 +58,7 @@
e.currentTarget.classList.remove("dragging"); e.currentTarget.classList.remove("dragging");
}} /> }} />
{:else} {:else}
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p> <p class="empty-text text-center">Empty hand. Match an obstacles color to draw cards.</p>
{/each} {/each}
</div> </div>
</div> </div>
@@ -49,31 +66,20 @@
{#if showChallengeArea} {#if showChallengeArea}
<div class="card glass-panel challenge-area-card"> <div class="card glass-panel challenge-area-card">
<ChallengePanel {state} /> <ChallengePanel {state} {completedDuels} on:dismissDuel={(event) => dismissDuel(event.detail)} />
</div> </div>
{/if} {/if}
<div class="card glass-panel obstacle-list-card"> <div class="card glass-panel obstacle-list-card">
<div class="card-header"> <div class="card-header">
<h3>🌊 The Obstacle List</h3> <h3>🌊 Obstacles</h3>
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
</div> </div>
<ObstacleBoard {state} /> <ObstacleBoard {state} />
</div> </div>
</div> </div>
<!-- RIGHT COLUMN: The Event Log (collapsible to a corner button) -->
{#if logOpen}
<div class="log-column">
<EventLog {state} inline on:collapse={() => (logOpen = false)} />
</div>
{/if}
</div> </div>
<button <style>
class="log-reopen-btn" .scene-content { display: contents; }
title={logOpen ? 'Hide the event log' : 'Show the event log'} </style>
on:click={() => (logOpen = !logOpen)}
>
{logOpen ? '✕ Close Log' : '📜 Event Log'}
</button>
+37 -46
View File
@@ -6,7 +6,7 @@
export let state; export let state;
let voting = false; let voting = false;
let readying = false; let voteError = "";
let confirming = false; let confirming = false;
let selectedVoteId = ''; let selectedVoteId = '';
@@ -27,7 +27,9 @@
); );
} }
$: myNominees = eligibleNomineesFor(state.player); $: myNominees = eligibleNomineesFor(state.player);
$: hasVoted = !!state.votes.find(v => v.voter_player_id === state.player.id); $: myVote = state.votes.find(v => v.voter_player_id === state.player.id);
$: hasVoted = !!myVote;
$: votedNominee = myVote ? state.players.find(p => p.id === myVote.nominated_player_id) : null;
// With no eligible crewmates, the voter skips voting entirely (no deadlock). // With no eligible crewmates, the voter skips voting entirely (no deadlock).
$: mustVote = myNominees.length > 0; $: mustVote = myNominees.length > 0;
@@ -132,28 +134,18 @@
async function submitVote() { async function submitVote() {
if (!selectedVoteId) return; if (!selectedVoteId) return;
voting = true; voting = true;
voteError = "";
try { try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-vote`, 'POST', { await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-vote`, 'POST', {
nominated_id: selectedVoteId nominated_id: selectedVoteId
}); });
} catch(e) { } catch(e) {
console.error(e); voteError = e.message;
} finally { } finally {
voting = false; voting = false;
} }
} }
async function setReady() {
readying = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/ready-next`, 'POST');
} catch(e) {
console.error(e);
} finally {
readying = false;
}
}
async function confirmRefresh() { async function confirmRefresh() {
confirming = true; confirming = true;
try { try {
@@ -170,8 +162,10 @@
</script> </script>
<div class="between-scenes-view text-center"> <div class="between-scenes-view text-center">
<h2>Between Scenes (Scene #{state.game.current_scene_number} Concluded)</h2> <h2>Between Scenes · Scene {state.game.current_scene_number}</h2>
<p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p> <p class="description">{state.game.phase === 'between_scenes'
? 'Vote for a crewmate to rank up. You can change your vote until everyone has voted.'
: 'Voting complete. The resting Deep refreshes their hand before the next scene.'}</p>
{#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll} {#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll}
<!-- Death Fate Panel --> <!-- Death Fate Panel -->
@@ -201,8 +195,10 @@
<div class="between-grid" class:single={state.game.phase !== 'deep_upkeep'}> <div class="between-grid" class:single={state.game.phase !== 'deep_upkeep'}>
<!-- Ranks and Voting Card --> <!-- Ranks and Voting Card -->
<div class="card glass-panel voting-card"> <div class="card glass-panel voting-card">
<h3>1. Pirate Ranking (Voting)</h3> <h3>Rank Up</h3>
<p class="section-desc">Every player (including the Deep) nominates one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p> {#if state.game.phase === 'between_scenes'}
<p class="section-desc">Nominate another Pi-Rat who best showed their pirate spirit this scene. The Deep cannot receive votes.</p>
{/if}
{#if state.game.phase === 'deep_upkeep'} {#if state.game.phase === 'deep_upkeep'}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
@@ -212,34 +208,38 @@
<p class="success-text">✔️ Voting complete! No one ranked up this time.</p> <p class="success-text">✔️ Voting complete! No one ranked up this time.</p>
{/if} {/if}
</div> </div>
{:else if hasVoted} {:else}
{#if hasVoted}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p> <p class="success-text">✓ Your vote: <strong>{votedNominee ? displayName(votedNominee) : "Unavailable crewmate"}</strong></p>
</div> </div>
{:else if !mustVote} {/if}
{#if !mustVote}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
<p class="info-text">No living crewmates left to nominate — you skip voting this round. Go ahead and ready up.</p> <p class="info-text">No eligible crewmates — voting skipped.</p>
</div> </div>
{:else} {:else}
<form on:submit|preventDefault={submitVote} class="vote-form inline-form"> <form on:submit|preventDefault={submitVote} class="vote-form inline-form">
<div class="form-group inline-group"> <div class="form-group inline-group">
<select class="select-field" bind:value={selectedVoteId} required> <select aria-label="Nominee" class="select-field" bind:value={selectedVoteId} required>
<option value="">Nominate crewmate...</option> <option value="">Nominate crewmate...</option>
{#each myNominees as p} {#each myNominees as p}
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option> <option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each} {/each}
</select> </select>
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button> <button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId || selectedVoteId === myVote?.nominated_player_id}>{hasVoted ? "Change Vote" : "Vote"}</button>
</div> </div>
</form> </form>
{/if} {/if}
{#if voteError}<p class="alert alert-danger" role="alert">{voteError}</p>{/if}
{/if}
</div> </div>
<!-- Resting Deep Player Card — only the actual hand refresh, during deep_upkeep --> <!-- Resting Deep Player Card — only the actual hand refresh, during deep_upkeep -->
{#if state.game.phase === 'deep_upkeep'} {#if state.game.phase === 'deep_upkeep'}
<div class="card glass-panel deep-rest-card"> <div class="card glass-panel deep-rest-card">
<h3>2. Resting Deep Upkeep</h3> <h3>Refresh Hand</h3>
{#if state.player.role === "deep"} {#if state.player.role === "deep"}
<div class="deep-rest-panel glass-panel"> <div class="deep-rest-panel glass-panel">
@@ -260,7 +260,7 @@
<p class="info-text text-center" style="margin-top: 1rem;">Waiting for the rest of the crew to finish upkeep...</p> <p class="info-text text-center" style="margin-top: 1rem;">Waiting for the rest of the crew to finish upkeep...</p>
{:else} {:else}
<p class="info-text" style="margin-top: 1rem;"> <p class="info-text" style="margin-top: 1rem;">
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/> <strong>Hand Limit: {maxHandSize} cards.</strong><br/>
Drag cards to the "Discard Pile" zone to discard them, or click them to move them. Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
</p> </p>
@@ -307,7 +307,7 @@
{/if} {/if}
<button class="btn btn-primary mt-4 w-full" on:click={confirmRefresh} disabled={confirming || isOverLimit}> <button class="btn btn-primary mt-4 w-full" on:click={confirmRefresh} disabled={confirming || isOverLimit}>
Confirm Hand Refresh Refresh Hand
</button> </button>
{/if} {/if}
</div> </div>
@@ -318,7 +318,7 @@
{/if} {/if}
</div> </div>
<!-- Next Scene Ready Up --> <!-- Upkeep progress and end-story action -->
<div class="action-box margin-top"> <div class="action-box margin-top">
{#if state.game.phase === 'deep_upkeep'} {#if state.game.phase === 'deep_upkeep'}
{#if state.player.role === 'deep' && !state.player.is_ready} {#if state.player.role === 'deep' && !state.player.is_ready}
@@ -329,24 +329,6 @@
<div class="spinner-small"></div> <div class="spinner-small"></div>
</div> </div>
{/if} {/if}
{:else}
{#if mustVote && !hasVoted}
<p class="info-text gold-text" style="margin-bottom:0.5rem;">⚠️ You must nominate a crewmate above before you can ready up.</p>
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
{:else}
{#if state.player.is_ready}
<div class="waiting-box">
<p>Ready! Waiting for other players to ready up...</p>
<div class="spinner-small"></div>
</div>
{:else}
<button on:click={setReady}
disabled={readying}
class="btn btn-primary btn-large glow-effect">
Ready for Next Scene
</button>
{/if}
{/if}
{/if} {/if}
{#if state.player.is_admin && state.game.phase === 'between_scenes'} {#if state.player.is_admin && state.game.phase === 'between_scenes'}
@@ -358,3 +340,12 @@
</div> </div>
{/if} {/if}
</div> </div>
<style>
.vote-form { margin-top: 1rem; }
.vote-form button { white-space: nowrap; flex-shrink: 0; }
.vote-form select { min-width: 0; }
@media (max-width: 600px) {
.vote-form .inline-group { flex-direction: column; align-items: stretch; }
}
</style>
@@ -1,10 +1,13 @@
<script> <script>
import { createEventDispatcher } from 'svelte';
import { apiRequest } from '../../lib/api'; import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltipHtml, obstacleTable } from '../../lib/cards'; import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltipHtml, obstacleTable } from '../../lib/cards';
import { tooltip } from '../../lib/tooltip'; import { tooltip } from '../../lib/tooltip';
import ObstacleItem from './ObstacleItem.svelte'; import ObstacleItem from './ObstacleItem.svelte';
export let state; export let state;
export let completedDuels = [];
const dispatch = createEventDispatcher();
let error = ''; let error = '';
let taxTargetId = ''; let taxTargetId = '';
@@ -126,6 +129,9 @@
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! 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> </p>
{:else} {:else}
<p class="info-text" style="margin-top: 0.5rem;">{state.obstacles.filter(o => chObstacleIds.includes(o.id)).length > 1
? 'Other Pi-Rats may assist with one card each. Only the attempting Pi-Rat draws replacements after resolution.'
: 'Only the attempting Pi-Rat can play against this single Obstacle. Matching colors earn a replacement after resolution.'}</p>
<div class="challenge-obstacles"> <div class="challenge-obstacles">
{#each chObstacleIds as oid} {#each chObstacleIds as oid}
{@const obs = state.obstacles.find(o => o.id === oid)} {@const obs = state.obstacles.find(o => o.id === oid)}
@@ -170,7 +176,7 @@
<!-- PvP: defender picks a card --> <!-- PvP: defender picks a card -->
{#if myPvpDefense && myPvpDefense.id === ch.id} {#if myPvpDefense && myPvpDefense.id === ch.id}
<div style="margin-top: 0.75rem;"> <div style="margin-top: 0.75rem;">
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p> <p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Match the Obstacles color to draw a replacement, even if you lose:</p>
<div class="challenge-actions"> <div class="challenge-actions">
{#each hand.filter(c => !isJoker(c)) as card} {#each hand.filter(c => !isJoker(c)) as card}
<button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable, true) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button> <button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable, true) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
@@ -181,6 +187,28 @@
</div> </div>
{/each} {/each}
<!-- Each player clears their own result feedback after reading it. -->
{#each completedDuels as ch (ch.id)}
<div class="challenge-item duel-result">
<div class="challenge-head">
<h4>⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}</h4>
<button class="btn btn-secondary btn-small" title="Dismiss this result for you" on:click={() => dispatch('dismissDuel', ch.id)}>Dismiss</button>
</div>
<p class="duel-outcome">
{ch.status === 'succeeded' ? '✓' : '✕'}
<strong>{playerName(ch.acting_player_id)} {ch.status === 'succeeded' ? 'won the defense' : 'lost the defense'}.</strong>
{playerName(ch.status === 'succeeded' ? ch.acting_player_id : ch.challenger_player_id)} wins the duel.
</p>
<p class="info-text">Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable, true) }}>{getCardDisplay(ch.temp_card)}</strong></p>
{#each JSON.parse(ch.plays || '[]') as play}
<p class="info-text">
Defense: <strong use:tooltip={{ html: cardTooltipHtml(play.card, $obstacleTable, true) }}>{getCardDisplay(play.card)}</strong>.
{play.details}
</p>
{/each}
</div>
{/each}
<!-- Deep controls: call a Challenge, and end the scene when none is active --> <!-- Deep controls: call a Challenge, and end the scene when none is active -->
<!-- The Deep calls Challenges and ends the scene only when none is active. --> <!-- The Deep calls Challenges and ends the scene only when none is active. -->
{#if isDeep && openChallenges.length === 0} {#if isDeep && openChallenges.length === 0}
@@ -16,10 +16,10 @@
$: isSelf = target.id === viewer.id; $: isSelf = target.id === viewer.id;
$: isDeep = viewer.role === 'deep'; $: isDeep = viewer.role === 'deep';
$: isCaptain = target.id === state.game.captain_player_id; $: isCaptain = target.id === state.game.captain_player_id;
// The Deep manages captaincy and ticks personal objectives from a Pi-Rat's sheet // Captaincy is managed by the Deep; objectives go to a group vote.
// (both moved here from the old Deep Control Panel).
$: canManageCaptain = isDeep && target.role === 'pirat' && !target.is_dead; $: canManageCaptain = isDeep && target.role === 'pirat' && !target.is_dead;
$: canManageObjectives = isDeep && target.role === 'pirat'; $: canManageObjectives = !viewer.needs_reroll && target.role === 'pirat' && !target.is_dead && !target.needs_reroll;
$: pendingObjective = JSON.parse(state.game.objective_votes || '[]').some(p => p.target_id === target.id);
// The viewer throws one of their OWN cards in a duel. // The viewer throws one of their OWN cards in a duel.
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : []; $: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
$: canDuel = !isSelf $: canDuel = !isSelf
@@ -59,10 +59,10 @@
} }
} }
async function toggleObjective(type) { async function proposeObjective(type) {
error = ''; error = '';
try { try {
await apiRequest(`/game/${state.game.id}/player/${target.id}/objective/toggle`, 'POST', { type }); await apiRequest(`/game/${state.game.id}/player/${viewer.id}/objective/propose`, 'POST', { type, target_id: target.id });
} catch (e) { } catch (e) {
error = e.message; error = e.message;
} }
@@ -119,18 +119,18 @@
{/if} {/if}
<div class="sheet-group"> <div class="sheet-group">
<h4>Description:</h4> <h4>Description</h4>
<p><strong>Look:</strong> {target.avatar_look}</p> <p><strong>Look:</strong> {target.avatar_look}</p>
<p><strong>Smell:</strong> {target.avatar_smell}</p> <p><strong>Smell:</strong> {target.avatar_smell}</p>
<p><strong>First Words:</strong> "{target.first_words}"</p> <p><strong>First Words:</strong> "{target.first_words}"</p>
{#if target.gat_description} {#if target.completed_personal_1}
<p><strong>🔫 Gat:</strong> {target.gat_description}</p> <p><strong>🔫 Gat:</strong> {target.gat_description?.trim() || 'Waiting for a description…'}</p>
{/if} {/if}
</div> </div>
{#if target.other_like || target.other_hate} {#if target.other_like || target.other_hate}
<div class="sheet-group margin-top"> <div class="sheet-group margin-top">
<h4>What the Crew Thinks:</h4> <h4>Crew Impressions</h4>
{#if target.other_like} {#if target.other_like}
<p><strong>👍 Likes:</strong> "{target.other_like}" <span class="text-muted">{getPlayerName(target.other_like_from_player_id)}</span></p> <p><strong>👍 Likes:</strong> "{target.other_like}" <span class="text-muted">{getPlayerName(target.other_like_from_player_id)}</span></p>
{/if} {/if}
@@ -143,7 +143,7 @@
<!-- Secret techniques are visible only on your own sheet --> <!-- Secret techniques are visible only on your own sheet -->
{#if isSelf} {#if isSelf}
<div class="sheet-group margin-top"> <div class="sheet-group margin-top">
<h4>Assigned Secret Techniques (J/Q/K):</h4> <h4>Secret Techniques</h4>
<ul class="techniques-list-sheet"> <ul class="techniques-list-sheet">
<li><strong>Jack (J):</strong> "{target.tech_jack}"</li> <li><strong>Jack (J):</strong> "{target.tech_jack}"</li>
<li><strong>Queen (Q):</strong> "{target.tech_queen}"</li> <li><strong>Queen (Q):</strong> "{target.tech_queen}"</li>
@@ -156,7 +156,7 @@
{#if canDuel} {#if canDuel}
<div class="sheet-group margin-top"> <div class="sheet-group margin-top">
<h4>⚔️ Duel {crewLabel(target, state.game.captain_player_id)}</h4> <h4>⚔️ Duel {crewLabel(target, state.game.captain_player_id)}</h4>
<p class="info-text" style="font-size: 0.85rem;">Challenge them (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p> <p class="info-text" style="font-size: 0.85rem;">Challenge them (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards. Only the defender draws a replacement, if their card matches the Obstacles color (even on failure).</p>
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;"> <select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Throw which card?</option> <option value="">Throw which card?</option>
{#each hand.filter(c => !isJoker(c)) as card} {#each hand.filter(c => !isJoker(c)) as card}
@@ -190,21 +190,21 @@
<div class="sheet-group"> <div class="sheet-group">
<h4>Personal Objectives</h4> <h4>Personal Objectives</h4>
{#if canManageObjectives} {#if canManageObjectives}
<p class="info-text" style="font-size: 0.8rem;">As the Deep, tick these off in sequence (1 → 2 → 3).</p> <p class="info-text" style="font-size: 0.8rem;">Propose the next objective for a group vote. Your proposal counts as a Yes vote.</p>
{/if} {/if}
<div class="objectives-checklist"> <div class="objectives-checklist">
<label class="checkbox-label"> {#each ['Gat', 'Name', 'Die/Retire'] as label, i}
<input type="checkbox" checked={target.completed_personal_1} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_1')}> {@const type = `personal_${i + 1}`}
<span>1. Gat</span> {@const completed = target[`completed_${type}`]}
</label> <div>
<label class="checkbox-label"> <span>{completed ? '✓' : '○'} {i + 1}. {label}</span>
<input type="checkbox" checked={target.completed_personal_2} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_2')}> {#if canManageObjectives && !completed && (i === 0 || target[`completed_personal_${i}`])}
<span>2. Name</span> <button class="btn btn-secondary" disabled={pendingObjective} on:click={() => proposeObjective(type)}>
</label> {pendingObjective ? 'Vote pending' : 'Propose'}
<label class="checkbox-label"> </button>
<input type="checkbox" checked={target.completed_personal_3} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_3')}> {/if}
<span>3. Die/Retire</span> </div>
</label> {/each}
</div> </div>
</div> </div>
</div> <!-- end sheet-side-column --> </div> <!-- end sheet-side-column -->
@@ -1,4 +1,5 @@
<script> <script>
import PlayerBadges from '../PlayerBadges.svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { apiRequest } from '../../lib/api'; import { apiRequest } from '../../lib/api';
import { crewLabel } from '../../lib/cards'; import { crewLabel } from '../../lib/cards';
@@ -34,8 +35,6 @@
} }
} }
// Pi-Rats first, the Deep last; otherwise keep join order.
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
$: captainId = state.game.captain_player_id; $: captainId = state.game.captain_player_id;
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null; $: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
$: crewDone = [state.game.completed_crew_1, state.game.completed_crew_2, state.game.completed_crew_3].filter(Boolean).length; $: crewDone = [state.game.completed_crew_1, state.game.completed_crew_2, state.game.completed_crew_3].filter(Boolean).length;
@@ -53,7 +52,7 @@
<div class="crew-column"> <div class="crew-column">
<div class="crew-bubbles"> <div class="crew-bubbles">
{#each crew as p (p.id)} {#each state.players as p (p.id)}
<button <button
class="crew-bubble" class="crew-bubble"
class:is-you={p.id === state.player.id} class:is-you={p.id === state.player.id}
@@ -73,14 +72,15 @@
>{hasBeenChallenged(p) ? '✓' : '○'}</span> >{hasBeenChallenged(p) ? '✓' : '○'}</span>
{/if} {/if}
<span class="bubble-icon">{roleIcon(p)}</span> <span class="bubble-icon">{roleIcon(p)}</span>
<span class="bubble-name">{crewLabel(p, captainId)}</span> <span class="bubble-name">{crewLabel(p, captainId)} <PlayerBadges player={p} {state} /></span>
<span class="bubble-meta"> <span class="bubble-meta">
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)} {#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
</span> </span>
</button> </button>
{/each} {/each}
<!-- Crew Objectives sits at the end of the roster and slides open in place --> <!-- One shared panel keeps the toggle attached to its contents. -->
<div class="crew-objectives-panel">
<button <button
class="crew-objectives-toggle" class="crew-objectives-toggle"
aria-expanded={showObjectives} aria-expanded={showObjectives}
@@ -109,6 +109,7 @@
{/if} {/if}
</div> </div>
</div> </div>
</div>
{#if openTarget} {#if openTarget}
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} /> <CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
@@ -0,0 +1,28 @@
<script>
import { apiRequest } from '../../lib/api';
export let state;
let error = '';
$: proposals = JSON.parse(state.game.objective_votes || '[]');
const labels = { personal_1: 'Gat', personal_2: 'Name', personal_3: 'Die/Retire' };
async function vote(proposal_id, approve) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/objective/vote`, 'POST', { proposal_id, approve });
} catch (e) { error = e.message; }
}
</script>
{#if error}<div class="alert alert-danger">{error}</div>{/if}
{#each proposals as proposal (proposal.id)}
<div class="card glass-panel">
<h3>Objective Vote: {state.players.find(p => p.id === proposal.target_id)?.name}{labels[proposal.type]}</h3>
<p class="info-text">Majority decides. The Captain breaks ties; a tie without a Captain does not award the objective. Votes close when decided or the scene ends.</p>
<p>{Object.values(proposal.ballots).filter(Boolean).length} Yes · {Object.values(proposal.ballots).filter(v => !v).length} No · {state.players.filter(p => !p.needs_reroll && !(p.id in proposal.ballots)).length} waiting</p>
{#if !state.player.needs_reroll}
<div class="challenge-actions">
<button class="btn btn-primary" aria-pressed={proposal.ballots[state.player.id] === true} disabled={proposal.ballots[state.player.id] === true} on:click={() => vote(proposal.id, true)}>Yes{proposal.ballots[state.player.id] === true ? ' ✓' : ''}</button>
<button class="btn btn-secondary" aria-pressed={proposal.ballots[state.player.id] === false} disabled={proposal.ballots[state.player.id] === false} on:click={() => vote(proposal.id, false)}>No{proposal.ballots[state.player.id] === false ? ' ✓' : ''}</button>
</div>
{/if}
</div>
{/each}
@@ -22,6 +22,12 @@
$: success_count = column_cards.filter(c => c.success).length; $: success_count = column_cards.filter(c => c.success).length;
$: is_completed = success_count >= state.players.length; $: is_completed = success_count >= state.players.length;
$: in_challenge = openChallenges.some(c => JSON.parse(c.obstacle_ids || '[]').includes(obs.id)); $: in_challenge = openChallenges.some(c => JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
$: challenge = openChallenges.find(c => JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
$: canPlay = state.player.role === 'pirat' && !state.player.is_dead && !state.player.needs_reroll
&& challenge && challenge.tax_state !== 'requested'
&& !JSON.parse(challenge.plays || '[]').some(p => p.obstacle_id === obs.id)
&& (challenge.acting_player_id === state.player.id
|| state.obstacles.filter(o => JSON.parse(challenge.obstacle_ids || '[]').includes(o.id)).length > 1);
$: cardCodeLabel = `${cardValue(obs.original_card)}${SUIT_CHAR[obs.suit] || ''}`; $: cardCodeLabel = `${cardValue(obs.original_card)}${SUIT_CHAR[obs.suit] || ''}`;
$: is_face_active = ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1)); $: is_face_active = ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1));
@@ -61,7 +67,7 @@
const cardCode = e.dataTransfer.getData('text/plain'); const cardCode = e.dataTransfer.getData('text/plain');
if (cardCode) { if (cardCode) {
if (isJoker(cardCode)) playJoker(cardCode); if (isJoker(cardCode)) playJoker(cardCode);
else playCard(cardCode); else if (canPlay) playCard(cardCode);
} }
}}> }}>
{#if error} {#if error}
@@ -74,10 +80,10 @@
<Card card={obs.original_card} size="medium" /> <Card card={obs.original_card} size="medium" />
</div> </div>
{#each column_cards as pc, i (i)} {#each column_cards as pc, i (i)}
<div class="stack-card {pc.success ? 'is-success' : 'is-failure'}" <div class="stack-card"
class:is-latest={i === column_cards.length - 1} class:is-latest={i === column_cards.length - 1}
use:tooltip={`${pc.player_name} played this — ${pc.success ? 'success' : 'no success'}.`}> use:tooltip={`${pc.player_name} played this — ${pc.success ? 'success' : 'no success'}.`}>
<Card card={pc.card} size="medium" /> <Card card={pc.card} size="medium" success={pc.success} />
</div> </div>
{/each} {/each}
</div> </div>
@@ -86,9 +92,10 @@
<p class="desc">{obs.description}</p> <p class="desc">{obs.description}</p>
</div> </div>
<div class="obstacle-stats">
<!-- Value Display --> <!-- Value Display -->
<div class="obstacle-value-display text-center" use:tooltip={VALUE_HINT}> <div class="obstacle-value-display text-center" use:tooltip={VALUE_HINT}>
<span class="val-label">Current Difficulty</span> <span class="val-label">Difficulty</span>
<span class="val-number"> <span class="val-number">
{#if is_face_active} {#if is_face_active}
{challengedRank !== null ? `${challengedRank} (Rat's Rank)` : "Challenged Rat's Rank"} {challengedRank !== null ? `${challengedRank} (Rat's Rank)` : "Challenged Rat's Rank"}
@@ -104,6 +111,8 @@
<span class="val-number">{success_count} / {state.players.length}</span> <span class="val-number">{success_count} / {state.players.length}</span>
</div> </div>
</div>
{#if is_completed && state.player.role === 'deep'} {#if is_completed && state.player.role === 'deep'}
<div class="clear-obstacle-row text-center"> <div class="clear-obstacle-row text-center">
<button class="btn btn-success btn-full" on:click={clearObstacle}>Clear Obstacle</button> <button class="btn btn-success btn-full" on:click={clearObstacle}>Clear Obstacle</button>
+37 -4
View File
@@ -2,14 +2,47 @@
// (rendered by AboutModal.svelte). // (rendered by AboutModal.svelte).
// //
// Maintenance (see AGENTS.md "Versioning & changelog"): // Maintenance (see AGENTS.md "Versioning & changelog"):
// - Bump VERSION by one on every commit. // - Accumulate player-facing development changes in one version: null entry.
// - Add a CHANGELOG entry only when a commit changes something players can // - Only an explicitly requested release bumps VERSION and both flake.nix versions.
// see. Skip refactors, tests, and tooling. Keep wording player-facing. // - At release, consolidate the batch into titled groups and add its version/date.
export const VERSION = 26; export const VERSION = 26;
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }. // Newest first. In testing: { version: null, changes: [string, ...] }.
// Release: { version, date: 'YYYY-MM-DD', groups: [{ title, changes: [...] }] }.
// Historical flat changes arrays are also supported. Omit the testing entry when empty.
export const CHANGELOG = [ export const CHANGELOG = [
{
version: 26,
date: '2026-09-04',
groups: [
{ title: 'Themes & table layout', changes: [
'Refreshed both themes across the game, full rulebook, and TL;DR rules with nautical chart backgrounds, woven solid panels, and richer sea-and-brass colors.',
'The Event Log and My Notes use the regular panel surface. Log controls stay the same size when opened or closed, and Dev Mode changes no longer clutter the log.',
'Shorter labels, aligned Difficulty and Successes boxes, and a connected Crew Objectives panel make the table easier to scan. Removed the deck count.',
'Played cards show a check or cross in the corner so results stay clear in stacked cards.',
] },
{ title: 'Notes, characters & rules', changes: [
'Keep private notes alongside the Event Log. Notes save automatically between sessions and stay intact when the game rewinds or you create a new Pi-Rat.',
'Rewrote character suggestions with scrappy pirate looks, distinctive smells, first words, and crew quirks that fit Yeld.',
'Added a printable TL;DR rules page, linked from the full rulebook.',
'Gats require descriptions, including older Gats. Read them on character sheets or hover over Gat icons in the roster. Descriptions follow Gat Taxes and clear for new recruits.',
] },
{ title: 'Crew & voting', changes: [
'The roster keeps a consistent player order and shows who is online, who is Captain, and who has a Gat.',
'Personal objectives use a group vote from the character sheet. A majority approves; the Captain breaks tied votes.',
'Change your rank-up vote until everyone has voted. Roster icons show voting progress and your chosen crewmate. The result advances play automatically and stays visible through upkeep and next-scene setup.',
] },
{ title: 'Challenges, cards & upkeep', changes: [
'Completed duels stay visible with the winner, both cards, and the defense result. Dismiss each result for yourself; your choice survives a reload.',
'Color-match replacement cards arrive after the Deep resolves a Challenge. Each Obstacle accepts one card per Challenge, and each assistant can contribute one card.',
'Assistance requires multiple Obstacles. Gat and Name Tax takeovers still work against a single Obstacle.',
'Duel instructions clarify that only the defender draws a replacement for matching the Obstacles color, including on failure.',
'Hand sizes compare ranks across the whole crew, including players acting as the Deep. Discards return to the deck at scene setup; only the previous Deep can refresh their hand during upkeep, once per scene.',
'Obstacles and their card columns are discarded after one success per player. Unfinished Obstacles carry over between scenes.',
] },
],
},
{ {
version: 25, version: 25,
date: '2026-07-10', date: '2026-07-10',
File diff suppressed because it is too large Load Diff
+31 -30
View File
@@ -16,7 +16,7 @@
import RecruitPhase from '../components/RecruitPhase.svelte'; import RecruitPhase from '../components/RecruitPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.svelte'; import GameOverPhase from '../components/GameOverPhase.svelte';
import CrewSidebar from '../components/CrewSidebar.svelte'; import CrewSidebar from '../components/CrewSidebar.svelte';
import EventLog from '../components/EventLog.svelte'; import GameNotebook from '../components/GameNotebook.svelte';
import NameModal from '../components/NameModal.svelte'; import NameModal from '../components/NameModal.svelte';
import GatModal from '../components/GatModal.svelte'; import GatModal from '../components/GatModal.svelte';
import RankBonusModal from '../components/RankBonusModal.svelte'; import RankBonusModal from '../components/RankBonusModal.svelte';
@@ -26,6 +26,8 @@
let playerId = params.pid; let playerId = params.pid;
let state = null; let state = null;
let onlinePlayerIds = null;
$: rosterState = state ? { ...state, onlinePlayerIds } : null;
let error = ''; let error = '';
let intervalId; let intervalId;
let ws = null; let ws = null;
@@ -33,9 +35,7 @@
let reconnectDelay = 1000; let reconnectDelay = 1000;
let destroyed = false; let destroyed = false;
let staleSession = false; let staleSession = false;
// Non-scene phases use the same pinned Event Log column and fixed corner let panelOpen = true;
// toggle that ScenePhase owns for the main play screen.
let phaseLogOpen = true;
function discardStaleSession(message) { function discardStaleSession(message) {
staleSession = true; staleSession = true;
@@ -71,13 +71,18 @@
// each ping triggers a refetch of the state blob. // each ping triggers a refetch of the state blob.
function connectSocket() { function connectSocket() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/api/game/${gameId}/ws`); ws = new WebSocket(`${proto}://${location.host}/api/game/${gameId}/ws?player_id=${encodeURIComponent(playerId)}`);
ws.onopen = () => { ws.onopen = () => {
reconnectDelay = 1000; reconnectDelay = 1000;
fetchState(); // catch up on anything missed while disconnected fetchState(); // catch up on anything missed while disconnected
}; };
ws.onmessage = () => fetchState(); ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'presence') onlinePlayerIds = message.player_ids;
else if (message.type === 'state_changed') fetchState();
};
ws.onclose = () => { ws.onclose = () => {
onlinePlayerIds = null;
if (destroyed || staleSession) return; if (destroyed || staleSession) return;
reconnectTimer = setTimeout(connectSocket, reconnectDelay); reconnectTimer = setTimeout(connectSocket, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 10000); reconnectDelay = Math.min(reconnectDelay * 2, 10000);
@@ -106,18 +111,18 @@
async function skipCharacterCreation() { async function skipCharacterCreation() {
// Suggested values for everyone's free-text fields; the backend only // Suggested values for everyone's free-text fields; the backend only
// applies them to fields players haven't filled in themselves. // applies them to fields players haven't filled in themselves.
try {
const fills = {}; const fills = {};
for (const p of state.players) { for (const p of state.players) {
fills[p.id] = { fills[p.id] = {
avatar_look: getSuggestion('look'), avatar_look: await getSuggestion('look'),
avatar_smell: getSuggestion('smell'), avatar_smell: await getSuggestion('smell'),
first_words: getSuggestion('first_words'), first_words: await getSuggestion('first_words'),
good_at_math: getSuggestion('good_at_math'), good_at_math: await getSuggestion('good_at_math'),
like: getSuggestion('like'), like: await getSuggestion('like'),
hate: getSuggestion('hate'), hate: await getSuggestion('hate'),
}; };
} }
try {
await apiRequest(`/game/${gameId}/player/${playerId}/skip-character-creation`, 'POST', { await apiRequest(`/game/${gameId}/player/${playerId}/skip-character-creation`, 'POST', {
fills: JSON.stringify(fills) fills: JSON.stringify(fills)
}); });
@@ -223,12 +228,21 @@
{#if state} {#if state}
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}"> <div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
<div class={state.game.phase === 'scene' ? 'scene-view-layout' : 'phase-view-layout'} class:log-collapsed={!panelOpen}>
{#if state.game.phase === 'scene'} {#if state.game.phase === 'scene'}
<ScenePhase {state} /> <ScenePhase state={rosterState} />
{:else} {:else}
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}> <CrewSidebar state={rosterState} />
<CrewSidebar {state} />
<main class="phase-main-column"> <main class="phase-main-column">
{#if ['scene_setup', 'recruit_creation'].includes(state.game.phase) && (state.game.last_rank_up_player_id || state.game.current_scene_number > 1)}
<div class="notice-banner" role="status">
{#if state.game.last_rank_up_player_id}
🏆 {displayName(state.players.find(p => p.id === state.game.last_rank_up_player_id))} won the rank-up vote!
{:else}
Voting complete. No one ranked up this time.
{/if}
</div>
{/if}
{#if state.game.phase === 'lobby'} {#if state.game.phase === 'lobby'}
<LobbyPhase {state} /> <LobbyPhase {state} />
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'} {:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
@@ -245,24 +259,11 @@
<div class="card p-4">Unknown phase: {state.game.phase}</div> <div class="card p-4">Unknown phase: {state.game.phase}</div>
{/if} {/if}
</main> </main>
{#if phaseLogOpen}
<div class="log-column">
<EventLog {state} inline on:collapse={() => (phaseLogOpen = false)} />
</div>
{/if} {/if}
<GameNotebook {state} bind:open={panelOpen} />
</div> </div>
{/if}
</div> </div>
{#if state.game.phase !== 'scene'}
<button
class="log-reopen-btn"
title={phaseLogOpen ? 'Hide the event log' : 'Show the event log'}
on:click={() => (phaseLogOpen = !phaseLogOpen)}
>
{phaseLogOpen ? '✕ Close Log' : '📜 Event Log'}
</button>
{/if}
<NameModal {state} /> <NameModal {state} />
<GatModal {state} /> <GatModal {state} />
<RankBonusModal {state} /> <RankBonusModal {state} />
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const asModule = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`;
const api = asModule(await readFile(new URL('../src/lib/api.js', import.meta.url), 'utf8'));
const source = (await readFile(new URL('../src/lib/suggestions.js', import.meta.url), 'utf8'))
.replace("'./api'", JSON.stringify(api));
test('suggestions retry failed requests, share the pool, and respect exclusions', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async url => {
calls++;
if (calls === 1) throw new Error('offline');
assert.equal(url, '/api/suggestions');
return { ok: true, json: async () => ({ look: ['Bandana', 'Eyepatch'], smell: ['Tar'] }) };
};
try {
const { getSuggestion } = await import(asModule(source));
await assert.rejects(getSuggestion('look'), /offline/);
assert.deepEqual(await Promise.all([
getSuggestion('look', [' bandana ']), getSuggestion('smell')
]), ['Eyepatch', 'Tar']);
assert.equal(calls, 2);
assert.equal(await getSuggestion('unknown'), '');
assert.ok(['Bandana', 'Eyepatch'].includes(await getSuggestion('look', ['Bandana', 'Eyepatch'])));
} finally {
globalThis.fetch = originalFetch;
}
});
+1 -1
View File
@@ -29,7 +29,7 @@ pirats = "pirats.main:main"
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
pirats = ["static/**/*", "rules.html", "migrations/**/*"] pirats = ["static/**/*", "rules.html", "rules-tldr.html", "migrations/**/*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = ["src"]
+4 -12
View File
@@ -92,13 +92,10 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player], capt
- Middle Rank Pi-Rat(s): max 3 cards - Middle Rank Pi-Rat(s): max 3 cards
- Lowest Rank Pi-Rat(s): max 2 cards - Lowest Rank Pi-Rat(s): max 2 cards
If everyone shares a Rank, they are all 'highest' and get 4 cards. If everyone shares a Rank, they are all 'highest' and get 4 cards.
Pi-Rats are ranked against the Pi-Rats in the scene; Deep players (whose hand size Compare the crew's Pi-Rats regardless of who currently plays the Deep.
only matters for the between-scenes redraw) are ranked against all players. Pending recruits do not yet have a Pi-Rat to compare.
""" """
if player.role == "deep": pool = [p for p in players_in_scene if not p.needs_reroll]
pool = players_in_scene
else:
pool = [p for p in players_in_scene if p.role != "deep"]
if not pool: if not pool:
pool = [player] pool = [player]
@@ -208,18 +205,13 @@ def reshuffle_discard_pile(db: Session, game: Game):
db.commit() db.commit()
def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -> List[str]: def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -> List[str]:
"""Draws count cards from the deck for a player, reshuffling if necessary.""" """Draw from the remaining deck; discards return only at scene setup."""
deck = get_game_deck(game) deck = get_game_deck(game)
hand = get_player_hand(player) hand = get_player_hand(player)
drawn = [] drawn = []
for _ in range(count): for _ in range(count):
if not deck: if not deck:
# Reshuffle discard pile
reshuffle_discard_pile(db, game)
deck = get_game_deck(game)
if not deck:
# If still empty (all 54 cards are in hands or active), we can't draw
break break
card = deck.pop(0) card = deck.pop(0)
hand.append(card) hand.append(card)
+41 -11
View File
@@ -120,6 +120,22 @@ def play_challenge_card(
if challenge.tax_state == "requested": if challenge.tax_state == "requested":
return False, "A Gat/Name Tax is pending. Wait for an answer before playing cards.", {} return False, "A Gat/Name Tax is pending. Wait for an answer before playing cards.", {}
if player.role != "pirat" or player.is_dead or player.needs_reroll:
return False, "Only participating Pi-Rats can play cards.", {}
active_ids = {o.id for o in game.obstacles} & set(json.loads(challenge.obstacle_ids))
if player.id != challenge.acting_player_id and len(active_ids) < 2:
return False, "Assistance requires multiple Obstacles in the current Challenge.", {}
plays = json.loads(challenge.plays)
if any(p["obstacle_id"] == obstacle_id for p in plays):
return False, "A card has already been played against this Obstacle in this Challenge.", {}
if player.id != challenge.acting_player_id:
if any(p["player_id"] == player.id for p in plays):
return False, "You may assist with one card per Challenge.", {}
if any(c.status == "open" and c.id != challenge.id
and player.id in (c.target_player_id, c.acting_player_id) for c in game.challenges):
return False, "Resolve your own Challenge before assisting another Pi-Rat.", {}
# An Obstacle beaten as many times as there are players is spent # An Obstacle beaten as many times as there are players is spent
played_list = json.loads(obstacle.played_cards) played_list = json.loads(obstacle.played_cards)
current_successes = sum(1 for c in played_list if c.get("success") is True) current_successes = sum(1 for c in played_list if c.get("success") is True)
@@ -139,34 +155,36 @@ def play_challenge_card(
acting = get_player(db, challenge.acting_player_id) or player acting = get_player(db, challenge.acting_player_id) or player
result = resolve_card_against_obstacle(db, game, player, obstacle, card_code, acting_rank=acting.rank) result = resolve_card_against_obstacle(db, game, player, obstacle, card_code, acting_rank=acting.rank)
# Record the play on the Challenge # Remember draw eligibility even if this play discards the completed Obstacle.
plays = json.loads(challenge.plays) draw_match = player.id == challenge.acting_player_id and cards.match_suit_color(card_code, obstacle.original_card)
plays.append({ plays.append({
"obstacle_id": obstacle_id, "obstacle_id": obstacle_id,
"card": card_code, "card": card_code,
"player_id": player.id, "player_id": player.id,
"player_name": player.name, "player_name": player.name,
"success": result["success"], "success": result["success"],
"details": result["details"] "details": result["details"],
"draw_match": draw_match,
}) })
challenge.plays = json.dumps(plays) challenge.plays = json.dumps(plays)
db.add(challenge) db.add(challenge)
db.commit() db.commit()
# Step 6: Draw back on suit-color match — only the attempting Pi-Rat draws. # Step 6 follows resolution, so replacements cannot be played in this Challenge.
drew_card = None drew_card = None
if player.id == challenge.acting_player_id: if draw_match:
if cards.match_suit_color(card_code, obstacle.original_card): result["details"] += " (Draw a replacement when the Challenge resolves.)"
drawn = draw_cards_for_player(db, game, player, 1) elif player.id != challenge.acting_player_id:
if drawn:
drew_card = drawn[0]
result["details"] += " (Drew a card back due to matching suit colors!)"
else:
result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)" result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)"
role_note = "" if player.id == challenge.acting_player_id else " (assist)" role_note = "" if player.id == challenge.acting_player_id else " (assist)"
add_game_event(db, game.id, f"{player.name} played {cards.parse_card(card_code)['display']} on '{obstacle.title}'{role_note}. {result['details']}", kind="card") add_game_event(db, game.id, f"{player.name} played {cards.parse_card(card_code)['display']} on '{obstacle.title}'{role_note}. {result['details']}", kind="card")
if obstacle.success_count >= len(game.players):
add_game_event(db, game.id, f"'{obstacle.title}' is complete and discarded with its column.", kind="obstacle")
db.delete(obstacle)
db.commit()
result["drew_card"] = drew_card result["drew_card"] = drew_card
result["is_joker"] = False result["is_joker"] = False
return True, "Card played successfully.", result return True, "Card played successfully.", result
@@ -202,6 +220,12 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
outcome = "succeeded" if success else "failed" outcome = "succeeded" if success else "failed"
add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!", kind="challenge") add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!", kind="challenge")
# Older saved plays already received their immediate draws; missing flags do not redraw.
draw_count = sum(1 for p in plays if p.get("draw_match") and p["player_id"] == acting.id)
if draw_count:
drawn = draw_cards_for_player(db, game, acting, draw_count)
add_game_event(db, game.id, f"{acting.name} draws {len(drawn)} replacement card(s) for matching Obstacle colors.", kind="card")
# Settle a refused Gat/Name Tax: keep the prize on success, return it on failure. # Settle a refused Gat/Name Tax: keep the prize on success, return it on failure.
if challenge.tax_state == "refused" and challenge.tax_target_id: if challenge.tax_state == "refused" and challenge.tax_target_id:
refuser = get_player(db, challenge.tax_target_id) refuser = get_player(db, challenge.tax_target_id)
@@ -215,7 +239,9 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
refuser.completed_personal_1 = True refuser.completed_personal_1 = True
# The Gat (and its description) returns to its owner. # The Gat (and its description) returns to its owner.
refuser.gat_description = acting.gat_description refuser.gat_description = acting.gat_description
refuser.needs_gat_description = not bool(refuser.gat_description.strip())
acting.gat_description = "" acting.gat_description = ""
acting.needs_gat_description = False
else: else:
# Return the stolen name string; the failed thief reverts to # Return the stolen name string; the failed thief reverts to
# their smell-based recruit identity. # their smell-based recruit identity.
@@ -271,6 +297,8 @@ def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id
return False, "Only the challenged Pi-Rat can call a Gat/Name Tax." return False, "Only the challenged Pi-Rat can call a Gat/Name Tax."
if challenge.tax_state is not None: if challenge.tax_state is not None:
return False, "A Tax has already been called on this Challenge." return False, "A Tax has already been called on this Challenge."
if json.loads(challenge.plays):
return False, "Call a Tax before any cards are played."
if requester.tax_banned: if requester.tax_banned:
return False, "You failed a refused Tax this scene — no more Gat/Name Taxes for you!" return False, "You failed a refused Tax this scene — no more Gat/Name Taxes for you!"
if target.id == requester.id or target.role == "deep" or target.is_dead or target.needs_reroll: if target.id == requester.id or target.role == "deep" or target.is_dead or target.needs_reroll:
@@ -349,7 +377,9 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
responder.completed_personal_1 = False responder.completed_personal_1 = False
# The Gat changes hands along with its description (like a stolen Name). # The Gat changes hands along with its description (like a stolen Name).
requester.gat_description = responder.gat_description requester.gat_description = responder.gat_description
requester.needs_gat_description = not bool(requester.gat_description.strip())
responder.gat_description = "" responder.gat_description = ""
responder.needs_gat_description = False
event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!" event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!"
else: else:
# The Name itself is stolen: the requester literally takes the responder's # The Name itself is stolen: the requester literally takes the responder's
+5 -1
View File
@@ -502,7 +502,7 @@ def skip_character_creation(db: Session, game: Game, fills: dict):
creation for ALL players and advances to scene setup. `fills` maps creation for ALL players and advances to scene setup. `fills` maps
player_id -> suggested values for the free-text fields (avatar_look, player_id -> suggested values for the free-text fields (avatar_look,
avatar_smell, first_words, good_at_math, like, hate), supplied by the avatar_smell, first_words, good_at_math, like, hate), supplied by the
admin's client from the frontend suggestion pools. Techniques come from admin's client from the backend suggestion API. Techniques come from
the backend pool. Already-filled fields are left untouched. the backend pool. Already-filled fields are left untouched.
""" """
from .cards import TECHNIQUE_SUGGESTIONS from .cards import TECHNIQUE_SUGGESTIONS
@@ -584,6 +584,8 @@ def choose_reroll(db: Session, player_id: str):
db.add(player) db.add(player)
db.commit() db.commit()
add_game_event(db, player.game_id, f"{player.name} will return as a fresh recruit! The crew will help create them after upkeep.", kind="join") add_game_event(db, player.game_id, f"{player.name} will return as a fresh recruit! The crew will help create them after upkeep.", kind="join")
from .crud_upkeep import recheck_phase_completion
recheck_phase_completion(db, get_game(db, player.game_id))
return True, "Queued for recruit creation." return True, "Queued for recruit creation."
def techniques_in_use(game: Game, exclude_player_id: str = None) -> set: def techniques_in_use(game: Game, exclude_player_id: str = None) -> set:
@@ -627,6 +629,8 @@ def activate_recruit(db: Session, game: Game, player):
player.is_ghost = False player.is_ghost = False
player.tax_banned = False player.tax_banned = False
player.needs_name = False player.needs_name = False
player.needs_gat_description = False
player.gat_description = ""
player.needs_rank_3_bonus = False player.needs_rank_3_bonus = False
player.completed_personal_1 = False player.completed_personal_1 = False
player.completed_personal_2 = False player.completed_personal_2 = False
+82 -1
View File
@@ -160,6 +160,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
db.add(p) db.add(p)
game.deck_cards = json.dumps(deck) game.deck_cards = json.dumps(deck)
game.last_rank_up_player_id = None
game.phase = "scene" game.phase = "scene"
db.add(game) db.add(game)
db.commit() db.commit()
@@ -404,7 +405,8 @@ def grant_story_bonus_rank(db: Session, game: Game, granter: Player, target):
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str): def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
obstacle = db.get(Obstacle, obstacle_id) obstacle = db.get(Obstacle, obstacle_id)
if obstacle: game = get_game(db, game_id)
if obstacle and game and obstacle.game_id == game_id and obstacle.success_count >= len(game.players):
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.", kind="obstacle") add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.", kind="obstacle")
db.delete(obstacle) db.delete(obstacle)
db.commit() db.commit()
@@ -419,3 +421,82 @@ def finish_game(db: Session, game_id: str):
db.commit() db.commit()
add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉", kind="victory") 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) logger.info("Game %s ended after %s scene(s)", game_id, game.current_scene_number)
PERSONAL_OBJECTIVES = ("personal_1", "personal_2", "personal_3")
def _can_award_objective(target, obj_type):
return (target and target.role == "pirat" and not target.needs_reroll
and not target.is_dead and obj_type in PERSONAL_OBJECTIVES
and not getattr(target, "completed_" + obj_type)
and all(getattr(target, "completed_" + previous)
for previous in PERSONAL_OBJECTIVES[:PERSONAL_OBJECTIVES.index(obj_type)]))
def propose_personal_objective(db, game_id, proposer_id, target_id, obj_type):
import uuid
game = get_game(db, game_id)
proposer = get_player(db, proposer_id)
target = get_player(db, target_id)
if not game or game.phase != "scene":
return False, "Propose objectives during a scene."
if not proposer or proposer.game_id != game_id or proposer.needs_reroll:
return False, "Only this game's participating players may propose objectives."
if not target or target.game_id != game_id or not _can_award_objective(target, obj_type):
return False, "Propose the next unfinished objective for a living Pi-Rat in this scene."
proposals = json.loads(game.objective_votes)
if any(p["target_id"] == target_id for p in proposals):
return False, "This Pi-Rat already has an objective vote pending."
proposal = {"id": str(uuid.uuid4()), "target_id": target_id, "type": obj_type, "ballots": {}}
proposals.append(proposal)
game.objective_votes = json.dumps(proposals)
db.add(game)
db.commit()
add_game_event(db, game_id, f"{proposer.name} proposed {target.name}'s {obj_type.replace('_', ' ')} for a group vote.", kind="objective")
return vote_personal_objective(db, game_id, proposer_id, proposal["id"], True)
def vote_personal_objective(db, game_id, voter_id, proposal_id, approve):
game = get_game(db, game_id)
voter = get_player(db, voter_id)
if not game or game.phase != "scene":
return False, "Objective voting is closed."
if not voter or voter.game_id != game_id or voter.needs_reroll:
return False, "Only this game's participating players may vote."
proposals = json.loads(game.objective_votes)
proposal = next((p for p in proposals if p["id"] == proposal_id), None)
if not proposal:
return False, "That objective vote is no longer pending."
target = get_player(db, proposal["target_id"])
if not _can_award_objective(target, proposal["type"]):
proposals.remove(proposal)
game.objective_votes = json.dumps(proposals)
db.add(game)
db.commit()
return False, "That objective is no longer eligible."
eligible = {p.id for p in game.players if not p.needs_reroll}
ballots = {pid: value for pid, value in proposal["ballots"].items() if pid in eligible}
ballots[voter_id] = approve
proposal["ballots"] = ballots
yes = sum(ballots.values())
no = len(ballots) - yes
outcome = None
if yes > len(eligible) / 2:
outcome = True
elif no > len(eligible) / 2:
outcome = False
elif len(ballots) == len(eligible):
# The Captain breaks a tie; without a Captain, the objective is not awarded.
outcome = ballots.get(game.captain_player_id, False)
if outcome is not None:
proposals.remove(proposal)
game.objective_votes = json.dumps(proposals)
db.add(game)
db.commit()
if outcome is True:
toggle_objective(db, game_id, target.id, proposal["type"], True)
if outcome is not None:
result = "approved" if outcome else "did not approve"
add_game_event(db, game_id, f"The group {result} {target.name}'s {proposal['type'].replace('_', ' ')} ({yes} yes, {no} no).", kind="objective")
return True, "Vote recorded."
+22 -13
View File
@@ -23,8 +23,11 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
Every Player (including last scene's Deep Players) nominates one *other* Pi-Rat Every Player (including last scene's Deep Players) nominates one *other* Pi-Rat
Player to Rank up. Deep Players from the previous scene cannot be nominated, Player to Rank up. Deep Players from the previous scene cannot be nominated,
nor can dead or awaiting-recruit Pi-Rats. A voter whose only options are nor can dead or awaiting-recruit Pi-Rats. A voter whose only options are
ineligible skips voting (the frontend lets them ready up without a vote). ineligible skips voting automatically.
""" """
game = get_game(db, game_id)
if not game or game.phase != "between_scenes":
return False, "Voting is closed."
voter = get_player(db, voter_id) voter = get_player(db, voter_id)
nominee = get_player(db, nominated_id) nominee = get_player(db, nominated_id)
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id: if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
@@ -38,7 +41,6 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
).all() ).all()
for v in existing: for v in existing:
db.delete(v) db.delete(v)
db.commit()
vote = Vote( vote = Vote(
game_id=game_id, game_id=game_id,
@@ -47,6 +49,7 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
) )
db.add(vote) db.add(vote)
db.commit() db.commit()
maybe_transition_to_deep_upkeep(db, game)
return True, "Vote submitted." return True, "Vote submitted."
def unchallenged_pirats(game: Game): def unchallenged_pirats(game: Game):
@@ -75,6 +78,7 @@ def end_scene_and_transition(db: Session, game_id: str):
f"(still waiting on: {names}). Clear the Obstacle List to end early." f"(still waiting on: {names}). Clear the Obstacle List to end early."
) )
game.objective_votes = "[]"
game.phase = "between_scenes" game.phase = "between_scenes"
# Clear ready flags for players # Clear ready flags for players
@@ -85,6 +89,7 @@ def end_scene_and_transition(db: Session, game_id: str):
db.commit() db.commit()
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene") add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
maybe_transition_to_deep_upkeep(db, game)
return True, "Scene ended." return True, "Scene ended."
def process_between_scenes_votes(db: Session, game: Game): def process_between_scenes_votes(db: Session, game: Game):
@@ -128,7 +133,6 @@ def begin_next_scene_setup(db: Session, game: Game):
"""Advances to the next scene's setup: bump the scene counter and clear roles.""" """Advances to the next scene's setup: bump the scene counter and clear roles."""
game.current_scene_number += 1 game.current_scene_number += 1
game.phase = "scene_setup" game.phase = "scene_setup"
game.last_rank_up_player_id = None # Stale once we leave the post-voting screen
for p in game.players: for p in game.players:
p.role = None p.role = None
p.is_ready = False p.is_ready = False
@@ -147,9 +151,17 @@ def advance_after_upkeep(db: Session, game: Game):
begin_next_scene_setup(db, game) begin_next_scene_setup(db, game)
def maybe_transition_to_deep_upkeep(db: Session, game: Game): def maybe_transition_to_deep_upkeep(db: Session, game: Game):
"""In between_scenes, move on once every player has readied up. Safe to call """Advance after every eligible voter has voted and death choices are settled."""
after a roster change to unblock the phase.""" if game.phase != "between_scenes" or not game.players:
if game.players and all(p.is_ready for p in game.players): return
if any(p.is_dead and not p.is_ghost and not p.needs_reroll for p in game.players):
return
votes = db.exec(select(Vote).where(Vote.game_id == game.id)).all()
ballots = {v.voter_player_id: v.nominated_player_id for v in votes}
for player in game.players:
nominees = eligible_vote_nominees(game, player)
if nominees and ballots.get(player.id) not in {n.id for n in nominees}:
return
transition_to_deep_upkeep(db, game.id) transition_to_deep_upkeep(db, game.id)
def transition_to_deep_upkeep(db: Session, game_id: str): def transition_to_deep_upkeep(db: Session, game_id: str):
@@ -186,17 +198,14 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
if not game: if not game:
return return
# 1. Discard cards if game.phase != "deep_upkeep" or player.previous_role != "deep" or player.is_ready:
hand = get_player_hand(player) return
deck = get_game_deck(game)
# Discards stay out of the deck until the next scene's shuffle.
hand = get_player_hand(player)
for card in discard_cards: for card in discard_cards:
if card in hand: if card in hand:
hand.remove(card) hand.remove(card)
deck.append(card)
random.shuffle(deck)
set_game_deck(game, deck)
set_player_hand(player, hand) set_player_hand(player, hand)
db.add(game) db.add(game)
db.add(player) db.add(player)
+46 -2
View File
@@ -220,8 +220,18 @@ async def broadcast_state_changes(request, call_next):
app.add_middleware(LimitRequestBodyMiddleware, max_bytes=max_body_bytes()) app.add_middleware(LimitRequestBodyMiddleware, max_bytes=max_body_bytes())
@app.websocket("/api/game/{game_id}/ws") @app.websocket("/api/game/{game_id}/ws")
async def game_websocket(websocket: WebSocket, game_id: str): async def game_websocket(
await manager.connect(game_id, websocket) websocket: WebSocket, game_id: str, player_id: Optional[str] = None,
db: Session = Depends(get_session),
):
if player_id is not None:
player = crud.get_player(db, player_id)
if not player or player.game_id != game_id:
await websocket.close(code=1008)
return
# Release the read transaction before waiting on a long-lived socket.
db.close()
await manager.connect(game_id, websocket, player_id)
try: try:
while True: while True:
# Clients don't send anything meaningful; this just detects disconnects. # Clients don't send anything meaningful; this just detects disconnects.
@@ -230,6 +240,8 @@ async def game_websocket(websocket: WebSocket, game_id: str):
pass pass
finally: finally:
manager.disconnect(game_id, websocket) manager.disconnect(game_id, websocket)
if player_id is not None:
await manager.broadcast_presence(game_id)
# --- API Core Route Handlers --- # --- API Core Route Handlers ---
@@ -277,6 +289,34 @@ def leave_game_route(game_id: str, player_id: str, db: Session = Depends(get_ses
return JSONResponse({"error": msg}, status_code=400) return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"} return {"status": "ok"}
def _notes_player(db, game_id, player_id):
player = crud.get_player(db, player_id)
if not player or player.game_id != game_id:
raise HTTPException(status_code=404, detail="Game or Player not found")
@api.get("/game/{game_id}/player/{player_id}/notes")
def get_notes(game_id: str, player_id: str, db: Session = Depends(get_session)):
from .models import PlayerNotebook
_notes_player(db, game_id, player_id)
notebook = db.get(PlayerNotebook, player_id)
return {"text": notebook.text if notebook else ""}
@api.put("/game/{game_id}/player/{player_id}/notes")
def save_notes(game_id: str, player_id: str, text: str = Form(default="", max_length=50000),
db: Session = Depends(get_session)):
from .models import PlayerNotebook
_notes_player(db, game_id, player_id)
notebook = db.get(PlayerNotebook, player_id)
if notebook is None:
notebook = PlayerNotebook(player_id=player_id, game_id=game_id)
notebook.text = text
db.add(notebook)
db.commit()
return {"text": text}
@api.get("/game/{game_id}/player/{player_id}/state") @api.get("/game/{game_id}/player/{player_id}/state")
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)): def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id) game = crud.get_game(db, game_id)
@@ -346,6 +386,10 @@ app.include_router(api, prefix="/api")
def rules_page(): def rules_page():
return FileResponse(BASE_DIR / "rules.html", media_type="text/html") return FileResponse(BASE_DIR / "rules.html", media_type="text/html")
@app.get("/rules/tldr", include_in_schema=False)
def rules_tldr_page():
return FileResponse(BASE_DIR / "rules-tldr.html", media_type="text/html")
# Mount SPA # Mount SPA
static_dir = BASE_DIR / "static" static_dir = BASE_DIR / "static"
if os.path.exists(static_dir): if os.path.exists(static_dir):
+1 -1
View File
@@ -81,7 +81,7 @@ def _estimate_game_bytes(game: Game) -> int:
any leftover rollback checkpoints' state_json.""" any leftover rollback checkpoints' state_json."""
total = len(json.dumps(game.model_dump(), default=str)) total = len(json.dumps(game.model_dump(), default=str))
for rows in (game.players, game.obstacles, game.votes, game.events, for rows in (game.players, game.obstacles, game.votes, game.events,
game.challenges, game.checkpoints): game.challenges, game.checkpoints, game.notebooks):
for row in rows: for row in rows:
total += len(json.dumps(row.model_dump(), default=str)) total += len(json.dumps(row.model_dump(), default=str))
return total return total
@@ -0,0 +1,40 @@
"""add private player notebooks
Revision ID: 2fb61a37117c
Revises: 9c04e3bec3b5
Create Date: 2026-09-04 20:07:35.320803
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '2fb61a37117c'
down_revision = '9c04e3bec3b5'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('playernotebook',
sa.Column('player_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('game_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('text', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['game_id'], ['game.id'], ),
sa.PrimaryKeyConstraint('player_id')
)
with op.batch_alter_table('playernotebook', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_playernotebook_game_id'), ['game_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playernotebook', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_playernotebook_game_id'))
op.drop_table('playernotebook')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""add personal objective group votes
Revision ID: 9c04e3bec3b5
Revises: 6ea41638edfc
Create Date: 2026-09-04 19:24:05.292837
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '9c04e3bec3b5'
down_revision = '6ea41638edfc'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.add_column(sa.Column('objective_votes', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default="[]"))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.drop_column('objective_votes')
# ### end Alembic commands ###
+10
View File
@@ -22,6 +22,7 @@ class Game(SQLModel, table=True):
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...] deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
rollback_timeline_version: int = Field(default=0) # Bumped whenever a rollback truncates events; lets the frontend reset its accumulated log. NEVER snapshotted (see crud_rollback.SNAPSHOT_EXCLUDE) rollback_timeline_version: int = Field(default=0) # Bumped whenever a rollback truncates events; lets the frontend reset its accumulated log. NEVER snapshotted (see crud_rollback.SNAPSHOT_EXCLUDE)
rollback_head_checkpoint_id: Optional[int] = Field(default=None) # When set, the game is viewing a rolled-back state at this checkpoint; events after it are the greyed, still-undoable future. None = live at the latest. NEVER snapshotted. rollback_head_checkpoint_id: Optional[int] = Field(default=None) # When set, the game is viewing a rolled-back state at this checkpoint; events after it are the greyed, still-undoable future. None = live at the latest. NEVER snapshotted.
objective_votes: str = Field(default="[]") # Pending personal-objective proposals and ballots
completed_crew_1: bool = Field(default=False) # Steal a Ship completed_crew_1: bool = Field(default=False) # Steal a Ship
completed_crew_2: bool = Field(default=False) # Choose a Captain completed_crew_2: bool = Field(default=False) # Choose a Captain
completed_crew_3: bool = Field(default=False) # Commit Piracy completed_crew_3: bool = Field(default=False) # Commit Piracy
@@ -34,6 +35,7 @@ class Game(SQLModel, table=True):
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True) votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True) events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True)
challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True) challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True)
notebooks: List["PlayerNotebook"] = Relationship(back_populates="game", cascade_delete=True)
checkpoints: List["Checkpoint"] = Relationship(back_populates="game", cascade_delete=True) checkpoints: List["Checkpoint"] = Relationship(back_populates="game", cascade_delete=True)
class Player(SQLModel, table=True): class Player(SQLModel, table=True):
@@ -167,3 +169,11 @@ class Checkpoint(SQLModel, table=True):
state_json: str = Field(...) # serialized gameplay snapshot (Game minus control cols + Players/Obstacles/Challenges/Votes) state_json: str = Field(...) # serialized gameplay snapshot (Game minus control cols + Players/Obstacles/Challenges/Votes)
game: Optional[Game] = Relationship(back_populates="checkpoints") game: Optional[Game] = Relationship(back_populates="checkpoints")
class PlayerNotebook(SQLModel, table=True):
"""Private player notes, deliberately outside gameplay snapshots and character resets."""
player_id: str = Field(primary_key=True)
game_id: str = Field(foreign_key="game.id", index=True)
text: str = Field(default="")
game: Game = Relationship(back_populates="notebooks")
-1
View File
@@ -30,7 +30,6 @@ def set_dev_mode(
game.dev_mode = enabled game.dev_mode = enabled
db.add(game) db.add(game)
db.commit() db.commit()
crud.add_game_event(db, game.id, f"🛠 Dev Mode turned {'ON' if enabled else 'OFF'} by {player.name}.", kind="info")
return {"status": "ok", "dev_mode": game.dev_mode} return {"status": "ok", "dev_mode": game.dev_mode}
# Teaching mode: an admin volunteers to be the forced Rank-3 Deep for scene 1. # Teaching mode: an admin volunteers to be the forced Rank-3 Deep for scene 1.
+6
View File
@@ -4,10 +4,16 @@ from sqlmodel import Session
from .database import get_session from .database import get_session
from . import crud from . import crud
from .cards import TECHNIQUE_SUGGESTIONS from .cards import TECHNIQUE_SUGGESTIONS
from .suggestions import CHARACTER_SUGGESTIONS
from .validation import sanitize_text, sanitize_name from .validation import sanitize_text, sanitize_name
router = APIRouter() router = APIRouter()
@router.get("/suggestions")
def character_suggestions():
return CHARACTER_SUGGESTIONS
# Secret Pirate Technique name pool (for the frontend's Suggest buttons) # Secret Pirate Technique name pool (for the frontend's Suggest buttons)
@router.get("/suggestions/techniques") @router.get("/suggestions/techniques")
def technique_suggestions(): def technique_suggestions():
+29 -13
View File
@@ -101,17 +101,7 @@ def toggle_objective_route(
current_status = game.completed_crew_3 current_status = game.completed_crew_3
crud.toggle_objective(db, game_id, player_id, type, not current_status) crud.toggle_objective(db, game_id, player_id, type, not current_status)
else: else:
player = crud.get_player(db, player_id) return JSONResponse({"error": "Personal objectives require a group vote."}, status_code=400)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
current_status = False
if type == "personal_1":
current_status = player.completed_personal_1
elif type == "personal_2":
current_status = player.completed_personal_2
elif type == "personal_3":
current_status = player.completed_personal_3
crud.toggle_objective(db, game_id, player_id, type, not current_status)
return {"status": "ok"} return {"status": "ok"}
@@ -140,8 +130,15 @@ def set_gat_description_route(
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
player = crud.get_player(db, player_id) player = crud.get_player(db, player_id)
if player and player.needs_gat_description: if not player or player.game_id != game_id:
player.gat_description = sanitize_text(description) raise HTTPException(status_code=404, detail="Player not found")
if not player.completed_personal_1:
return JSONResponse({"error": "You must have a Gat to describe it."}, status_code=400)
description = sanitize_text(description)
if not description:
return JSONResponse({"error": "Describe your Gat before claiming it."}, status_code=400)
if player.needs_gat_description or not player.gat_description.strip():
player.gat_description = description
player.needs_gat_description = False player.needs_gat_description = False
db.add(player) db.add(player)
db.commit() db.commit()
@@ -180,6 +177,7 @@ def become_ghost_route(
player.needs_reroll = False # in case they queued a re-roll first, ghosthood wins player.needs_reroll = False # in case they queued a re-roll first, ghosthood wins
db.add(player) db.add(player)
db.commit() db.commit()
crud.recheck_phase_completion(db, crud.get_game(db, player.game_id))
return {"status": "ok"} return {"status": "ok"}
# Deep ends the scene # Deep ends the scene
@@ -227,3 +225,21 @@ def set_captain_route(
def finish_game_route(game_id: str, db: Session = Depends(get_session)): def finish_game_route(game_id: str, db: Session = Depends(get_session)):
crud.finish_game(db, game_id) crud.finish_game(db, game_id)
return {"status": "ok"} return {"status": "ok"}
@router.post("/game/{game_id}/player/{player_id}/objective/propose")
def propose_objective_route(game_id: str, player_id: str, target_id: str = Form(...),
type: str = Form(...), db: Session = Depends(get_session)):
ok, msg = crud.propose_personal_objective(db, game_id, player_id, target_id, type)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
@router.post("/game/{game_id}/player/{player_id}/objective/vote")
def vote_objective_route(game_id: str, player_id: str, proposal_id: str = Form(...),
approve: bool = Form(...), db: Session = Depends(get_session)):
ok, msg = crud.vote_personal_objective(db, game_id, player_id, proposal_id, approve)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
-18
View File
@@ -20,24 +20,6 @@ def submit_vote_route(
return JSONResponse({"error": msg}, status_code=400) return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"} return {"status": "ok"}
# Player Ready for Next Scene
@router.post("/game/{game_id}/player/{player_id}/ready-next")
def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_session)):
player = crud.get_player(db, player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
player.is_ready = True
db.add(player)
db.commit()
# Check if all players in the game are ready. If so, transition to deep upkeep!
game = crud.get_game(db, game_id)
if game:
crud.maybe_transition_to_deep_upkeep(db, game)
return {"status": "ok"}
# Confirm Hand Refresh for Deep player # Confirm Hand Refresh for Deep player
@router.post("/game/{game_id}/player/{player_id}/confirm-refresh") @router.post("/game/{game_id}/player/{player_id}/confirm-refresh")
def confirm_deep_refresh_route( def confirm_deep_refresh_route(
+217
View File
@@ -0,0 +1,217 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rats with Gats — TL;DR Rules</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Alegreya+SC:wght@500;700&family=Alegreya+Sans:wght@400;500;700&family=Pirata+One&display=swap" rel="stylesheet">
<script>
// Apply the saved theme before first paint (same localStorage key as the SPA).
document.documentElement.dataset.theme =
localStorage.getItem('pirats-theme') === 'dark' ? 'dark' : 'light';
</script>
<style>
:root {
--bg-dark: #8dbcc4;
--bg-ocean: #c9e4e5;
--gold: #8c6010;
--neon-cyan: #08747d;
--red-suit: #c92a35;
--black-suit: #2c3245;
--glass-bg: #f8f1dd;
--glass-border: rgba(168, 119, 15, 0.35);
--text-primary: #33291a;
--text-muted: #6e604b;
--well-bg: rgba(51, 41, 26, 0.06);
--row-border: rgba(168, 119, 15, 0.2);
--solid-bg: #fffaec;
--shadow-panel: 0 8px 32px rgba(36, 62, 87, 0.2);
/* Match the app's chart backdrop and opaque sailcloth panels. */
--bg: var(--bg-ocean);
--bg-deep: var(--bg-dark);
--chart-line: color-mix(in srgb, var(--neon-cyan) 9%, transparent);
--chart-glow: color-mix(in srgb, #fffaec 48%, transparent);
--weave: color-mix(in srgb, var(--text-primary) 3%, transparent);
--panel-sheen: color-mix(in srgb, var(--gold) 7%, transparent);
--panel-texture:
linear-gradient(135deg, var(--panel-sheen), transparent 48%),
repeating-linear-gradient(0deg, var(--weave) 0 1px, transparent 1px 4px),
repeating-linear-gradient(90deg, var(--weave) 0 1px, transparent 1px 5px);
--panel-highlight: inset 0 1px 0 color-mix(in srgb, var(--solid-bg) 70%, transparent);
--font-display: 'Pirata One', 'Alegreya SC', serif;
--font-heading: 'Alegreya SC', serif;
--font-body: 'Alegreya Sans', sans-serif;
}
:root[data-theme="dark"] {
--chart-line: color-mix(in srgb, var(--neon-cyan) 6%, transparent);
--chart-glow: color-mix(in srgb, var(--gold) 12%, transparent);
--weave: color-mix(in srgb, var(--text-primary) 2%, transparent);
--panel-sheen: color-mix(in srgb, var(--gold) 9%, transparent);
--bg-dark: #09131c;
--bg-ocean: #101f27;
--gold: #d9a23c;
--neon-cyan: #41c9bd;
--red-suit: #e8606e;
--black-suit: #dfe6ef;
--glass-bg: #1c2c32;
--glass-border: rgba(217, 162, 60, 0.25);
--text-primary: #efe7d3;
--text-muted: #a89e8a;
--well-bg: rgba(10, 13, 19, 0.5);
--row-border: rgba(217, 162, 60, 0.12);
--solid-bg: #293c42;
--shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.4);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
background-color: var(--bg-deep);
background-image:
radial-gradient(ellipse at 15% 0%, var(--chart-glow), transparent 55%),
repeating-linear-gradient(30deg, transparent 0 159px, var(--chart-line) 159px 160px),
repeating-linear-gradient(150deg, transparent 0 159px, var(--chart-line) 159px 160px),
repeating-radial-gradient(circle at 85% 15%, transparent 0 119px, var(--chart-line) 119px 120px, transparent 120px 240px),
radial-gradient(ellipse at 50% 20%, var(--bg), var(--bg-deep));
background-attachment: fixed;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
min-height: 100vh;
}
a { color: var(--neon-cyan); }
.glass-panel {
background: var(--panel-texture), var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
box-shadow: var(--panel-highlight), var(--shadow-panel);
}
.sheet { max-width: 1080px; margin: 0 auto; padding: 2rem 1.25rem 5rem; }
header { margin-bottom: 1.25rem; }
h1 { font: 400 2.8rem var(--font-display); color: var(--gold); }
.tagline { margin: .4rem 0 .8rem; }
nav { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; align-items: center; }
button { font: inherit; color: var(--neon-cyan); background: var(--glass-bg); border: 1px solid var(--glass-border); padding: .35rem .7rem; border-radius: 6px; cursor: pointer; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
section { padding: 1rem 1.25rem; }
h2 { font: 700 1.3rem var(--font-heading); color: var(--gold); margin-bottom: .45rem; }
p + p, ul, ol { margin-top: .45rem; }
ul, ol { padding-left: 1.2rem; }
li + li { margin-top: .25rem; }
footer { margin-top: 1rem; font-size: .85rem; }
@media (max-width: 650px) {
.sheet { padding: 1rem .8rem 2rem; }
.grid { grid-template-columns: 1fr; }
h1 { font-size: 2.3rem; }
}
@page { size: auto; margin: 10mm; }
@media print {
:root, :root[data-theme="dark"] { --text-primary: #111; --gold: #111; --neon-cyan: #111; }
body { background: white; font-size: 9pt; line-height: 1.2; min-height: auto; }
.sheet { max-width: none; padding: 0; }
header { margin-bottom: 8pt; }
h1 { font-size: 24pt; }
nav, footer { font-size: 8pt; }
button { display: none; }
.grid { grid-template-columns: 1fr 1fr; gap: 7pt; }
.glass-panel { background: white; box-shadow: none; border: 1px solid #bbb; border-radius: 0; }
section { padding: 7pt; break-inside: avoid; }
h2 { font-size: 12pt; }
a { text-decoration: none; }
}
</style>
</head>
<body>
<main class="sheet">
<header>
<h1>Rats with Gats — TL;DR</h1>
<p class="tagline">Ordinary rats. Math Magic. Extremely questionable piracy. A one-page rules reference.</p>
<nav aria-label="Rulebook navigation">
<a href="/rules">← Full rulebook</a>
<a href="/rules#example">Example of play</a>
<button id="print" type="button">Print summary</button>
<button id="theme-toggle" type="button"></button>
</nav>
</header>
<div class="grid">
<section class="glass-panel">
<h2>1. Muster the crew</h2>
<p><strong>36 players; one deck with both Jokers.</strong> Everyone makes a Pi-Rat: looks, smell, first words, math ability, and what others like/hate about them. Names come later.</p>
<p>Write 3 Secret Pirate Techniques, swap them with other players, then assign your new techniques to J, Q and K.</p>
<p>Random starting ranks: one Rank 3 (first Deep), one Rank 1 (first Pi-Rat), everyone else Rank 2 (choose either role).</p>
<p><strong>Hand limits:</strong> highest rank 4, middle 3, lowest 2; compare the whole crew. Captain gets +1. Draw your starting hand to its limit. Draw immediately when your hand limit increases.</p>
</section>
<section class="glass-panel">
<h2>2. Set a scene</h2>
<p>At least one player is a <strong>Pi-Rat</strong> (take action); at least one is the <strong>Deep</strong> (world, NPCs and trouble). Last scenes Deep must play Pi-Rats next.</p>
<p>Shuffle discards into the deck. Keep unfinished Obstacles and draw until the list has at least <strong>Deep players + 1</strong> cards, plus any accumulated Joker increases.</p>
<p>The Deep frames an urgent problem using the <a href="/rules#obstacles">Obstacle tables</a>. First scene: steal the ship youre on!</p>
<p><strong>Suit themes:</strong> ♣ shootin &amp; stabbin; ♠ sneakin &amp; schemin; ♦ swimmin &amp; sailin; ♥ shoutin &amp; singin.</p>
</section>
<section class="glass-panel">
<h2>3. Face a Challenge</h2>
<ol>
<li>The Deep states success/failure stakes and applies one or more Obstacles.</li>
<li>Play up to <strong>one card per Obstacle</strong>. Beat its current value to succeed. Equal, lower or no card = failure.</li>
<li>One success passes the Challenge; every failed Obstacle adds a complication. Narrate success using your cards suit (or Technique).</li>
<li>Stack played cards beneath each Obstacle. The <strong>last card sets its next value</strong>; the original card keeps its suit/color.</li>
<li>After resolution, draw a replacement for each of your cards matching the <strong>original Obstacles color</strong>, even on failure.</li>
</ol>
<p>Discard an Obstacle and its column after <strong>one success per player</strong> in the whole group.</p>
</section>
<section class="glass-panel">
<h2>4. Know your cards</h2>
<p><strong>A = 1; 210 = printed value.</strong> A Gat adds +1 to black cards; a Name adds +1 to red cards.</p>
<p><strong>J / Q / K:</strong> your assigned Secret Pirate Technique automatically succeeds. As an Obstacle or the last card beneath one, its value is the challenged Pi-Rats rank.</p>
<p><strong>Joker in hand:</strong> before or after a Challenge, discard and replace one Obstacle (and its column); discard the Joker. <strong>Drawn as an Obstacle:</strong> discard it; permanently increase the list requirement by 1 starting next scene.</p>
<p><strong>Helping:</strong> only with multiple applied Obstacles. Each helper plays one card and shares the outcome; helpers dont draw replacements.</p>
<p><strong>Duels:</strong> attacker plays a temporary Obstacle; defender responds normally. Discard the duel cards afterward. Only the defender gets color-match replacements.</p>
</section>
<section class="glass-panel">
<h2>5. Chase glory</h2>
<p><strong>Personal, in order:</strong> get a Gat → earn a Name → die like a pirate (or retire). The group decides when youve earned each. First two: +1 rank each; third: give another Pi-Rat +1 rank.</p>
<p><strong>Crew, in order:</strong> steal a ship → choose a Captain → commit piracy.</p>
<p><strong>Captain:</strong> highest rank after taking a ship; can be challenged for command. +1 hand limit, final say in crew disputes and vote ties; 1 rank whenever they personally fail a Challenge.</p>
<p><strong>Gat / Name Taxes:</strong> ask an eligible crewmate to take your Challenge for a random card. Refusal puts their Gat or Name at stake. <a href="/rules#objectives">Read the full tax rules before using them.</a></p>
</section>
<section class="glass-panel">
<h2>6. Wrap up &amp; go again</h2>
<p>The Deep may end the scene once every participating Pi-Rat has faced a Challenge and had an Objective opportunity, or no Obstacles remain, or all Pi-Rats have fled/been incapacitated.</p>
<p><strong>Flee</strong> before or after a Challenge; youre out of the scene and dont count as progressing or completing Objectives that scene.</p>
<p><strong>Between scenes:</strong> everyone votes for another Pi-Rat who wasnt Deep; most votes earns +1 rank. Resolve ties together (Captain breaks ties). Apply Objective rank rewards.</p>
<p>Only the previous Deep may discard any cards and refill to their hand limit. Rotate roles and set the next scene.</p>
<p><strong>End the story</strong> when the group is ready—ideally after everyone tries the Deep, each rat earns an Objective, and the crew completes all three. Celebrate!</p>
</section>
</div>
<footer>Quick reference to the playtest rulebook by Nick Smith. Rats with Gats © 2025 Nick Smith and J. Richmond · Yeld LLC. <a href="/rules">Full rules, exceptions &amp; credits</a>.</footer>
</main>
<script>
// Theme toggle: shares the "pirats-theme" localStorage key with the SPA.
const themeToggle = document.getElementById('theme-toggle');
function refreshThemeToggle() {
const dark = document.documentElement.dataset.theme === 'dark';
themeToggle.textContent = dark ? '☀️ Light Mode' : '🌙 Dark Mode';
}
themeToggle.addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('pirats-theme', next);
refreshThemeToggle();
});
refreshThemeToggle();
document.getElementById('print').addEventListener('click', () => window.print());
</script>
</body>
</html>
+38 -14
View File
@@ -18,39 +18,55 @@ document.documentElement.dataset.theme =
If you re-theme the app, update these values too. The theme is read from If you re-theme the app, update these values too. The theme is read from
the same localStorage key the SPA uses ("pirats-theme"). */ the same localStorage key the SPA uses ("pirats-theme"). */
:root { :root {
--bg-dark: #9fc9e2; --bg-dark: #8dbcc4;
--bg-ocean: #cde6f5; --bg-ocean: #c9e4e5;
--gold: #a8770f; --gold: #8c6010;
--neon-cyan: #0c8d96; --neon-cyan: #08747d;
--red-suit: #c92a35; --red-suit: #c92a35;
--black-suit: #2c3245; --black-suit: #2c3245;
--glass-bg: rgba(248, 241, 221, 0.92); --glass-bg: #f8f1dd;
--glass-border: rgba(168, 119, 15, 0.35); --glass-border: rgba(168, 119, 15, 0.35);
--text-primary: #33291a; --text-primary: #33291a;
--text-muted: #7d6e54; --text-muted: #6e604b;
--well-bg: rgba(51, 41, 26, 0.06); --well-bg: rgba(51, 41, 26, 0.06);
--row-border: rgba(168, 119, 15, 0.2); --row-border: rgba(168, 119, 15, 0.2);
--solid-bg: rgba(255, 250, 236, 0.95); --solid-bg: #fffaec;
--shadow-panel: 0 8px 32px rgba(36, 62, 87, 0.2); --shadow-panel: 0 8px 32px rgba(36, 62, 87, 0.2);
/* Match the app's chart backdrop and opaque sailcloth panels. */
--bg: var(--bg-ocean);
--bg-deep: var(--bg-dark);
--chart-line: color-mix(in srgb, var(--neon-cyan) 9%, transparent);
--chart-glow: color-mix(in srgb, #fffaec 48%, transparent);
--weave: color-mix(in srgb, var(--text-primary) 3%, transparent);
--panel-sheen: color-mix(in srgb, var(--gold) 7%, transparent);
--panel-texture:
linear-gradient(135deg, var(--panel-sheen), transparent 48%),
repeating-linear-gradient(0deg, var(--weave) 0 1px, transparent 1px 4px),
repeating-linear-gradient(90deg, var(--weave) 0 1px, transparent 1px 5px);
--panel-highlight: inset 0 1px 0 color-mix(in srgb, var(--solid-bg) 70%, transparent);
--font-display: 'Pirata One', 'Alegreya SC', serif; --font-display: 'Pirata One', 'Alegreya SC', serif;
--font-heading: 'Alegreya SC', serif; --font-heading: 'Alegreya SC', serif;
--font-body: 'Alegreya Sans', sans-serif; --font-body: 'Alegreya Sans', sans-serif;
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--bg-dark: #0a0d13; --chart-line: color-mix(in srgb, var(--neon-cyan) 6%, transparent);
--bg-ocean: #131822; --chart-glow: color-mix(in srgb, var(--gold) 12%, transparent);
--weave: color-mix(in srgb, var(--text-primary) 2%, transparent);
--panel-sheen: color-mix(in srgb, var(--gold) 9%, transparent);
--bg-dark: #09131c;
--bg-ocean: #101f27;
--gold: #d9a23c; --gold: #d9a23c;
--neon-cyan: #41c9bd; --neon-cyan: #41c9bd;
--red-suit: #e8606e; --red-suit: #e8606e;
--black-suit: #dfe6ef; --black-suit: #dfe6ef;
--glass-bg: rgba(27, 33, 46, 0.85); --glass-bg: #1c2c32;
--glass-border: rgba(217, 162, 60, 0.25); --glass-border: rgba(217, 162, 60, 0.25);
--text-primary: #efe7d3; --text-primary: #efe7d3;
--text-muted: #a89e8a; --text-muted: #a89e8a;
--well-bg: rgba(10, 13, 19, 0.5); --well-bg: rgba(10, 13, 19, 0.5);
--row-border: rgba(217, 162, 60, 0.12); --row-border: rgba(217, 162, 60, 0.12);
--solid-bg: rgba(10, 13, 19, 0.9); --solid-bg: #293c42;
--shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.4); --shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.4);
} }
@@ -59,7 +75,14 @@ document.documentElement.dataset.theme =
html { scroll-behavior: smooth; } html { scroll-behavior: smooth; }
body { body {
background: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%); background-color: var(--bg-deep);
background-image:
radial-gradient(ellipse at 15% 0%, var(--chart-glow), transparent 55%),
repeating-linear-gradient(30deg, transparent 0 159px, var(--chart-line) 159px 160px),
repeating-linear-gradient(150deg, transparent 0 159px, var(--chart-line) 159px 160px),
repeating-radial-gradient(circle at 85% 15%, transparent 0 119px, var(--chart-line) 119px 120px, transparent 120px 240px),
radial-gradient(ellipse at 50% 20%, var(--bg), var(--bg-deep));
background-attachment: fixed;
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-body); font-family: var(--font-body);
font-size: 16px; font-size: 16px;
@@ -70,10 +93,10 @@ body {
a { color: var(--neon-cyan); } a { color: var(--neon-cyan); }
.glass-panel { .glass-panel {
background: var(--glass-bg); background: var(--panel-texture), var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: 12px; border-radius: 12px;
box-shadow: var(--shadow-panel); box-shadow: var(--panel-highlight), var(--shadow-panel);
} }
.rules-page { .rules-page {
@@ -336,6 +359,7 @@ a { color: var(--neon-cyan); }
<header class="rules-header glass-panel"> <header class="rules-header glass-panel">
<h1>Rats with Gats</h1> <h1>Rats with Gats</h1>
<p class="rules-subtitle">A Story Game about the Pi-Rats of Yeld!</p> <p class="rules-subtitle">A Story Game about the Pi-Rats of Yeld!</p>
<p class="rules-subtitle"><a href="/rules/tldr">Short on time? Read the TL;DR rules →</a></p>
<p class="rules-credits"> <p class="rules-credits">
Written &amp; Designed by Nick Smith · Art by J. Richmond · Produced by Sally Hsu · Written &amp; Designed by Nick Smith · Art by J. Richmond · Produced by Sally Hsu ·
Support: Maia and EmmaVoid Support: Maia and EmmaVoid
+126
View File
@@ -0,0 +1,126 @@
"""Hand-written Rat Records prompts for the scrappy fantasy pirates of Yeld.
Smells also serve as temporary names; keep them short noun phrases. Math
is a personality joke, not a power or a rule benefit.
"""
CHARACTER_SUGGESTIONS = {'look': ['A red bandana with two holes chewed through for ears',
'An eyepatch that keeps sliding onto the wrong eye',
'A stolen waistcoat with pockets full of biscuit crumbs',
'Whiskers braided with bits of sail thread',
'A tail tied in a knot so nobody steps on it',
'A tiny skull painted between the ears',
"A coat made from the ship's old flag",
'One gold tooth and a grin that shows it off',
'A belt made of rope, with far too many knots',
'A hat so wide it catches every gust of wind',
'Soot-black paws and a suspiciously innocent expression',
'A scar across the snout that they insist looks impressive',
'A brass button worn as a medal',
'A striped shirt still bearing the laundry tag',
'A fork tucked behind one ear for emergencies',
'A tail wrapped in a strip of stolen velvet',
'A necklace of fish bones and one very nice pearl',
"Fur slicked back with the cook's best butter",
'A chalk handprint on the back of a borrowed coat',
'An enormous hat held on with string'],
'smell': ['Gunpowder and wet fur',
'Old cheese and fresh tar',
'Pickle brine',
'Burnt toast',
'Fish guts and cheap perfume',
'Damp rope',
'Sour grog',
'Peppermint and bilge water',
'Smoked herring',
'Candle wax and singed whiskers',
'Mouldy biscuits',
'Salt pork and garlic',
'Seaweed left in the sun',
'Stolen jam',
'Wet wool and black pepper',
'Boot polish',
'Rancid butter',
'Lemon peel and cannon smoke',
'Onions and damp wood',
'Something dead behind the pantry'],
'first_words': ["Who's been keeping all this cheese up here?",
"I've got thumbs! Nobody panic!",
'Right. Which end of the ship do we steal first?',
"That hat's mine now.",
'I liked the captain better when I could fit in his boot.',
'I demand a bigger lunch.',
'Can we eat the map?',
'Nobody tell the cat.',
"I can explain. Actually, no I can't.",
'Why is the floor suddenly so far away?',
'Hand over the biscuits and nobody gets bitten.',
'I vote we keep the ship.',
'Does this mean I have to wear trousers?',
"I've always wanted to shout this: BOARD THEM!",
'Somebody fetch me a dangerous stick.',
'I know three words and two of them are threats.',
'Was that your cheese? Unfortunate.',
'I can count! We are outnumbered!',
"Let's point the cannon at whoever owns it.",
'I was already this clever. Ask anyone.'],
'good_at_math': ['Yes, until someone starts watching.',
'Only when dividing up stolen food.',
'No, but very loud about the answer.',
'Can count every coin in a purse by shaking it.',
'Knows that two lunches are better than one.',
"Counts on their claws, then borrows someone else's.",
'Brilliant at sums. Cannot tell left from right.',
'Insists every problem can be solved by adding more rats.',
'Yes, and will prove it on your coat in chalk.',
'Can divide loot fairly. Chooses not to.',
'Thinks fractions are what happens when you bite a biscuit.',
'Keeps losing count when their tail moves.',
'No. Has appointed a beetle to handle the numbers.',
'Can calculate a cannon shot but not the price of lunch.',
'Gets the right answer for entirely the wrong reasons.',
'Counts backwards whenever frightened.',
'Only if the numbers are small enough to threaten.',
'Knows pi to several places, all of them wrong.',
"Refuses to subtract. That's how things go missing.",
'Perfectly average, which they find deeply suspicious.'],
'like': ['They always save you the least mouldy biscuit.',
"They'll bite anyone who threatens the crew.",
'They remember which hammock is yours.',
'They take the blame when a good prank goes wrong.',
'They can make a feast out of stolen scraps.',
'They laugh at your jokes, even during a mutiny.',
'They share their last dry match.',
'They never leave a rat behind.',
'They know when to stop talking and start chewing through ropes.',
'They tell excellent lies on your behalf.',
'They warm your paws when the night watch gets cold.',
'They always volunteer to distract the cat.',
'They can find food in an empty cupboard.',
'They mend your clothes without being asked.',
'They cheer loudest when you do something reckless.',
'They keep a lookout while you sneak an extra ration.',
'They turn every terrible plan into a team effort.',
'They know a song for every disaster.',
'They give the best apologies, usually with snacks.',
'They trust you with the good end of the rope.'],
'hate': ['They eat the evidence before anyone can examine it.',
'They borrow your boots and return only one.',
"They practice their captain voice while you're sleeping.",
'They leave fish bones in your hammock.',
'They insist every plan was their idea.',
'They lick the best biscuit before offering to share.',
"They chew through ropes without checking what they're holding up.",
'They shout "Land ho!" whenever they\'re bored.',
'They use your coat to wipe cannon soot off their paws.',
'They keep a secret food stash that everyone can smell.',
'They correct your sums in the middle of a fight.',
'They drum on empty barrels during the night watch.',
'They promise your share of the loot to strangers.',
'They pick their teeth with your favourite knife.',
'They give away the hiding place by giggling.',
"They refuse to admit they can't read the map.",
'They shake seawater onto everyone after a swim.',
'They start a new verse just when the song seems over.',
'They call every creature with fins their cousin.',
"They always ask if you're going to finish that, mid-bite."]}
+19 -5
View File
@@ -5,29 +5,43 @@ Single-process only (uvicorn runs one worker); a multi-worker deploy would
need an external pub/sub instead of this dict. need an external pub/sub instead of this dict.
""" """
from collections import defaultdict from collections import defaultdict
from typing import Optional
from fastapi import WebSocket from fastapi import WebSocket
class GameConnectionManager: class GameConnectionManager:
def __init__(self): def __init__(self):
self._connections: dict[str, set[WebSocket]] = defaultdict(set) self._connections: dict[str, dict[WebSocket, Optional[str]]] = defaultdict(dict)
async def connect(self, game_id: str, websocket: WebSocket): async def connect(self, game_id: str, websocket: WebSocket, player_id: Optional[str] = None):
await websocket.accept() await websocket.accept()
self._connections[game_id].add(websocket) self._connections[game_id][websocket] = player_id
if player_id is not None:
await self.broadcast_presence(game_id)
def disconnect(self, game_id: str, websocket: WebSocket): def disconnect(self, game_id: str, websocket: WebSocket):
self._connections[game_id].discard(websocket) connections = self._connections.get(game_id)
if not self._connections[game_id]: if connections is None:
return
connections.pop(websocket, None)
if not connections:
del self._connections[game_id] del self._connections[game_id]
async def broadcast_presence(self, game_id: str):
player_ids = sorted({pid for pid in self._connections.get(game_id, {}).values() if pid})
await self.broadcast(game_id, {"type": "presence", "player_ids": player_ids})
async def broadcast(self, game_id: str, message: dict): async def broadcast(self, game_id: str, message: dict):
disconnected = False
for ws in list(self._connections.get(game_id, ())): for ws in list(self._connections.get(game_id, ())):
try: try:
await ws.send_json(message) await ws.send_json(message)
except Exception: except Exception:
self.disconnect(game_id, ws) self.disconnect(game_id, ws)
disconnected = True
if disconnected:
await self.broadcast_presence(game_id)
manager = GameConnectionManager() manager = GameConnectionManager()
+418 -19
View File
@@ -1,6 +1,7 @@
import pytest import pytest
import json import json
from sqlmodel import SQLModel, create_engine, Session, select from sqlmodel import SQLModel, create_engine, Session, select
from sqlalchemy.pool import StaticPool
from pirats import cards from pirats import cards
from pirats import crud from pirats import crud
from pirats.models import Game, Obstacle, Player, Challenge, GameEvent, Checkpoint from pirats.models import Game, Obstacle, Player, Challenge, GameEvent, Checkpoint
@@ -8,7 +9,7 @@ from pirats.models import Game, Obstacle, Player, Challenge, GameEvent, Checkpoi
# In-memory database for testing # In-memory database for testing
@pytest.fixture(name="session") @pytest.fixture(name="session")
def session_fixture(): def session_fixture():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}) engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
with Session(engine) as session: with Session(engine) as session:
yield session yield session
@@ -215,21 +216,14 @@ def test_scene_start_and_challenges(session):
# Obstacle current value should update to 10 # Obstacle current value should update to 10
assert obs.current_value == 10 assert obs.current_value == 10
# Check if hand size matches card draw rule # Replacements wait until the Deep resolves the Challenge.
# 10D was played (-1 card). If colors matched, drew 1 back (+1 card). assert len(crud.get_player_hand(p2)) == 3
hand = crud.get_player_hand(p2)
if orig_is_red:
assert len(hand) == 4
assert res["drew_card"] is not None
else:
assert len(hand) == 3
assert res["drew_card"] is None assert res["drew_card"] is None
# The Deep resolves the challenge: at least one success -> succeeded
ok, msg = crud.resolve_challenge(session, challenge.id, deep.id) ok, msg = crud.resolve_challenge(session, challenge.id, deep.id)
assert ok assert ok
session.refresh(challenge) session.refresh(challenge)
assert challenge.status == "succeeded" assert challenge.status == "succeeded"
assert len(crud.get_player_hand(p2)) == (4 if orig_is_red else 3)
def test_assistant_does_not_draw(session): def test_assistant_does_not_draw(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
@@ -241,7 +235,7 @@ def test_assistant_does_not_draw(session):
session.add_all([obs, p3]) session.add_all([obs, p3])
session.commit() session.commit()
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id]) ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [o.id for o in game.obstacles])
assert ok assert ok
session.refresh(game) session.refresh(game)
@@ -511,6 +505,7 @@ def test_transition_to_deep_upkeep(session):
def test_confirm_deep_refresh(session): def test_confirm_deep_refresh(session):
game = crud.create_game(session) game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1") p1 = crud.add_player(session, game.id, "P1")
game.phase = "deep_upkeep"
p1.previous_role = "deep" p1.previous_role = "deep"
p1.role = "deep" p1.role = "deep"
p1.rank = 2 p1.rank = 2
@@ -547,11 +542,11 @@ def test_confirm_deep_refresh(session):
assert "7H" in hand assert "7H" in hand
assert len(hand) == 4 assert len(hand) == 4
# Discarded cards should be at the end of the deck # Discarded cards stay out until scene setup
deck = json.loads(game.deck_cards) deck = json.loads(game.deck_cards)
assert "2C" in deck assert "2C" not in deck
assert "3C" in deck assert "3C" not in deck
assert deck == ["8H", "2C", "3C"] assert deck == ["8H"]
# Since P1 was the only resting deep player and is now ready, phase should have advanced to scene_setup! # Since P1 was the only resting deep player and is now ready, phase should have advanced to scene_setup!
assert game.phase == "scene_setup" assert game.phase == "scene_setup"
@@ -1128,6 +1123,8 @@ def test_vote_validation(session):
p1 = crud.add_player(session, game.id, "P1") p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2") p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3") p3 = crud.add_player(session, game.id, "P3")
game.phase = "between_scenes"
session.add(game)
p1.role = "deep" p1.role = "deep"
p2.role = "pirat" p2.role = "pirat"
p3.role = "pirat" p3.role = "pirat"
@@ -1181,11 +1178,11 @@ def test_vote_skips_when_only_dead_options(session):
def test_rank_up_draws_immediately(session): def test_rank_up_draws_immediately(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
# p2 is rank 1 (lowest -> max 2), p3 is rank 2 (highest -> max 4) # p2 starts lowest. Reaching rank 3 ties the Deep for highest.
hand_before = len(crud.get_player_hand(p2)) hand_before = len(crud.get_player_hand(p2))
crud.toggle_objective(session, game.id, p2.id, "personal_1", True) crud.change_player_rank(session, game, p2, 2)
session.refresh(p2) session.refresh(p2)
assert p2.rank == 2 assert p2.rank == 3
# p2 jumped from lowest (2 cards) to highest-tied (4 cards): draws 2 immediately # p2 jumped from lowest (2 cards) to highest-tied (4 cards): draws 2 immediately
assert len(crud.get_player_hand(p2)) == hand_before + 2 assert len(crud.get_player_hand(p2)) == hand_before + 2
@@ -2585,3 +2582,405 @@ def test_request_body_size_limit_on_real_app():
client = TestClient(app) client = TestClient(app)
r = client.post("/api/game", content=b"x" * (DEFAULT_MAX_BODY_BYTES + 1)) r = client.post("/api/game", content=b"x" * (DEFAULT_MAX_BODY_BYTES + 1))
assert r.status_code == 413 assert r.status_code == 413
def test_votes_can_change_until_final_vote_then_close(session):
game, deep, (a, b) = make_scene_game(session, num_pirats=2)
crud.end_scene_and_transition(session, game.id)
assert crud.submit_rank_vote(session, game.id, deep.id, a.id)[0]
assert crud.submit_rank_vote(session, game.id, deep.id, b.id)[0]
session.refresh(game)
assert len(game.votes) == 1
assert game.votes[0].nominated_player_id == b.id
assert game.phase == "between_scenes"
assert crud.submit_rank_vote(session, game.id, a.id, b.id)[0]
old_rank = b.rank
assert crud.submit_rank_vote(session, game.id, b.id, a.id)[0]
session.refresh(game)
assert game.phase == "deep_upkeep"
assert game.last_rank_up_player_id == b.id
assert b.rank == old_rank + 1
assert not crud.submit_rank_vote(session, game.id, deep.id, a.id)[0]
crud.maybe_transition_to_deep_upkeep(session, game)
assert b.rank == old_rank + 1
def test_vote_skips_sole_nominee_and_advances_without_ready(session):
game, deep, (a,) = make_scene_game(session)
crud.end_scene_and_transition(session, game.id)
assert crud.eligible_vote_nominees(game, a) == []
assert crud.submit_rank_vote(session, game.id, deep.id, a.id)[0]
assert game.phase == "deep_upkeep"
assert game.last_rank_up_player_id == a.id
def test_voting_waits_for_death_choice_and_skips_empty_ballots(session):
game, deep, (a,) = make_scene_game(session)
a.is_dead = True
session.add(a)
session.commit()
crud.end_scene_and_transition(session, game.id)
assert game.phase == "between_scenes"
assert crud.choose_reroll(session, a.id)[0]
assert game.phase == "deep_upkeep"
assert game.last_rank_up_player_id is None
def test_vote_result_survives_skipping_deep_upkeep(session):
game, deep, (a,) = make_scene_game(session)
deep.previous_role = None
session.add(deep)
session.commit()
crud.end_scene_and_transition(session, game.id)
assert crud.submit_rank_vote(session, game.id, deep.id, a.id)[0]
assert game.phase == "scene_setup"
assert game.last_rank_up_player_id == a.id
def test_kick_last_pending_voter_completes_voting(session):
game, deep, (a, b) = make_scene_game(session, num_pirats=2)
crud.end_scene_and_transition(session, game.id)
assert crud.submit_rank_vote(session, game.id, deep.id, a.id)[0]
assert crud.submit_rank_vote(session, game.id, a.id, b.id)[0]
assert crud.kick_player(session, game, b)[0]
assert game.phase == "deep_upkeep"
assert game.last_rank_up_player_id == a.id
def test_completed_obstacle_discards_without_replacement(session):
game, deep, (rat,) = make_scene_game(session)
obs = game.obstacles[0]
oid = obs.id
obs.played_cards = json.dumps([{"card": "KC", "success": True}])
rat.hand_cards = json.dumps(["QD"])
session.add(obs)
session.add(rat)
session.commit()
assert crud.create_challenge(session, game.id, deep.id, rat.id, [oid])[0]
assert crud.play_challenge_card(session, rat.id, oid, "QD")[0]
assert session.get(Obstacle, oid) is None
session.refresh(game)
assert len(game.obstacles) == 1
def test_cannot_clear_unfinished_obstacle(session):
game, deep, (rat,) = make_scene_game(session)
oid = game.obstacles[0].id
crud.clear_completed_obstacle(session, game.id, oid)
assert session.get(Obstacle, oid) is not None
def test_setup_joker_only_increases_following_scene(session, monkeypatch):
game, deep, (rat,) = make_scene_game(session)
for obs in list(game.obstacles):
session.delete(obs)
game.current_scene_number = 2
deep.previous_role = "pirat"
rat.previous_role = "deep"
session.commit()
monkeypatch.setattr('pirats.crud_scene.reshuffle_discard_pile', lambda db, game: None)
game.deck_cards = json.dumps(["Joker1", "2H", "3H", "4H"])
session.commit()
assert crud.confirm_scene_setup(session, game.id)[0]
session.refresh(game)
assert len(game.obstacles) == 2
assert game.extra_obstacles == 1
assert crud.get_game_deck(game) == ["4H"]
def test_hand_sizes_use_crew_ranks_and_ignore_role_changes(session):
game, deep, (low, middle) = make_scene_game(session, num_pirats=2)
assert [crud.calculate_max_hand_size(p, game.players) for p in (low, middle, deep)] == [2, 3, 4]
low.role, deep.role = "deep", "pirat"
assert [crud.calculate_max_hand_size(p, game.players) for p in (low, middle, deep)] == [2, 3, 4]
def test_draw_stops_at_empty_deck_without_recycling(session):
game, deep, (rat,) = make_scene_game(session)
rat.hand_cards = '[]'
game.deck_cards = '["AH"]'
session.commit()
assert crud.draw_cards_for_player(session, game, rat, 3) == ["AH"]
assert crud.get_player_hand(rat) == ["AH"]
assert crud.get_game_deck(game) == []
def test_deep_refresh_does_not_recycle_discards_or_allow_pirats(session):
game, deep, (rat,) = make_scene_game(session)
game.phase = "deep_upkeep"
deep.hand_cards = '["KC"]'
rat.hand_cards = '["QC"]'
game.deck_cards = '["AH", "2H", "3H", "4H"]'
session.commit()
crud.confirm_deep_refresh(session, rat.id, ["QC"])
assert crud.get_player_hand(rat) == ["QC"]
crud.confirm_deep_refresh(session, deep.id, ["KC"])
assert crud.get_player_hand(deep) == ["AH", "2H", "3H", "4H"]
assert "KC" not in crud.get_game_deck(game)
crud.confirm_deep_refresh(session, deep.id, ["AH"])
assert "AH" in crud.get_player_hand(deep)
def test_single_obstacle_rejects_assistance_but_allows_tax_actor(session):
game, deep, (rat, helper) = make_scene_game(session, num_pirats=2)
obs = game.obstacles[0]
helper.hand_cards = '["KH"]'
helper.completed_personal_1 = True
session.commit()
assert crud.create_challenge(session, game.id, deep.id, rat.id, [obs.id])[0]
ok, msg, _ = crud.play_challenge_card(session, helper.id, obs.id, "KH")
assert not ok and 'multiple' in msg
assert crud.get_player_hand(helper) == ["KH"]
challenge = game.challenges[0]
assert crud.request_tax(session, challenge.id, rat.id, helper.id)[0]
assert crud.respond_tax(session, challenge.id, helper.id, True)[0]
assert crud.play_challenge_card(session, helper.id, obs.id, "KH")[0]
def test_objective_vote_majority_awards_once_and_preserves_rollback(session):
game, deep, (rat, helper) = make_scene_game(session, num_pirats=2)
rank = rat.rank
assert not crud.propose_personal_objective(session, game.id, deep.id, rat.id, 'personal_2')[0]
assert crud.propose_personal_objective(session, game.id, deep.id, rat.id, 'personal_1')[0]
proposal = json.loads(game.objective_votes)[0]
assert not rat.completed_personal_1
assert not crud.propose_personal_objective(session, game.id, helper.id, rat.id, 'personal_1')[0]
snapshot = crud.serialize_game_state(game)
assert crud.vote_personal_objective(session, game.id, helper.id, proposal['id'], True)[0]
assert rat.completed_personal_1 and rat.needs_gat_description
assert rat.rank == rank + 1
assert json.loads(game.objective_votes) == []
assert not crud.vote_personal_objective(session, game.id, helper.id, proposal['id'], True)[0]
assert rat.rank == rank + 1
rat_id = rat.id
crud.apply_game_state(session, game, snapshot)
rat = session.get(Player, rat_id)
session.refresh(game)
assert not rat.completed_personal_1
assert json.loads(game.objective_votes)[0]['id'] == proposal['id']
@pytest.mark.parametrize('captain_vote, awarded', [(True, True), (False, False), (None, False)])
def test_objective_vote_ties(session, captain_vote, awarded):
game, deep, (rat, helper, captain) = make_scene_game(session, num_pirats=3)
if captain_vote is not None:
game.captain_player_id = captain.id
session.commit()
assert crud.propose_personal_objective(session, game.id, deep.id, rat.id, 'personal_1')[0]
pid = json.loads(game.objective_votes)[0]['id']
assert crud.vote_personal_objective(session, game.id, rat.id, pid, False)[0]
assert crud.vote_personal_objective(session, game.id, helper.id, pid, not bool(captain_vote))[0]
assert crud.vote_personal_objective(session, game.id, captain.id, pid, bool(captain_vote))[0]
assert rat.completed_personal_1 == awarded
assert json.loads(game.objective_votes) == []
def test_objective_votes_reject_foreign_players_and_close_at_scene_end(session):
game, deep, (rat, helper) = make_scene_game(session, num_pirats=2)
other = crud.create_game(session)
foreign = crud.add_player(session, other.id, 'Outsider')
assert not crud.propose_personal_objective(session, game.id, foreign.id, rat.id, 'personal_1')[0]
assert crud.propose_personal_objective(session, game.id, deep.id, rat.id, 'personal_1')[0]
pid = json.loads(game.objective_votes)[0]['id']
assert not crud.vote_personal_objective(session, game.id, foreign.id, pid, True)[0]
assert crud.end_scene_and_transition(session, game.id)[0]
assert json.loads(game.objective_votes) == []
assert not crud.vote_personal_objective(session, game.id, rat.id, pid, True)[0]
def test_objective_vote_rejection_and_death_effects(session):
game, deep, (rat, helper) = make_scene_game(session, num_pirats=2)
assert crud.propose_personal_objective(session, game.id, deep.id, rat.id, 'personal_1')[0]
pid = json.loads(game.objective_votes)[0]['id']
assert crud.vote_personal_objective(session, game.id, rat.id, pid, False)[0]
assert crud.vote_personal_objective(session, game.id, helper.id, pid, False)[0]
assert not rat.completed_personal_1
rat.completed_personal_1 = rat.completed_personal_2 = True
game.captain_player_id = rat.id
session.commit()
assert crud.propose_personal_objective(session, game.id, helper.id, rat.id, 'personal_3')[0]
pid = json.loads(game.objective_votes)[0]['id']
assert crud.vote_personal_objective(session, game.id, rat.id, pid, True)[0]
assert rat.is_dead and rat.needs_rank_3_bonus
assert game.captain_player_id is None
def test_personal_objective_api_requires_vote(session):
from fastapi.testclient import TestClient
from pirats.main import app
from pirats.database import get_session
game, deep, (rat, helper) = make_scene_game(session, num_pirats=2)
def override():
yield session
app.dependency_overrides[get_session] = override
try:
client = TestClient(app)
path = f'/api/game/{game.id}/player'
response = client.post(f'{path}/{rat.id}/objective/toggle', data={'type': 'personal_1'})
assert response.status_code == 400
assert not rat.completed_personal_1
response = client.post(f'{path}/{deep.id}/objective/propose', data={'target_id': rat.id, 'type': 'personal_1'})
assert response.status_code == 200
pid = json.loads(game.objective_votes)[0]['id']
response = client.post(f'{path}/{helper.id}/objective/vote', data={'proposal_id': pid, 'approve': 'true'})
assert response.status_code == 200
session.refresh(rat)
assert rat.completed_personal_1
finally:
app.dependency_overrides.clear()
@pytest.mark.parametrize('defense, success, redraw', [
('9S', True, True), ('2S', False, True),
('9H', True, False), ('2H', False, False), ('QS', True, True),
])
def test_pvp_redraw_depends_on_color_not_success(session, defense, success, redraw):
game, deep, (attacker, defender) = make_scene_game(session, num_pirats=2)
attacker.hand_cards = '["7C"]'
defender.hand_cards = json.dumps([defense])
game.deck_cards = '["AD"]'
session.commit()
assert crud.create_pvp_challenge(session, game.id, attacker.id, defender.id, '7C')[0]
duel = game.challenges[0]
ok, _, result = crud.play_pvp_defense(session, duel.id, defender.id, defense)
assert ok and result['success'] == success
assert result['drew_card'] == ('AD' if redraw else None)
assert crud.get_player_hand(attacker) == []
assert crud.get_player_hand(defender) == (['AD'] if redraw else [])
assert not crud.play_pvp_defense(session, duel.id, defender.id, 'AD')[0]
def test_challenge_draw_waits_for_resolution_and_cannot_repeat(session):
game, deep, (rat, helper) = make_scene_game(session, num_pirats=2)
obs = game.obstacles[0]
obs.original_card, obs.suit, obs.current_value = '7C', 'C', 7
rat.hand_cards = '["2S", "9S"]'
game.deck_cards = '["AD"]'
session.commit()
assert crud.create_challenge(session, game.id, deep.id, rat.id, [obs.id])[0]
challenge = game.challenges[0]
assert crud.play_challenge_card(session, rat.id, obs.id, '2S')[0]
assert crud.get_player_hand(rat) == ['9S']
assert not crud.play_challenge_card(session, rat.id, obs.id, '9S')[0]
assert not crud.request_tax(session, challenge.id, rat.id, helper.id)[0]
assert crud.resolve_challenge(session, challenge.id, deep.id)[0]
assert challenge.status == 'failed'
assert crud.get_player_hand(rat) == ['9S', 'AD']
assert not crud.resolve_challenge(session, challenge.id, deep.id)[0]
assert crud.get_player_hand(rat) == ['9S', 'AD']
def test_completed_obstacle_still_awards_deferred_draw(session):
game, deep, (rat,) = make_scene_game(session)
obs = game.obstacles[0]
obs.original_card = '7C'
obs.played_cards = '[{"card":"JC", "success":true}]'
rat.hand_cards = '["QS"]'
game.deck_cards = '["AD"]'
session.commit()
assert crud.create_challenge(session, game.id, deep.id, rat.id, [obs.id])[0]
challenge = game.challenges[0]
oid = obs.id
assert crud.play_challenge_card(session, rat.id, oid, 'QS')[0]
assert session.get(Obstacle, oid) is None
assert crud.get_player_hand(rat) == []
assert crud.resolve_challenge(session, challenge.id, deep.id)[0]
assert crud.get_player_hand(rat) == ['AD']
@pytest.mark.parametrize("description", ["", " \t\n", "\x00\x07"])
def test_gat_description_rejects_blank_input(session, description):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pirats.database import get_session
from pirats.routes_scene import router
game, deep, (rat,) = make_scene_game(session, num_pirats=1)
crud.toggle_objective(session, game.id, rat.id, "personal_1", True)
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_session] = lambda: session
with TestClient(app) as client:
response = client.post(
f"/game/{game.id}/player/{rat.id}/set-gat-description",
data={"description": description},
)
assert response.status_code in (400, 422)
session.refresh(rat)
assert rat.gat_description == ""
assert rat.needs_gat_description
def test_gat_description_saved_and_shared_with_crew(session):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pirats.database import get_session
from pirats.main import get_game_state
from pirats.routes_scene import router
game, deep, (rat,) = make_scene_game(session, num_pirats=1)
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_session] = lambda: session
path = f"/game/{game.id}/player/{rat.id}/set-gat-description"
with TestClient(app) as client:
assert client.post(path, data={"description": "Cutlass"}).status_code == 400
# Older Gats may be missing a description without a pending prompt flag.
rat.completed_personal_1 = True
session.add(rat)
session.commit()
assert client.post(
f"/game/wrong-game/player/{rat.id}/set-gat-description",
data={"description": "Cutlass"},
).status_code == 404
assert client.post(path, data={"description": " Pearl\x00-handled flintlock "}).status_code == 200
session.refresh(rat)
assert rat.gat_description == "Pearl-handled flintlock"
assert not rat.needs_gat_description
own_state = get_game_state(game.id, rat.id, session)
crew_state = get_game_state(game.id, deep.id, session)
assert own_state["player"]["gat_description"] == rat.gat_description
assert next(p for p in crew_state["players"] if p["id"] == rat.id)["gat_description"] == rat.gat_description
@pytest.mark.parametrize("description", ["A rusty cutlass", ""])
def test_gat_description_and_prompt_follow_tax_transfer(session, description):
game, deep, (requester, owner) = make_scene_game(session, num_pirats=2)
obstacle = game.obstacles[0]
obstacle.current_value = 13
owner.completed_personal_1 = True
owner.gat_description = description
owner.needs_gat_description = not bool(description)
requester.hand_cards = json.dumps(["2C"])
session.add_all([obstacle, requester, owner])
session.commit()
assert crud.create_challenge(session, game.id, deep.id, requester.id, [obstacle.id])[0]
session.refresh(game)
challenge = game.challenges[0]
assert crud.request_tax(session, challenge.id, requester.id, owner.id)[0]
assert crud.respond_tax(session, challenge.id, owner.id, accept=False)[0]
assert requester.gat_description == description
assert requester.needs_gat_description == (not bool(description))
assert owner.gat_description == ""
assert not owner.needs_gat_description
assert crud.play_challenge_card(session, requester.id, obstacle.id, "2C")[0]
assert crud.resolve_challenge(session, challenge.id, deep.id)[0]
assert owner.gat_description == description
assert owner.needs_gat_description == (not bool(description))
assert requester.gat_description == ""
assert not requester.needs_gat_description
@pytest.mark.parametrize("description", ["A rusty cutlass", ""])
def test_recruit_clears_previous_gat(session, description):
game, deep, (rat,) = make_scene_game(session, num_pirats=1)
rat.completed_personal_1 = True
rat.gat_description = description
rat.needs_gat_description = not bool(description)
session.add(rat)
session.commit()
crud.activate_recruit(session, game, rat)
session.refresh(rat)
assert not rat.completed_personal_1
assert rat.gat_description == ""
assert not rat.needs_gat_description
+76
View File
@@ -0,0 +1,76 @@
"""Notebooks persist independently of public gameplay and rollback history."""
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine, select
from sqlmodel.pool import StaticPool
from pirats.database import get_session
from pirats.main import app
from pirats.models import Game, Player, PlayerNotebook, Checkpoint
from pirats.crud_rollback import serialize_game_state, apply_game_state
@pytest.fixture
def notebook_env(monkeypatch):
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
SQLModel.metadata.create_all(engine)
with Session(engine) as db:
game = Game(phase='scene')
other = Game()
db.add(game)
db.add(other)
db.commit()
players = [Player(game_id=game.id, player_name=name) for name in ('One', 'Two')]
db.add_all(players)
db.commit()
ids = game.id, other.id, players[0].id, players[1].id
def session_override():
with Session(engine) as db:
yield db
app.dependency_overrides[get_session] = session_override
def unexpected(*args, **kwargs):
pytest.fail('Notes must not checkpoint gameplay or broadcast to the crew')
monkeypatch.setattr('pirats.main._checkpoint_after_mutation', unexpected)
monkeypatch.setattr('pirats.main.manager.broadcast', unexpected)
try:
yield TestClient(app), engine, ids
finally:
app.dependency_overrides.clear()
engine.dispose()
def test_notes_round_trip_isolation_and_validation(notebook_env):
client, engine, (gid, other, pid, second) = notebook_env
path = f'/api/game/{gid}/player/{pid}/notes'
assert client.get(path).json() == {'text': ''}
text = 'Remember the captains map 🐀\n Meet at dusk.\n'
assert client.put(path, data={'text': text}).json() == {'text': text}
assert client.get(path).json() == {'text': text}
assert client.get(f'/api/game/{gid}/player/{second}/notes').json() == {'text': ''}
assert text not in client.get(f'/api/game/{gid}/player/{second}/state').text
wrong = f'/api/game/{other}/player/{pid}/notes'
assert client.get(wrong).status_code == 404
assert client.put(wrong, data={'text': 'bad'}).status_code == 404
assert client.put(path, data={'text': 'x' * 50001}).status_code == 422
assert client.get(path).json()['text'] == text
assert client.put(path, data={'text': ''}).json() == {'text': ''}
assert client.get(path).json() == {'text': ''}
def test_notes_survive_rollback_and_game_cleanup(notebook_env):
client, engine, (gid, _, pid, _) = notebook_env
path = f'/api/game/{gid}/player/{pid}/notes'
with Session(engine) as db:
game = db.get(Game, gid)
snapshot = serialize_game_state(game)
client.put(path, data={'text': 'Keep this after rewinding'})
with Session(engine) as db:
game = db.get(Game, gid)
assert 'Keep this after rewinding' not in serialize_game_state(game)
apply_game_state(db, game, snapshot)
assert db.exec(select(Checkpoint)).all() == []
assert client.get(path).json()['text'] == 'Keep this after rewinding'
with Session(engine) as db:
db.delete(db.get(Game, gid))
db.commit()
assert db.exec(select(PlayerNotebook)).all() == []
+86
View File
@@ -0,0 +1,86 @@
"""Live presence tracks connections rather than persistent character state."""
import asyncio
from contextlib import asynccontextmanager
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
from pirats.database import get_session
from pirats.main import app
from pirats.ws import GameConnectionManager
def test_presence_tracks_multiple_tabs_and_rejects_other_games():
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
SQLModel.metadata.create_all(engine)
def session_override():
with Session(engine) as db:
yield db
app.dependency_overrides[get_session] = session_override
original_lifespan = app.router.lifespan_context
@asynccontextmanager
async def test_lifespan(app):
yield
app.router.lifespan_context = test_lifespan
try:
with TestClient(app) as client:
gid = client.post('/api/game', data={'crew_name': 'Presence'}).json()['id']
pid = client.post(f'/api/game/{gid}/join', data={'name': 'Rat'}).json()['id']
other = client.post('/api/game', data={'crew_name': 'Other'}).json()['id']
from starlette.websockets import WebSocketDisconnect
import pytest
with pytest.raises(WebSocketDisconnect) as rejected:
with client.websocket_connect(f'/api/game/{other}/ws?player_id={pid}'):
pass
assert rejected.value.code == 1008
with client.websocket_connect(f'/api/game/{gid}/ws') as observer:
with client.websocket_connect(f'/api/game/{gid}/ws?player_id={pid}') as first:
expected = {'type': 'presence', 'player_ids': [pid]}
assert first.receive_json() == expected
assert observer.receive_json() == expected
with client.websocket_connect(f'/api/game/{gid}/ws?player_id={pid}') as second:
assert second.receive_json() == expected
assert first.receive_json() == expected
assert observer.receive_json() == expected
second.close()
assert first.receive_json() == expected
assert observer.receive_json() == expected
first.close()
assert observer.receive_json() == {'type': 'presence', 'player_ids': []}
finally:
app.router.lifespan_context = original_lifespan
app.dependency_overrides.clear()
engine.dispose()
def test_failed_socket_is_removed_from_presence():
class Socket:
def __init__(self):
self.messages = []
self.failed = False
async def accept(self):
pass
async def send_json(self, message):
if self.failed:
raise RuntimeError('Connection lost')
self.messages.append(message)
async def scenario():
manager = GameConnectionManager()
first, second = Socket(), Socket()
await manager.connect('game', first, 'a')
await manager.connect('game', second, 'b')
first.failed = True
await manager.broadcast('game', {'type': 'state_changed'})
assert second.messages[-1] == {'type': 'presence', 'player_ids': ['b']}
manager.disconnect('game', first) # Endpoint cleanup remains idempotent.
manager.disconnect('game', second)
assert not manager._connections
asyncio.run(scenario())
+20
View File
@@ -0,0 +1,20 @@
from fastapi.testclient import TestClient
from pirats.main import app
from pirats.validation import sanitize_name, sanitize_text
def test_suggestions_api_covers_rat_records_without_truncation():
# No lifespan needed: these public endpoints do not access the database.
client = TestClient(app)
response = client.get('/api/suggestions')
assert response.status_code == 200
pools = response.json()
assert set(pools) == {'look', 'smell', 'first_words', 'good_at_math', 'like', 'hate'}
for category, entries in pools.items():
assert len(entries) >= 20
assert len({entry.casefold() for entry in entries}) == len(entries)
sanitize = sanitize_name if category == 'smell' else sanitize_text
assert all(entry and sanitize(entry) == entry for entry in entries)
techniques = client.get('/api/suggestions/techniques')
assert techniques.status_code == 200
assert techniques.json()['techniques']