Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b40864cc4 | ||
|
|
145f526634 | ||
|
|
0ae1701b82 | ||
|
|
1dac2141e6 | ||
|
|
5c761b079d | ||
|
|
ad9f450343 | ||
|
|
9b673cb8bc | ||
|
|
53df97e3a5 | ||
|
|
a5e3969d0a | ||
|
|
811a80a4c9 | ||
|
|
22880cc466 | ||
|
|
fcbc617fe3 | ||
|
|
8b67d78f25 | ||
|
|
cf63ffed47 | ||
|
|
2f8f67a24c | ||
|
|
8fdd10f7a3 | ||
|
|
673a26f271 | ||
|
|
749bef6ff1 | ||
|
|
1837bf04e6 | ||
|
|
88f8781dce | ||
|
|
d78d23d269 | ||
|
|
edcb3c08a2 | ||
|
|
376765dd0f |
@@ -16,12 +16,28 @@ 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`).
|
||||||
|
|||||||
@@ -1,15 +1,43 @@
|
|||||||
|
## 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] Refresh both themes with legible texture and nautical flair, and give the Event Log / My Notes the regular panel surface.
|
||||||
- [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] 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] 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] Event log button should be the same size and position when expanded and collapsed in all screens
|
||||||
- [x] In the admin panel, the "Open" and "Copy" buttons should be the same size
|
- [x] Align the "Current Difficulty" and "Successes" boxes
|
||||||
- [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] Don't have dev mode toggling appear in the event log
|
||||||
- [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] Remove display of current deck size
|
||||||
- [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] 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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = 'Couldn’t 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 = 'Couldn’t 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 obstacle’s 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>
|
|
||||||
|
|||||||
@@ -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,26 +208,30 @@
|
|||||||
<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 voteError}<p class="alert alert-danger" role="alert">{voteError}</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
<!-- 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 Obstacle’s 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 Obstacle’s 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,40 +72,42 @@
|
|||||||
>{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. -->
|
||||||
<button
|
<div class="crew-objectives-panel">
|
||||||
class="crew-objectives-toggle"
|
<button
|
||||||
aria-expanded={showObjectives}
|
class="crew-objectives-toggle"
|
||||||
on:click={() => showObjectives = !showObjectives}
|
aria-expanded={showObjectives}
|
||||||
>
|
on:click={() => showObjectives = !showObjectives}
|
||||||
<span class="co-label">{showObjectives ? '▾' : '▸'} Crew Objectives</span>
|
>
|
||||||
<span class="co-count">{crewDone}/3</span>
|
<span class="co-label">{showObjectives ? '▾' : '▸'} Crew Objectives</span>
|
||||||
</button>
|
<span class="co-count">{crewDone}/3</span>
|
||||||
{#if showObjectives}
|
</button>
|
||||||
<div class="crew-objectives-detail" transition:slide>
|
{#if showObjectives}
|
||||||
{#if objError}<div class="alert alert-danger">{objError}</div>{/if}
|
<div class="crew-objectives-detail" transition:slide>
|
||||||
<label class="checkbox-label">
|
{#if objError}<div class="alert alert-danger">{objError}</div>{/if}
|
||||||
<input type="checkbox" checked={state.game.completed_crew_1} disabled={!isDeep} on:change={() => toggleCrew('crew_1')}>
|
<label class="checkbox-label">
|
||||||
<span>Steal a Ship</span>
|
<input type="checkbox" checked={state.game.completed_crew_1} disabled={!isDeep} on:change={() => toggleCrew('crew_1')}>
|
||||||
</label>
|
<span>Steal a Ship</span>
|
||||||
<label class="checkbox-label">
|
</label>
|
||||||
<input type="checkbox" checked={state.game.completed_crew_2} disabled={!isDeep} on:change={() => toggleCrew('crew_2')}>
|
<label class="checkbox-label">
|
||||||
<span>Choose a Captain</span>
|
<input type="checkbox" checked={state.game.completed_crew_2} disabled={!isDeep} on:change={() => toggleCrew('crew_2')}>
|
||||||
</label>
|
<span>Choose a Captain</span>
|
||||||
<label class="checkbox-label">
|
</label>
|
||||||
<input type="checkbox" checked={state.game.completed_crew_3} disabled={!isDeep} on:change={() => toggleCrew('crew_3')}>
|
<label class="checkbox-label">
|
||||||
<span>Commit Piracy</span>
|
<input type="checkbox" checked={state.game.completed_crew_3} disabled={!isDeep} on:change={() => toggleCrew('crew_3')}>
|
||||||
</label>
|
<span>Commit Piracy</span>
|
||||||
{#if isDeep}<p class="info-text" style="font-size: 0.75rem; margin: 0.25rem 0 0;">As the Deep, tick these off as the crew earns them.</p>{/if}
|
</label>
|
||||||
</div>
|
{#if isDeep}<p class="info-text" style="font-size: 0.75rem; margin: 0.25rem 0 0;">As the Deep, tick these off as the crew earns them.</p>{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -6,10 +6,32 @@
|
|||||||
// - Add a CHANGELOG entry only when a commit changes something players can
|
// - Add a CHANGELOG entry only when a commit changes something players can
|
||||||
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
||||||
|
|
||||||
export const VERSION = 26;
|
export const VERSION = 49;
|
||||||
|
|
||||||
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
||||||
export const CHANGELOG = [
|
export const CHANGELOG = [
|
||||||
|
{ version: 49, date: '2026-09-04', changes: ['Freshened both themes with nautical chart lines, woven panel textures, and richer sea-and-brass colors while keeping text clear.', 'The Event Log and My Notes now use the regular solid panel surface.'] },
|
||||||
|
{ version: 48, date: '2026-09-04', changes: ['Keep your own notes alongside the Event Log. Notes save automatically between sessions, including when you close the panel, and stay intact when the game rewinds or you create a new Pi-Rat.'] },
|
||||||
|
{ version: 47, date: '2026-09-04', changes: ['Rewrote character suggestions with scrappy pirate looks, distinctive smells, first words, and crew quirks that fit Yeld.'] },
|
||||||
|
{ version: 46, date: '2026-09-04', changes: ['Added a printable TL;DR rules page, linked at the top of the full rulebook.'] },
|
||||||
|
{ version: 44, date: '2026-09-04', changes: ['Crew Objectives opens as one connected panel, with the toggle and checklist sharing a border and background.'] },
|
||||||
|
{ version: 43, date: '2026-09-04', changes: ['The crew roster keeps players in the same order when roles or ranks change, including when someone becomes the Deep.'] },
|
||||||
|
{ version: 42, date: '2026-09-04', changes: ['Gats require a description, including older Gats that were left blank. Read descriptions on character sheets or hover over Gat icons in the crew roster.', 'Gat descriptions and prompts follow Gat Taxes correctly and clear when you create a new recruit.'] },
|
||||||
|
{ version: 41, date: '2026-09-04', changes: ['Dismiss a completed duel once you have read the result. Other players can still read it, and dismissed results stay hidden after a reload.'] },
|
||||||
|
{ version: 40, date: '2026-09-04', changes: ['Played cards show a check or cross in the upper corner instead of a green or red outline, so results remain clear in stacked cards.'] },
|
||||||
|
{ version: 39, date: '2026-09-04', changes: ['Completed duels stay visible for the rest of the scene, showing the winner, both cards, and how the defense resolved.'] },
|
||||||
|
{ version: 38, date: '2026-09-04', changes: ['The crew roster shows who is online, with hat and gun icons for the Captain and Pi-Rats who have a Gat.'] },
|
||||||
|
{ version: 37, date: '2026-09-04', changes: ['Color-match replacement cards arrive after the Deep resolves a Challenge. Each applied Obstacle accepts one card per Challenge, and each assistant can contribute one card.'] },
|
||||||
|
{ version: 36, date: '2026-09-04', changes: ['Duel instructions clarify that only the defender draws a replacement for matching the Obstacle’s color, even when the defense fails.'] },
|
||||||
|
{ version: 35, date: '2026-09-04', changes: ['Personal objectives now require a group vote. Propose the next objective from a character sheet, then vote Yes or No at the table. A majority approves; the Captain breaks tied votes.'] },
|
||||||
|
{ version: 34, date: '2026-09-04', changes: ['Assistance is available only when a Challenge has multiple active Obstacles. Gat and Name Tax takeovers still work against a single Obstacle.'] },
|
||||||
|
{ version: 33, date: '2026-09-04', changes: ['Hand sizes compare ranks across the crew, including Pi-Rats whose players are the Deep.', 'Discarded cards return to the deck at scene setup. Only the previous Deep can refresh their hand during upkeep, once per scene.'] },
|
||||||
|
{ version: 32, date: '2026-09-04', changes: ['Obstacles are automatically discarded with their columns once they reach one success per player. Unfinished obstacles carry over between scenes.'] },
|
||||||
|
{ version: 31, date: '2026-09-04', changes: ['Change your rank-up vote until everyone has voted. The final vote now reveals the result and advances automatically, without a Ready button.', 'The vote result stays visible through upkeep and next-scene setup.'] },
|
||||||
|
{ version: 30, date: '2026-09-04', changes: ['The crew roster shows voting progress with icons.', 'Your submitted vote now shows the crewmate you nominated.'] },
|
||||||
|
{ version: 29, date: '2026-09-04', changes: ['Removed the remaining deck count from the table.'] },
|
||||||
|
{ version: 28, date: '2026-09-04', changes: ['The Event Log toggle keeps the same size when opened or closed.', 'Dev Mode changes no longer clutter the Event Log.'] },
|
||||||
|
{ version: 27, date: '2026-09-04', changes: ['Shorter labels make the table easier to scan.', 'Difficulty and Successes now line up side by side, including on small screens.'] },
|
||||||
{
|
{
|
||||||
version: 25,
|
version: 25,
|
||||||
date: '2026-07-10',
|
date: '2026-07-10',
|
||||||
|
|||||||
+19
-1229
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
const fills = {};
|
|
||||||
for (const p of state.players) {
|
|
||||||
fills[p.id] = {
|
|
||||||
avatar_look: getSuggestion('look'),
|
|
||||||
avatar_smell: getSuggestion('smell'),
|
|
||||||
first_words: getSuggestion('first_words'),
|
|
||||||
good_at_math: getSuggestion('good_at_math'),
|
|
||||||
like: getSuggestion('like'),
|
|
||||||
hate: getSuggestion('hate'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
const fills = {};
|
||||||
|
for (const p of state.players) {
|
||||||
|
fills[p.id] = {
|
||||||
|
avatar_look: await getSuggestion('look'),
|
||||||
|
avatar_smell: await getSuggestion('smell'),
|
||||||
|
first_words: await getSuggestion('first_words'),
|
||||||
|
good_at_math: await getSuggestion('good_at_math'),
|
||||||
|
like: await getSuggestion('like'),
|
||||||
|
hate: await getSuggestion('hate'),
|
||||||
|
};
|
||||||
|
}
|
||||||
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}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<GameNotebook {state} bind:open={panelOpen} />
|
||||||
|
</div>
|
||||||
</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} />
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
+5
-13
@@ -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,19 +205,14 @@ 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
|
break
|
||||||
reshuffle_discard_pile(db, game)
|
|
||||||
deck = get_game_deck(game)
|
|
||||||
if not deck:
|
|
||||||
# If still empty (all 54 cards are in hands or active), we can't draw
|
|
||||||
break
|
|
||||||
card = deck.pop(0)
|
card = deck.pop(0)
|
||||||
hand.append(card)
|
hand.append(card)
|
||||||
drawn.append(card)
|
drawn.append(card)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
+23
-14
@@ -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,10 +151,18 @@ 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
|
||||||
transition_to_deep_upkeep(db, game.id)
|
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)
|
||||||
|
|
||||||
def transition_to_deep_upkeep(db: Session, game_id: str):
|
def transition_to_deep_upkeep(db: Session, game_id: str):
|
||||||
game = get_game(db, game_id)
|
game = get_game(db, game_id)
|
||||||
@@ -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:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Discards stay out of the deck until the next scene's shuffle.
|
||||||
hand = get_player_hand(player)
|
hand = get_player_hand(player)
|
||||||
deck = get_game_deck(game)
|
|
||||||
|
|
||||||
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
@@ -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):
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
+30
-14
@@ -101,18 +101,8 @@ 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"}
|
||||||
|
|
||||||
# Pi-Rat sets a new name
|
# Pi-Rat sets a new name
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<!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: #9fc9e2;
|
||||||
|
--bg-ocean: #cde6f5;
|
||||||
|
--gold: #a8770f;
|
||||||
|
--neon-cyan: #0c8d96;
|
||||||
|
--red-suit: #c92a35;
|
||||||
|
--black-suit: #2c3245;
|
||||||
|
--glass-bg: rgba(248, 241, 221, 0.92);
|
||||||
|
--glass-border: rgba(168, 119, 15, 0.35);
|
||||||
|
--text-primary: #33291a;
|
||||||
|
--text-muted: #7d6e54;
|
||||||
|
--well-bg: rgba(51, 41, 26, 0.06);
|
||||||
|
--row-border: rgba(168, 119, 15, 0.2);
|
||||||
|
--solid-bg: rgba(255, 250, 236, 0.95);
|
||||||
|
--shadow-panel: 0 8px 32px rgba(36, 62, 87, 0.2);
|
||||||
|
--font-display: 'Pirata One', 'Alegreya SC', serif;
|
||||||
|
--font-heading: 'Alegreya SC', serif;
|
||||||
|
--font-body: 'Alegreya Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg-dark: #0a0d13;
|
||||||
|
--bg-ocean: #131822;
|
||||||
|
--gold: #d9a23c;
|
||||||
|
--neon-cyan: #41c9bd;
|
||||||
|
--red-suit: #e8606e;
|
||||||
|
--black-suit: #dfe6ef;
|
||||||
|
--glass-bg: rgba(27, 33, 46, 0.85);
|
||||||
|
--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: rgba(10, 13, 19, 0.9);
|
||||||
|
--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: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--neon-cyan); }
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 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>3–6 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 scene’s 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 you’re on!</p>
|
||||||
|
<p><strong>Suit themes:</strong> ♣ shootin’ & stabbin’; ♠ sneakin’ & schemin’; ♦ swimmin’ & sailin’; ♥ shoutin’ & 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 card’s 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 Obstacle’s 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; 2–10 = 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-Rat’s 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 don’t 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 you’ve 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 & 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; you’re out of the scene and don’t count as progressing or completing Objectives that scene.</p>
|
||||||
|
<p><strong>Between scenes:</strong> everyone votes for another Pi-Rat who wasn’t 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 & 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>
|
||||||
@@ -336,6 +336,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 & Designed by Nick Smith · Art by J. Richmond · Produced by Sally Hsu ·
|
Written & Designed by Nick Smith · Art by J. Richmond · Produced by Sally Hsu ·
|
||||||
Support: Maia and EmmaVoid
|
Support: Maia and EmmaVoid
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
+419
-20
@@ -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)
|
assert res["drew_card"] is None
|
||||||
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
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -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 captain’s 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() == []
|
||||||
@@ -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())
|
||||||
@@ -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']
|
||||||
Reference in New Issue
Block a user