Compare commits

..

15 Commits

Author SHA1 Message Date
daeac91d07 Dead code cleanups 2026-06-15 07:46:32 -07:00
045857a8fd Refine crew column: names, Captain label, slide-out objectives
Follow-up tweaks to the scene dashboard:
- Bubbles now show the human player name alongside the Pi-Rat name.
- The Captain is named "Captain <name>" (the "Recruit" honorific swapped,
  or "Captain" prepended once Named) instead of a 🛞 icon — applied on
  bubbles and the character-sheet/duel headings via new crewLabel/
  captainName helpers in lib/cards.js.
- "Objectives" → "Crew Objectives" moved to the end of the roster as a
  left-aligned toggle with a right-aligned X/3 completed counter; it now
  slides the detailed list open in place (svelte/transition slide)
  instead of opening a modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:12:44 -07:00
2ea91c066a Refresh the scene dashboard around a crew bubble column
Replace the roster banner and the standalone character-sheet panel with
a left-hand column of crew bubbles. Each bubble shows the player's role
icon, name, rank, current hand size, and a 🛞 for the Captain; clicking
one pops out that player's character sheet as a modal.

- New CrewColumn.svelte: bubbles (Pi-Rats first, Deep last) + an
  Objectives button that opens a crew-objectives popup.
- CharacterSheet.svelte is now a target-driven modal: description,
  likes/hates, and personal objectives for anyone; secret J/Q/K only on
  your own sheet; the Duel UI (no "Challenge whom?" — target is fixed)
  only when a living Pi-Rat views another living Pi-Rat.
- ScenePhase.svelte → 3-column layout (crew | obstacles | hand/deep);
  crew column collapses to a horizontal strip on narrow screens.
- Shared .modal-backdrop/.modal-box/.modal-close moved into components.css;
  removed the now-dead roster-banner and captain-badge styles.

Backend unchanged — the state blob already carries hands, roles, ranks,
objectives, and the captain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:03:41 -07:00
8614a6c820 Collapse between-scenes upkeep into a single panel
During between_scenes there's now just one centered panel with the
voting controls, nomination progress, and Crew Rank Ledger. The separate
"Resting Deep Upkeep" panel (which only said hand refresh would happen
later) is rendered solely during deep_upkeep, where the actual discard/
redraw happens. Moved the rank ledger into the voting panel and added a
.between-grid.single layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:57:55 -07:00
44bbb88836 Add gat-description modal when a Pi-Rat earns a Gat
Mirrors the name modal: earning the Gat objective sets
Player.needs_gat_description, and GatModal.svelte (rendered by Dashboard
across all phases) prompts for a one-of-a-kind Gat description, stored in
Player.gat_description and shown on the character sheet. Like a stolen
Name, the Gat's description travels with it through Gat Tax transfers.

- Player.gat_description / needs_gat_description (+ migration)
- toggle_objective personal_1 sets/clears the prompt
- crud_challenge gat-tax paths move the description with the Gat
- set-gat-description route; CharacterSheet displays it

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:46:02 -07:00
7825ad2cca Toast new events into the Event Log button
When the Event Log is collapsed, a freshly-arrived event now flashes as
a toast anchored above the button, then animates down and fades into it.
Suppressed on the initial state load and on rollback reseeds so it only
fires for genuinely new events; clicking the toast opens the log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:39:54 -07:00
473fd74a00 Point Admin back button at the active game, not the join screen
The Admin page's back button now returns to this browser's own Pi-Rat
dashboard for the game (looked up from the saved sessions), labelled
"← Back to Game". Falls back to the join page only when this browser has
no saved session for the game.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:33:53 -07:00
ceaf88ef22 Stop auto-assigning the Captain when a ship is stolen
Completing Crew Objective 1 (Steal a Ship) no longer anoints the
highest-ranked Pi-Rat as Captain (nor vacates the seat when the ship is
lost). Captaincy is now earned: the crew chooses one via the Captaincy
UI / set_captain and Crew Objective 2. Decoupled captaincy from crew_1
in toggle_objective, dropped the now-unused random import, and updated
test_captaincy_and_hand_sizes accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:30:46 -07:00
39dc07d6b6 Split Deep challenge stakes into success and failure fields
The Deep now states what happens on success and on failure as two
separate fields when calling a Challenge, instead of one free-text
stakes blob. ChallengePanel shows them as " On success" / " On
failure"; the event log lists both.

- Challenge.stakes_success / stakes_failure (+ migration); the old
  single `stakes` column is kept (vestigial) so in-scene rollback
  snapshots taken before the upgrade still deserialize, and ChallengePanel
  falls back to it for any pre-existing challenge.
- create_challenge / route / DeepControlPanel updated; tests adjusted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:28:38 -07:00
5a8919d967 Display rank-up vote winner on the post-voting upkeep screen
The post-voting (deep_upkeep) screen now names who won the rank-up vote
and their new rank, instead of just "Voting complete!". Adds
Game.last_rank_up_player_id (+ migration), set when votes are tallied
and cleared when the next scene begins; falls back to a "no one ranked
up" message when there were no votes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:23:18 -07:00
b37b655118 Confirm hand refresh visually in Deep Upkeep
After a resting Deep player confirms their hand refresh, replace the
discard/keep drag editor with a confirmation panel showing the freshly
drawn hand and a "Waiting for the rest of the crew" message, instead of
re-rendering the editable drag UI (which made it look like nothing
happened). Also stops nagging "please complete your Hand Refresh" once
they're done. Visible whenever they remain in deep_upkeep waiting on
other resting Deep players.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:19:58 -07:00
bfe982251e Add teaching mode: admin can seed themselves as scene-1 Deep
An admin can now opt into "teaching mode" from the lobby, seeding
themselves as the Rank-3 player who must play the Deep in the first
scene (instead of leaving it to the random starting-rank roll), so they
can run the table for new crews.

- Game.teaching_deep_player_id (+ Alembic migration)
- maybe_finish_assign_techniques honors it before rolling ranks, so both
  the normal flow and the dev-mode skip path respect it
- Admin-gated POST .../teaching-mode toggle (pre-rank phases only)
- Lobby checkbox UI, greyed out if another admin already volunteered

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:14:58 -07:00
5a9d712c95 Add Cancel button to character-creation Revise flows
Clicking Revise on basic records, techniques, or J/Q/K assignment now
offers a Cancel button that throws away any edits made since Revise and
restores the previously-saved values. Basic-records cancel is a pure
client-side toggle; techniques/face cancel re-submit a snapshot taken at
Revise time (since Revise immediately unsubmits/unassigns server-side).
Also added Cancel to the recruit-creation basic-records revise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:09:24 -07:00
45fefc0c71 Add modal name popup when a Pi-Rat earns a Name
When a Pi-Rat completes their second objective and ranks up, they now
get a blocking modal popup (NameModal.svelte, rendered by Dashboard
across all phases) to claim their Pirate Name, rather than an optional
box buried in their character sheet. Removed the old inline prompt-box
and its handler from CharacterSheet.svelte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:05:02 -07:00
f40724c9d2 Clean up ruff E701 warnings
Split multi-statement-on-one-line colon constructs in crud_scene.py
and routes_scene.py onto separate lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:00:44 -07:00
39 changed files with 1690 additions and 749 deletions

View File

@@ -12,3 +12,11 @@ After editing `src/pirats/models.py`, generate a migration and sanity-check it:
``` ```
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.
## 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.
## Work order
You will be working on tasks in [TODO.md](./TODO.md]. Work on at most two tasks at a time (one is preferable, but two is fine if they dove tail really nicely), and after you finish each task, make a git commit and update the TODO list to check off the completed task.

11
TODO.md
View File

@@ -1,17 +1,18 @@
## Major features ## UI Polish
- [x] **Rollback of game state.** Full-state JSON snapshots in a `Checkpoint` table, captured per action by the broadcast middleware, restored by `crud_rollback.rollback_to_checkpoint`; scene-confined; server-enforced for Admins and Deep players. Rolling back is non-destructive — it moves a head pointer (`Game.rollback_head_checkpoint_id`), so later log entries grey out and can be redone (roll forward); the abandoned future is only discarded when a new action is taken. UI is the per-event rollback/redo buttons + greyed entries + "rolled back" banner in `EventLog.svelte`.
- [ ] On the home page, rejoin a crew should be above muster a crew, and there should be a gap between the two boxes
## Words Words Words ## Words Words Words
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing. - [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
- [ ] **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. - [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient - [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
- [ ] **Version number and change log**
## Security ## Security
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates. - [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates.
## Cleanup ## Gameplay gaps
- [ ] **Look for dead code.** While implementing the rollback feature, we discovered /scene/rollback, an unused and buggy single-card-play rollback route that was added but never hooked up to the UI. Look for other code that matches that pattern -- orphan functions and routes that never get called. - [x] **Finish the Rank-3 story bonus.** A Pi-Rat who completes their 3rd Personal Objective (RULEBOOK §93) now gets a "⭐ Your Story is Complete!" modal (`RankBonusModal.svelte`, keyed on `needs_rank_3_bonus`) to grant another eligible crewmate +1 Rank — or forfeit it when none qualify. *(Done 2026-06-15: logic in `crud_scene.grant_story_bonus_rank` reusing the shared `is_eligible_nominee` guard; route slimmed to the (ok, msg) convention; unit tests added; verified end-to-end in the browser.)*
- [ ] **Clean up ruff E701 warnings**

View File

@@ -398,25 +398,6 @@
color: var(--ghost); color: var(--ghost);
} }
.captain-badge {
background: color-mix(in srgb, var(--accent) 14%, transparent);
border: 1px solid var(--accent);
color: var(--accent);
padding: 0.4rem 1rem;
border-radius: 20px;
font-size: 1rem;
font-weight: 700;
font-family: var(--font-heading);
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.captain-badge.vacant {
background: color-mix(in srgb, var(--text) 4%, transparent);
border: 1px dashed var(--edge);
color: var(--text-muted);
}
/* --- Waiting indicator --- */ /* --- Waiting indicator --- */
.waiting-box, .waiting-indicator { .waiting-box, .waiting-indicator {
@@ -428,3 +409,135 @@
gap: 0.75rem; gap: 0.75rem;
} }
/* --- Modal overlay (shared by popups) --- */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
backdrop-filter: blur(6px);
}
.modal-box {
position: relative;
max-width: 440px;
width: 100%;
max-height: 88vh;
overflow-y: auto;
padding: 1.75rem;
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
}
.modal-box h3 {
margin-top: 0;
}
.modal-box.sheet-modal {
max-width: 520px;
text-align: left;
}
.modal-close {
position: absolute;
top: 0.6rem;
right: 0.75rem;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
padding: 0.1rem 0.4rem;
border-radius: var(--radius-sm);
}
.modal-close:hover {
color: var(--text);
background: color-mix(in srgb, var(--text) 10%, transparent);
}
/* --- Graphical tooltip (lib/tooltip.js action) --- */
.tooltip-pop {
position: fixed;
z-index: 3000;
max-width: 280px;
padding: 0.6rem 0.7rem;
background: var(--surface-raised);
border: 1px solid var(--edge-accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
color: var(--text);
font-family: var(--font-body);
font-size: 0.82rem;
line-height: 1.4;
pointer-events: none;
opacity: 0;
transform: translateY(3px);
transition: opacity 0.12s ease, transform 0.12s ease;
}
.tooltip-pop.visible {
opacity: 1;
transform: translateY(0);
}
.tooltip-pop .tt-head {
display: flex;
align-items: baseline;
gap: 0.4rem;
margin-bottom: 0.3rem;
}
.tooltip-pop .tt-card {
font-family: var(--font-heading);
font-weight: 900;
font-size: 1rem;
}
.tooltip-pop .tt-card.tt-red { color: var(--suit-red); }
.tooltip-pop .tt-card.tt-black { color: var(--suit-black); }
.tooltip-pop .tt-card.tt-joker { color: var(--accent); }
.tooltip-pop .tt-color {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
padding: 0.05rem 0.35rem;
border-radius: 999px;
}
.tooltip-pop .tt-color-red {
color: var(--suit-red);
background: color-mix(in srgb, var(--suit-red) 16%, transparent);
}
.tooltip-pop .tt-color-black {
color: var(--suit-black);
background: color-mix(in srgb, var(--suit-black) 16%, transparent);
}
.tooltip-pop .tt-theme {
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.tooltip-pop .tt-row {
margin-top: 0.25rem;
}
.tooltip-pop .tt-label {
display: block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
font-weight: 700;
}

View File

@@ -13,6 +13,22 @@
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.teaching-box {
text-align: left;
margin-bottom: 2rem;
padding: 1rem 1.25rem;
}
.teaching-box .checkbox-label.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.teaching-box .info-text {
margin: 0.5rem 0 0;
font-size: 0.85rem;
}
.link-item { .link-item {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }

View File

@@ -1,8 +1,20 @@
/* --- Scene board layout --- */ /* --- Scene board layout --- */
.scene-view-layout { .scene-view-layout {
display: grid; display: grid;
grid-template-columns: 1.4fr 1fr; grid-template-columns: 180px minmax(0, 1.7fr) minmax(300px, 1fr);
gap: 2rem; gap: 1.5rem;
align-items: start;
}
/* The middle column stacks hand / challenge / obstacle cards. */
.table-column {
display: flex;
flex-direction: column;
}
/* Stretch the log column to the full row height so its sticky panel can travel. */
.log-column {
align-self: stretch;
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
@@ -11,6 +23,128 @@
} }
} }
/* --- Crew bubble column --- */
.crew-column {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.crew-bubbles {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.crew-bubble {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
width: 100%;
text-align: left;
padding: 0.55rem 0.7rem;
background: var(--well);
border: 1px solid var(--edge);
border-left: 4px solid var(--pirat);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition-smooth);
}
.crew-bubble:hover {
background: color-mix(in srgb, var(--text) 8%, transparent);
transform: translateX(2px);
}
.crew-bubble.is-deep {
border-left-color: var(--deep);
background: color-mix(in srgb, var(--deep) 6%, transparent);
}
.crew-bubble.is-you {
border-color: var(--accent);
border-left-color: var(--accent);
}
.crew-bubble.is-ghost { opacity: 0.7; filter: grayscale(0.6); }
.crew-bubble.is-dead { opacity: 0.6; filter: grayscale(0.8); }
.crew-bubble .bubble-icon {
font-size: 1.1rem;
}
.crew-bubble .bubble-name {
font-weight: 700;
font-size: 0.9rem;
line-height: 1.15;
overflow-wrap: anywhere;
}
.crew-bubble .bubble-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Crew Objectives toggle at the end of the roster */
.crew-objectives-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
text-align: left;
padding: 0.55rem 0.7rem;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid var(--accent);
border-radius: var(--radius-md);
color: var(--text);
font-family: var(--font-heading);
font-size: 0.85rem;
cursor: pointer;
transition: var(--transition-smooth);
}
.crew-objectives-toggle:hover {
background: color-mix(in srgb, var(--accent) 22%, transparent);
}
.crew-objectives-toggle .co-count {
font-weight: 700;
color: var(--accent);
}
.crew-objectives-detail {
padding: 0.6rem 0.7rem;
background: var(--well);
border: 1px solid var(--edge);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
}
/* On narrow screens the crew column becomes a horizontal strip above the board */
@media (max-width: 1100px) {
.crew-bubbles {
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
}
.crew-bubble {
width: auto;
flex: 1 1 140px;
}
.crew-objectives-toggle {
width: auto;
flex: 1 1 100%;
}
.crew-objectives-detail {
flex: 1 1 100%;
}
}
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -32,44 +166,6 @@
font-weight: 700; font-weight: 700;
} }
/* Scene status banner (captain + roster) */
.scene-status-banner {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--well);
border: 1px solid var(--edge);
border-radius: var(--radius-md);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.roster-chips {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.roster-chips .roster-label {
font-size: 0.85rem;
color: var(--text-muted);
font-family: var(--font-heading);
margin-right: 0.25rem;
}
.roster-chips .player-chip {
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
border-radius: 12px;
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
/* --- Obstacles --- */ /* --- Obstacles --- */
.obstacles-container { .obstacles-container {
display: flex; display: flex;
@@ -90,7 +186,7 @@
} }
.obstacle-item.in-challenge { .obstacle-item.in-challenge {
box-shadow: 0 0 0 2px var(--danger); box-shadow: 0 0 0 2px var(--deep);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -111,10 +207,56 @@
.obstacle-card-wrapper { .obstacle-card-wrapper {
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 0.3rem;
} }
.card-caption {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
/* Obstacle color (red/black) — fixed by the original card; matching it draws back */
.obstacle-color-tag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.4rem;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.12rem 0.5rem;
border-radius: 999px;
cursor: help;
}
.obstacle-color-tag::before {
content: '';
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
display: inline-block;
}
.obstacle-color-tag.red {
color: var(--suit-red);
background: color-mix(in srgb, var(--suit-red) 14%, transparent);
}
.obstacle-color-tag.red::before { background: var(--suit-red); }
.obstacle-color-tag.black {
color: var(--suit-black);
background: color-mix(in srgb, var(--suit-black) 14%, transparent);
}
.obstacle-color-tag.black::before { background: var(--suit-black); }
.suit-badge { .suit-badge {
font-weight: 900; font-weight: 900;
font-size: 1.1rem; font-size: 1.1rem;
@@ -136,7 +278,7 @@
} }
.obstacle-details .in-challenge-tag { .obstacle-details .in-challenge-tag {
color: var(--danger); color: var(--deep);
font-size: 0.8rem; font-size: 0.8rem;
} }
@@ -209,19 +351,25 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
/* --- Challenges --- */ /* --- Challenges --- (framed in the Deep's teal so red/black always means suit color) */
/* The challenge-area card IS the box; the challenge content sits flush inside it. */
.challenge-area-card {
border-color: color-mix(in srgb, var(--deep) 45%, transparent);
}
.challenge-item { .challenge-item {
background: color-mix(in srgb, var(--danger) 5%, transparent);
border: 1px solid var(--danger);
border-radius: var(--radius-md);
padding: 1rem 1.25rem;
margin-bottom: 1rem; margin-bottom: 1rem;
text-align: left; text-align: left;
} }
.challenge-item + .challenge-item {
border-top: 1px solid var(--edge-soft);
padding-top: 1rem;
}
.challenge-item h4 { .challenge-item h4 {
color: var(--danger); color: var(--deep);
font-size: 1.15rem; font-size: 1.25rem;
margin: 0; margin: 0;
} }
@@ -252,37 +400,31 @@
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
} }
/* --- Deep control panel --- */ /* --- Challenge area (middle column) --- */
.deep-panel-card { /* Obstacles applied to an active Challenge, pulled up into the Challenge panel */
max-height: 80vh; .challenge-obstacles {
overflow-y: auto;
border-color: color-mix(in srgb, var(--deep) 40%, transparent);
}
.deep-divider {
border: 0;
height: 1px;
background: linear-gradient(to right, transparent, color-mix(in srgb, var(--deep) 40%, transparent), transparent);
margin: 2rem 0;
}
.obstacles-checkboxes {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 1rem;
background: var(--well); margin-top: 0.75rem;
padding: 1rem;
border-radius: var(--radius-sm);
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--edge);
} }
.objective-player-block { /* The Deep's call-a-challenge + end-scene controls live below any open Challenges */
background: var(--well); .deep-challenge-controls {
padding: 0.5rem 0.75rem; margin-top: 0.5rem;
border-radius: var(--radius-sm); }
margin-bottom: 0.5rem;
/* When a Challenge is active the rest of the Obstacle list collapses out of the way */
.obstacle-collapse > summary {
cursor: pointer;
color: var(--text-muted);
font-family: var(--font-heading);
font-size: 0.9rem;
padding: 0.35rem 0;
}
.obstacle-collapse[open] > summary {
margin-bottom: 1rem;
} }
.scene-upkeep-controls { .scene-upkeep-controls {

View File

@@ -6,6 +6,14 @@
margin: 2rem 0; margin: 2rem 0;
} }
/* Between scenes there's only the voting/roster panel — center it. */
.between-grid.single {
grid-template-columns: 1fr;
max-width: 640px;
margin-left: auto;
margin-right: auto;
}
@media (max-width: 800px) { @media (max-width: 800px) {
.between-grid { .between-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -1,5 +1,6 @@
<script> <script>
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, obstacleTable } from '../lib/cards'; import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, cardTooltipHtml, obstacleTable } from '../lib/cards';
import { tooltip } from '../lib/tooltip';
export let card; // card code, e.g. "10D" or "Joker1" export let card; // card code, e.g. "10D" or "Joker1"
export let size = 'large'; // 'large' | 'medium' | 'mini' export let size = 'large'; // 'large' | 'medium' | 'mini'
@@ -7,7 +8,7 @@
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; // mini: true/false adds success/failure styling
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 = ''; 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
export let style = ''; export let style = '';
@@ -16,20 +17,23 @@
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)]; $: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase(); $: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
$: techName = techs && !joker ? techs[cardValue(card)] : null; $: techName = techs && !joker ? techs[cardValue(card)] : null;
// Tooltip: explicit title wins; otherwise the suit's theme plus what the // Tooltip: an explicit title wins (plain text); otherwise a rich graphical
// card would represent as an Obstacle (recomputes once the table loads) // tooltip describing the card itself (suit theme + Obstacle meaning), which
$: tooltip = title || cardTooltip(card, $obstacleTable); // recomputes once the obstacle table loads.
$: tipContent = title ? title : { html: cardTooltipHtml(card, $obstacleTable) };
$: ariaLabel = title || cardTooltip(card, $obstacleTable);
</script> </script>
{#if size === 'mini'} {#if size === 'mini'}
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}" title={tooltip}> <div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}"
use:tooltip={tipContent} aria-label={ariaLabel}>
<span class="val">{val}</span> <span class="val">{val}</span>
<span class="suit">{suit}</span> <span class="suit">{suit}</span>
{#if owner}<span class="owner">{owner}</span>{/if} {#if owner}<span class="owner">{owner}</span>{/if}
</div> </div>
{:else} {:else}
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}" <div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
title={tooltip} {draggable} {style} use:tooltip={tipContent} aria-label={ariaLabel} {draggable} {style}
on:dragstart on:dragend on:click> on:dragstart on:dragend on:click>
<div class="card-corner top-left"> <div class="card-corner top-left">
<span class="val">{val}</span> <span class="val">{val}</span>
@@ -44,7 +48,7 @@
</div> </div>
{#if techName} {#if techName}
<div class="card-tech-overlay"> <div class="card-tech-overlay">
<div class="tech-tag" title="Automatic success when played">{cardValue(card)}: "{techName}"</div> <div class="tech-tag" use:tooltip={"Automatic success when played"}>{cardValue(card)}: "{techName}"</div>
</div> </div>
{/if} {/if}
<div class="card-corner bottom-right"> <div class="card-corner bottom-right">

View File

@@ -40,6 +40,11 @@
editingBasic = true; editingBasic = true;
} }
// Discard any edits made since clicking Revise and return to the saved view.
function cancelReviseBasic() {
editingBasic = false;
}
// Like/Hate tasks are assigned automatically when character creation starts. // Like/Hate tasks are assigned automatically when character creation starts.
// This is a fallback for games that entered the phase before assignments existed. // This is a fallback for games that entered the phase before assignments existed.
onMount(async () => { onMount(async () => {
@@ -63,6 +68,10 @@
let tech1 = '', tech2 = '', tech3 = ''; let tech1 = '', tech2 = '', tech3 = '';
let savingTechs = false; let savingTechs = false;
let techError = ''; let techError = '';
// True while editing previously-submitted techniques (vs. first-time entry),
// so we know to offer a Cancel button. techSnapshot holds the saved values.
let revisingTechs = false;
let techSnapshot = [];
async function submitTechniques() { async function submitTechniques() {
savingTechs = true; savingTechs = true;
@@ -71,6 +80,7 @@
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', { await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
tech1, tech2, tech3 tech1, tech2, tech3
}); });
revisingTechs = false;
} catch(e) { } catch(e) {
techError = e.message; techError = e.message;
} finally { } finally {
@@ -79,12 +89,33 @@
} }
async function reviseTechniques() { async function reviseTechniques() {
techSnapshot = [...createdTechniques];
[tech1 = '', tech2 = '', tech3 = ''] = createdTechniques; [tech1 = '', tech2 = '', tech3 = ''] = createdTechniques;
revisingTechs = true;
techError = ''; techError = '';
try { try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unsubmit-techniques`, 'POST'); await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unsubmit-techniques`, 'POST');
} catch(e) { } catch(e) {
techError = e.message; techError = e.message;
revisingTechs = false;
}
}
// Throw away edits and re-submit the techniques as they were before Revise.
async function cancelReviseTechniques() {
savingTechs = true;
techError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
tech1: techSnapshot[0] || '',
tech2: techSnapshot[1] || '',
tech3: techSnapshot[2] || ''
});
revisingTechs = false;
} catch(e) {
techError = e.message;
} finally {
savingTechs = false;
} }
} }
@@ -127,6 +158,9 @@
let jackTech = '', queenTech = '', kingTech = ''; let jackTech = '', queenTech = '', kingTech = '';
let assigningFace = false; let assigningFace = false;
let faceError = ''; let faceError = '';
// True while revising a previously-assigned J/Q/K layout, so we offer Cancel.
let revisingFace = false;
let faceSnapshot = {};
async function assignFaceTechniques() { async function assignFaceTechniques() {
assigningFace = true; assigningFace = true;
@@ -137,6 +171,7 @@
queen: queenTech, queen: queenTech,
king: kingTech king: kingTech
}); });
revisingFace = false;
} catch(e) { } catch(e) {
faceError = e.message; faceError = e.message;
} finally { } finally {
@@ -145,14 +180,39 @@
} }
async function reviseFaceTechniques() { async function reviseFaceTechniques() {
faceSnapshot = {
jack: state.player.tech_jack,
queen: state.player.tech_queen,
king: state.player.tech_king
};
jackTech = state.player.tech_jack; jackTech = state.player.tech_jack;
queenTech = state.player.tech_queen; queenTech = state.player.tech_queen;
kingTech = state.player.tech_king; kingTech = state.player.tech_king;
revisingFace = true;
faceError = ''; faceError = '';
try { try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unassign-face-techniques`, 'POST'); await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unassign-face-techniques`, 'POST');
} catch(e) { } catch(e) {
faceError = e.message; faceError = e.message;
revisingFace = false;
}
}
// Throw away edits and re-assign the J/Q/K layout as it was before Revise.
async function cancelReviseFaceTechniques() {
assigningFace = true;
faceError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/assign-face-techniques`, 'POST', {
jack: faceSnapshot.jack,
queen: faceSnapshot.queen,
king: faceSnapshot.king
});
revisingFace = false;
} catch(e) {
faceError = e.message;
} finally {
assigningFace = false;
} }
} }
@@ -245,7 +305,12 @@
{#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={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button> <div class="input-row mt-4">
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
{#if basicComplete}
<button type="button" class="btn btn-secondary" disabled={savingBasic} on:click={cancelReviseBasic}>Cancel</button>
{/if}
</div>
</form> </form>
{/if} {/if}
</div> </div>
@@ -350,6 +415,9 @@
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button> <button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
{#if revisingTechs}
<button type="button" class="btn btn-secondary btn-full mt-4" disabled={savingTechs} on:click={cancelReviseTechniques}>Cancel keep my original techniques</button>
{/if}
</form> </form>
{:else if phase === 'character_creation'} {:else if phase === 'character_creation'}
<div class="submitted-techniques text-center mt-4"> <div class="submitted-techniques text-center mt-4">
@@ -470,6 +538,13 @@
> >
{assigningFace ? 'Assigning...' : 'Assign to Cards'} {assigningFace ? 'Assigning...' : 'Assign to Cards'}
</button> </button>
{#if revisingFace}
<button
class="btn btn-secondary w-full mt-4"
disabled={assigningFace}
on:click={cancelReviseFaceTechniques}
>Cancel keep my original assignment</button>
{/if}
</div> </div>
</div> </div>
{:else} {:else}

View File

@@ -1,8 +1,11 @@
<script> <script>
import { tick } from 'svelte'; import { tick, onMount } from 'svelte';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
export let state; export let state;
// Inline mode pins the log open as a column (scene phase) instead of the
// floating, collapsible corner panel used in every other phase.
export let inline = false;
const PAGE_SIZE = 50; const 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"
@@ -27,6 +30,8 @@
}; };
let open = false; let open = false;
// The content is visible whenever the floating log is open OR it's pinned inline.
$: shown = open || inline;
let logEl; let logEl;
let events = []; // ascending by timestamp; accumulated across polls + history loads let events = []; // ascending by timestamp; accumulated across polls + history loads
let seenIds = new Set(); let seenIds = new Set();
@@ -35,6 +40,11 @@
let atBottom = true; let atBottom = true;
let error = ''; let error = '';
let timelineVersion = null; let timelineVersion = null;
// Toast that briefly previews a freshly-arrived event while the log is
// collapsed, then animates down into the Event Log button.
let toast = null; // { id, message, kind }
let initialized = false; // suppresses a toast flood on the first state load
// Rollback buttons appear during a scene, for Admins and Deep players (server-enforced too). // Rollback buttons appear during a scene, for Admins and Deep players (server-enforced too).
$: canRollback = state.game?.phase === 'scene' $: canRollback = state.game?.phase === 'scene'
@@ -57,29 +67,50 @@
// action from a rolled-back state truncates the abandoned future and bumps the // action from a rolled-back state truncates the abandoned future and bumps the
// version, which tells us to drop what we'd accumulated and reseed. // version, which tells us to drop what we'd accumulated and reseed.
const version = state.game?.rollback_timeline_version ?? 0; const version = state.game?.rollback_timeline_version ?? 0;
let reset = false;
if (timelineVersion !== null && version !== timelineVersion) { if (timelineVersion !== null && version !== timelineVersion) {
events = []; events = [];
seenIds = new Set(); seenIds = new Set();
reset = true;
} }
timelineVersion = version; timelineVersion = version;
if (events.length === 0) { if (events.length === 0) {
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE; haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
} }
let added = false; let added = false;
let newest = null;
for (const e of polled) { for (const e of polled) {
if (!seenIds.has(e.id)) { if (!seenIds.has(e.id)) {
seenIds.add(e.id); seenIds.add(e.id);
events.push(e); events.push(e);
added = true; added = true;
newest = e;
} }
} }
if (added) { if (added) {
events = events; events = events;
if (open && atBottom) { if (shown && atBottom) {
await tick(); await tick();
scrollToBottom(); scrollToBottom();
} }
// Toast only for genuinely new events (not the initial seed or a
// rollback reseed) and only while the floating log is closed.
if (initialized && !reset && !shown && newest) {
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
}
} }
initialized = true;
}
// An open/inline log makes the preview toast redundant.
$: if (shown) toast = null;
onMount(() => {
if (inline) scrollToBottom();
});
function clearToast(id) {
if (toast && toast.id === id) toast = null;
} }
function scrollToBottom() { function scrollToBottom() {
@@ -148,12 +179,29 @@
} }
</script> </script>
<div class="floating-event-log {open ? 'open' : ''}"> <div class="floating-event-log {shown ? 'open' : ''} {inline ? 'inline' : ''}">
<button class="toggle-log-btn" on:click={toggleOpen}> {#if toast && !shown}
{open ? '⬇️ Hide Log' : '📜 Event Log'} {#key toast.id}
</button> <button
class="event-toast log-kind-{toast.kind}"
title="Open the Event Log"
on:click={toggleOpen}
on:animationend={() => clearToast(toast.id)}
>
<span class="log-icon">{KIND_ICONS[toast.kind] || KIND_ICONS.info}</span>
<span class="toast-message">{toast.message}</span>
</button>
{/key}
{/if}
{#if inline}
<div class="log-header">📜 Event Log</div>
{:else}
<button class="toggle-log-btn" on:click={toggleOpen}>
{open ? '⬇️ Hide Log' : '📜 Event Log'}
</button>
{/if}
{#if open} {#if shown}
{#if error} {#if error}
<div class="log-error">{error}</div> <div class="log-error">{error}</div>
{/if} {/if}
@@ -218,6 +266,29 @@
.floating-event-log:not(.open) { .floating-event-log:not(.open) {
width: auto; width: auto;
} }
/* Inline mode: pinned as the scene's third column instead of floating. */
.floating-event-log.inline {
position: sticky;
top: 1rem;
right: auto;
bottom: auto;
width: 100%;
max-height: calc(100vh - 2rem);
}
@media (max-width: 1100px) {
.floating-event-log.inline {
position: static;
max-height: 60vh;
}
}
.log-header {
padding: 10px;
text-align: center;
font-weight: bold;
font-family: var(--font-heading);
border-bottom: 1px solid var(--edge-soft);
color: var(--text);
}
.toggle-log-btn { .toggle-log-btn {
background: transparent; background: transparent;
color: var(--text); color: var(--text);
@@ -352,6 +423,48 @@
filter: brightness(1.1); filter: brightness(1.1);
} }
/* Toast preview of a fresh event, anchored above the button */
.event-toast {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
width: 320px;
max-width: 80vw;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
padding: 10px 12px;
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
border: 1px solid var(--edge);
border-left: 4px solid var(--accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
backdrop-filter: blur(10px);
color: var(--text);
font-family: var(--font-body);
cursor: pointer;
transform-origin: bottom right;
animation: event-toast-into-button 3.4s ease-in forwards;
}
.event-toast .toast-message {
flex: 1;
font-size: 0.85rem;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
@keyframes event-toast-into-button {
0% { opacity: 0; transform: translateY(-6px) scale(0.96); }
8% { opacity: 1; transform: translateY(0) scale(1); }
75% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(40px) scale(0.4); }
}
@media (prefers-reduced-motion: reduce) {
.event-toast { animation-duration: 2.5s; animation-timing-function: linear; }
}
/* Accent colors by event kind */ /* Accent colors by event kind */
.log-kind-challenge { border-left-color: var(--danger); } .log-kind-challenge { border-left-color: var(--danger); }
.log-kind-card { border-left-color: var(--deep); } .log-kind-card { border-left-color: var(--deep); }

View File

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

View File

@@ -24,6 +24,19 @@
starting = false; starting = false;
} }
} }
$: teaching = state.game.teaching_deep_player_id === state.player.id;
$: teachingTakenByOther = state.game.teaching_deep_player_id && !teaching;
async function toggleTeaching() {
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/teaching-mode`, 'POST', {
enabled: !teaching
});
} catch (err) {
console.error("Failed to toggle teaching mode:", err);
}
}
</script> </script>
<div class="lobby-view text-center"> <div class="lobby-view text-center">
@@ -72,6 +85,22 @@
{/if} {/if}
</div> </div>
{#if state.player.is_admin}
<div class="teaching-box glass-panel">
<label class="checkbox-label" class:disabled={teachingTakenByOther}>
<input type="checkbox" checked={teaching} disabled={teachingTakenByOther} on:change={toggleTeaching}>
<span>📚 <strong>Teaching mode:</strong> I'll be the Deep for the first scene</span>
</label>
<p class="info-text">
{#if teachingTakenByOther}
Another admin has already volunteered to teach as the Deep.
{:else}
Seeds you as the Rank-3 player who must play the Deep in scene 1, so you can run the table for new crews. Otherwise it's assigned at random.
{/if}
</p>
</div>
{/if}
<div class="lobby-action"> <div class="lobby-action">
{#if state.player.is_admin} {#if state.player.is_admin}
{#if state.players.length >= 3 || state.game.dev_mode} {#if state.players.length >= 3 || state.game.dev_mode}

View File

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

View File

@@ -56,6 +56,11 @@
editingBasic = true; editingBasic = true;
} }
// Discard any edits made since clicking Revise and return to the saved view.
function cancelReviseBasic() {
editingBasic = false;
}
$: basicComplete = !!(me.avatar_look && me.avatar_smell && me.first_words && me.good_at_math); $: basicComplete = !!(me.avatar_look && me.avatar_smell && me.first_words && me.good_at_math);
$: likeDone = !me.other_like_from_player_id || !!me.other_like; $: likeDone = !me.other_like_from_player_id || !!me.other_like;
$: hateDone = !me.other_hate_from_player_id || !!me.other_hate; $: hateDone = !me.other_hate_from_player_id || !!me.other_hate;
@@ -191,7 +196,12 @@
{#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={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button> <div class="input-row mt-4">
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
{#if basicComplete}
<button type="button" class="btn btn-secondary" disabled={savingBasic} on:click={cancelReviseBasic}>Cancel</button>
{/if}
</div>
</form> </form>
{/if} {/if}
</div> </div>

View File

@@ -1,75 +1,28 @@
<script> <script>
import Card from './Card.svelte'; import Card from './Card.svelte';
import { displayName } from '../lib/cards';
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 DeepControlPanel from './scene/DeepControlPanel.svelte'; import CrewColumn from './scene/CrewColumn.svelte';
import CharacterSheet from './scene/CharacterSheet.svelte'; import EventLog from './EventLog.svelte';
export let state; export let state;
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; $: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
$: isCaptain = state.game.captain_player_id === state.player.id;
$: 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';
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
// The Challenge area is shown when something is happening there, or to the Deep
// (who calls Challenges and ends the scene from it).
$: showChallengeArea = isDeep || openChallenges.length > 0;
</script> </script>
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}> <div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
<!-- LEFT COLUMN: The Shared Table --> <!-- LEFT COLUMN: The Crew -->
<CrewColumn {state} />
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
<div class="table-column"> <div class="table-column">
{#if !isDeep}
<!-- Game Deck and Obstacles Roster -->
<div class="card glass-panel obstacle-list-card">
<div class="card-header">
<h3>🌊 The Obstacle List</h3>
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
</div>
<div class="obstacles-container" id="scene-obstacles-container">
<!-- TOP STATUS BAR: Scene Info and Captain -->
<div class="scene-status-banner">
<!-- Captain Badge -->
<div>
{#if captain}
<span class="captain-badge">
🏴‍☠️ Captain: {displayName(captain)} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
</span>
{:else}
<span class="captain-badge vacant">
🏴‍☠️ Captain: None{state.game.completed_crew_1 ? '' : ' (steal a ship first!)'}
</span>
{/if}
</div>
<!-- Active Scene Roster Mini-list -->
<div class="roster-chips">
<span class="roster-label">Active Roster:</span>
{#each state.players as p}
{#if p.role === 'deep'}
<span class="player-chip deep-chip">
🌊 {displayName(p)} (Deep)
</span>
{:else if p.role === 'pirat'}
<span class="player-chip pirat-chip {p.is_ghost ? 'is-ghost' : ''} {p.is_dead ? 'is-dead' : ''} {p.id === state.game.captain_player_id ? 'is-captain' : ''}">
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {displayName(p)} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
</span>
{/if}
{/each}
</div>
</div>
<ChallengePanel {state} />
<ObstacleBoard {state} />
</div>
</div>
</div>
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
<div class="private-column">
{#if state.player.role === "deep"}
<DeepControlPanel {state} />
{:else}
<!-- Player Hand Card -->
<div class="card glass-panel hand-card"> <div class="card glass-panel hand-card">
<h3>🃏 Your Hand</h3> <h3>🃏 Your Hand</h3>
<p class="section-desc">Keep your cards secret! When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.</p> <p class="section-desc">Keep your cards secret! When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.</p>
@@ -90,8 +43,25 @@
{/each} {/each}
</div> </div>
</div> </div>
<CharacterSheet {state} />
{/if} {/if}
{#if showChallengeArea}
<div class="card glass-panel challenge-area-card">
<ChallengePanel {state} />
</div>
{/if}
<div class="card glass-panel obstacle-list-card">
<div class="card-header">
<h3>🌊 The Obstacle List</h3>
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
</div>
<ObstacleBoard {state} />
</div>
</div>
<!-- RIGHT COLUMN: The Event Log -->
<div class="log-column">
<EventLog {state} inline />
</div> </div>
</div> </div>

View File

@@ -13,6 +13,10 @@
// Helpers // Helpers
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; $: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
// Winner of the rank-up vote, surfaced on the post-voting (deep_upkeep) screen.
$: rankUpWinner = state.game.last_rank_up_player_id
? state.players.find(p => p.id === state.game.last_rank_up_player_id)
: null;
// A nominee must be another living, in-play Pi-Rat: not the voter, not a // A nominee must be another living, in-play Pi-Rat: not the voter, not a
// previous-scene Deep, and not dead / awaiting-recruit (they'd reset to // previous-scene Deep, and not dead / awaiting-recruit (they'd reset to
@@ -194,7 +198,7 @@
<p style="margin: 0;">🐀 A fresh recruit is on the way! Once upkeep wraps up, you and the crew will create your new Pi-Rat together.</p> <p style="margin: 0;">🐀 A fresh recruit is on the way! Once upkeep wraps up, you and the crew will create your new Pi-Rat together.</p>
</div> </div>
{/if} {/if}
<div class="between-grid"> <div class="between-grid" class:single={state.game.phase !== 'deep_upkeep'}>
<!-- Ranks and Voting Card --> <!-- Ranks and Voting Card -->
<div class="card glass-panel voting-card"> <div class="card glass-panel voting-card">
<h3>1. Pirate Ranking (Voting)</h3> <h3>1. Pirate Ranking (Voting)</h3>
@@ -202,7 +206,11 @@
{#if state.game.phase === 'deep_upkeep'} {#if state.game.phase === 'deep_upkeep'}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Voting complete! Results tallied.</p> {#if rankUpWinner}
<p class="success-text">🏆 {displayName(rankUpWinner)} won the vote and ranked up to Rank {rankUpWinner.rank}!</p>
{:else}
<p class="success-text">✔️ Voting complete! No one ranked up this time.</p>
{/if}
</div> </div>
{:else if hasVoted} {:else if hasVoted}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
@@ -242,19 +250,43 @@
</div> </div>
</div> </div>
{/if} {/if}
<div class="roster-sheet-summary margin-top text-left glass-panel">
<h4>Crew Rank Ledger:</h4>
<ul class="ranks-ledger">
{#each state.players as p}
<li>
<strong>{displayName(p)}:</strong> Rank {p.rank}
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
</li>
{/each}
</ul>
</div>
</div> </div>
<!-- Resting Deep Player Card --> <!-- Resting Deep Player Card — only the actual hand refresh, during deep_upkeep -->
{#if state.game.phase === 'deep_upkeep'}
<div class="card glass-panel deep-rest-card"> <div class="card glass-panel deep-rest-card">
<h3>2. Resting Deep Upkeep</h3> <h3>2. Resting Deep Upkeep</h3>
{#if state.player.role === "deep"} {#if state.player.role === "deep"}
<div class="deep-rest-panel glass-panel"> <div class="deep-rest-panel glass-panel">
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p> <p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p>
{#if state.game.phase === 'between_scenes'} {#if state.player.is_ready}
<p class="info-text text-center gold-text" style="margin-top: 1rem;">Voting is currently in progress. Hand refresh will begin after voting concludes.</p> <!-- Confirmation: the refresh went through; show the freshly-drawn hand. -->
{:else if state.game.phase === 'deep_upkeep'} <div class="voted-confirmation-box glass-panel" style="margin-top: 1rem;">
<p class="success-text">✔️ Hand refreshed! You're holding {hand.length} card{hand.length === 1 ? '' : 's'} for the next scene.</p>
</div>
<div class="upkeep-flex" style="justify-content: center; margin-top: 1rem;">
{#each hand as card}
<Card {card} />
{:else}
<p class="empty-text text-center w-full">No cards in hand.</p>
{/each}
</div>
<p class="info-text text-center" style="margin-top: 1rem;">Waiting for the rest of the crew to finish upkeep...</p>
{:else}
<p class="info-text" style="margin-top: 1rem;"> <p class="info-text" style="margin-top: 1rem;">
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/> <strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
Drag cards to the "Discard Pile" zone to discard them, or click them to move them. Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
@@ -308,31 +340,16 @@
{/if} {/if}
</div> </div>
{:else} {:else}
{#if state.game.phase === 'between_scenes'} <p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
<p class="info-text text-center">Your Pi-Rat was active. Your hand cards carry over to the next scene.</p>
{:else if state.game.phase === 'deep_upkeep'}
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
{/if}
{/if} {/if}
<div class="roster-sheet-summary margin-top text-left glass-panel">
<h4>Crew Rank Ledger:</h4>
<ul class="ranks-ledger">
{#each state.players as p}
<li>
<strong>{displayName(p)}:</strong> Rank {p.rank}
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
</li>
{/each}
</ul>
</div>
</div> </div>
{/if}
</div> </div>
<!-- Next Scene Ready Up --> <!-- Next Scene Ready Up -->
<div class="action-box margin-top"> <div class="action-box margin-top">
{#if state.game.phase === 'deep_upkeep'} {#if state.game.phase === 'deep_upkeep'}
{#if state.player.role === 'deep'} {#if state.player.role === 'deep' && !state.player.is_ready}
<p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p> <p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
{:else} {:else}
<div class="waiting-box"> <div class="waiting-box">

View File

@@ -1,17 +1,27 @@
<script> <script>
import { apiRequest } from '../../lib/api'; import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltip, obstacleTable } from '../../lib/cards'; import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltipHtml, obstacleTable } from '../../lib/cards';
import { tooltip } from '../../lib/tooltip';
import ObstacleItem from './ObstacleItem.svelte';
export let state; export let state;
let error = ''; let error = '';
let taxTargetId = ''; let taxTargetId = '';
// Deep: call-a-challenge form (moved here from the old Deep Control Panel)
let showCreate = false;
let challengeTargetId = '';
let stakesSuccess = '';
let stakesFailure = '';
let selectedObstacles = {};
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; $: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open'); $: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null; $: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null; $: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
$: scenePirats = state.players.filter(p => p.role === 'pirat'); $: scenePirats = state.players.filter(p => p.role === 'pirat');
$: isDeep = state.player.role === 'deep';
function playerName(id) { function playerName(id) {
return lookupName(state.players, id); return lookupName(state.players, id);
@@ -53,6 +63,31 @@
taxTargetId = ''; taxTargetId = '';
} }
} }
async function createChallenge() {
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
if (await post('challenge/create', {
target_player_id: challengeTargetId,
obstacle_ids: JSON.stringify(ids),
stakes_success: stakesSuccess,
stakes_failure: stakesFailure,
})) {
challengeTargetId = '';
stakesSuccess = '';
stakesFailure = '';
selectedObstacles = {};
showCreate = false;
}
}
async function endScene() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
} catch(e) {
error = e.message;
}
}
</script> </script>
{#if error} {#if error}
@@ -78,22 +113,37 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if ch.stakes} {#if ch.stakes_success}
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>✅ On success:</strong> {ch.stakes_success}</p>
{/if}
{#if ch.stakes_failure}
<p class="info-text" style="margin: 0.25rem 0 0 0;"><strong>❌ On failure:</strong> {ch.stakes_failure}</p>
{/if}
{#if ch.stakes && !ch.stakes_success && !ch.stakes_failure}
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p> <p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
{/if} {/if}
{#if ch.challenge_type === 'pvp'} {#if ch.challenge_type === 'pvp'}
<p class="info-text" style="margin: 0.5rem 0 0 0;"> <p class="info-text" style="margin: 0.5rem 0 0 0;">
Temporary Obstacle: <strong title={cardTooltip(ch.temp_card, $obstacleTable)}>{getCardDisplay(ch.temp_card)}</strong>{playerName(ch.acting_player_id)} must answer it! Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable) }}>{getCardDisplay(ch.temp_card)}</strong>{playerName(ch.acting_player_id)} must answer it!
</p> </p>
{:else} {:else}
<p class="info-text" style="margin: 0.5rem 0 0 0;"> <p class="info-text" style="margin: 0.5rem 0 0 0;">
Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'} Attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
— attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}. Drag a card onto an Obstacle below to play it. Other Pi-Rats may assist (assistants don't draw back).
Other Pi-Rats may assist (assistants don't draw back).
</p> </p>
<div class="challenge-obstacles">
{#each chObstacleIds as oid}
{@const obs = state.obstacles.find(o => o.id === oid)}
{#if obs}
<ObstacleItem {state} {obs} embedded />
{:else}
<p class="info-text" style="margin: 0;">An applied Obstacle was discarded.</p>
{/if}
{/each}
</div>
{/if} {/if}
{#if chPlays.length > 0} {#if chPlays.length > 0}
<p class="info-text" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;"> <p class="info-text" style="margin: 0.5rem 0 0 0; font-size: 0.85rem;">
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')} Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
</p> </p>
{/if} {/if}
@@ -134,10 +184,53 @@
<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> Pick a card:</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" title={cardTooltip(card, $obstacleTable)} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button> <button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
{/each} {/each}
<!-- Deep controls: call a Challenge, and end the scene when none is active -->
<!-- The Deep calls Challenges and ends the scene only when none is active. -->
{#if isDeep && openChallenges.length === 0}
<div class="deep-challenge-controls">
<button class="btn {showCreate ? 'btn-secondary' : 'btn-primary'} btn-full" on:click={() => showCreate = !showCreate}>
{showCreate ? '✕ Cancel' : '⚔️ Call a Challenge'}
</button>
{#if showCreate}
<div class="sheet-group margin-top">
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge which Pi-Rat?</option>
{#each scenePirats.filter(p => !p.is_dead) as p}
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each}
</select>
<div style="margin-bottom: 0.5rem;">
{#each state.obstacles as obs}
{@const taken = challengedObstacleIds.has(obs.id)}
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
</label>
{/each}
</div>
<input type="text" class="input-field" placeholder="On success (optional): what they win…" bind:value={stakesSuccess} style="width: 100%; margin-bottom: 0.5rem;">
<input type="text" class="input-field" placeholder="On failure (optional): what it costs them…" bind:value={stakesFailure} style="width: 100%; margin-bottom: 0.5rem;">
<button class="btn btn-primary btn-full" on:click={createChallenge}
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
Call Challenge
</button>
</div>
{/if}
<div class="scene-upkeep-controls text-center">
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
End Scene & Proceed to Ranking
</button>
</div>
</div>
{/if}

View File

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

View File

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

View File

@@ -1,153 +0,0 @@
<script>
import { apiRequest } from '../../lib/api';
import { displayName } from '../../lib/cards';
export let state;
let error = '';
let challengeTargetId = '';
let challengeStakes = '';
let selectedObstacles = {};
let captainSelectId = '';
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
$: scenePirats = state.players.filter(p => p.role === 'pirat');
async function createChallenge() {
error = '';
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', {
target_player_id: challengeTargetId,
obstacle_ids: JSON.stringify(ids),
stakes: challengeStakes
});
challengeTargetId = '';
challengeStakes = '';
selectedObstacles = {};
} catch(e) {
error = e.message;
}
}
async function setCaptain() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-captain`, 'POST', {
target_player_id: captainSelectId
});
captainSelectId = '';
} catch(e) {
error = e.message;
}
}
async function toggleObjective(targetPlayerId, type) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', { type });
} catch(e) {
error = e.message;
}
}
async function endScene() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
} catch(e) {
error = e.message;
}
}
</script>
<div class="card glass-panel deep-panel-card">
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
<!-- Call a Challenge -->
<div class="sheet-group margin-top">
<h4>⚔️ Call a Challenge</h4>
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge which Pi-Rat?</option>
{#each scenePirats.filter(p => !p.is_dead) as p}
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each}
</select>
<div style="margin-bottom: 0.5rem;">
{#each state.obstacles as obs}
{@const taken = challengedObstacleIds.has(obs.id)}
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
</label>
{/each}
</div>
<input type="text" class="input-field" placeholder="Stakes (optional): what happens on success/failure?" bind:value={challengeStakes} style="width: 100%; margin-bottom: 0.5rem;">
<button class="btn btn-primary btn-full" on:click={createChallenge}
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
Call Challenge
</button>
</div>
<!-- Captain Assignment -->
<div class="sheet-group margin-top">
<h4>🏴‍☠️ Captaincy</h4>
<p class="info-text" style="font-size: 0.85rem;">The Captain holds power until they step down, lose a duel, die, or the ship is lost. Reassign after a leadership duel or resignation.</p>
<div style="display: flex; gap: 0.5rem;">
<select class="select-field" bind:value={captainSelectId} style="flex: 1;">
<option value="">No Captain</option>
{#each scenePirats.filter(p => !p.is_dead) as p}
<option value={p.id}>{displayName(p)}</option>
{/each}
</select>
<button class="btn btn-secondary" on:click={setCaptain}>Set</button>
</div>
</div>
<div class="sheet-group margin-top">
<h4>Crew Objectives</h4>
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_1} on:change={() => toggleObjective(state.player.id, 'crew_1')}>
<span>Steal a Ship</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_2} on:change={() => toggleObjective(state.player.id, 'crew_2')}>
<span>Choose a Captain</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_3} on:change={() => toggleObjective(state.player.id, 'crew_3')}>
<span>Commit Piracy</span>
</label>
</div>
</div>
<div class="sheet-group margin-top">
<h4>Pi-Rat Personal Objectives</h4>
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
{#each scenePirats as p}
<div class="objective-player-block">
<strong>{displayName(p)}</strong>
<div class="objectives-checklist" style="margin-top: 0.25rem;">
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_1} on:change={() => toggleObjective(p.id, 'personal_1')}> <span>1. Gat</span></label>
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_2} on:change={() => toggleObjective(p.id, 'personal_2')}> <span>2. Name</span></label>
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_3} on:change={() => toggleObjective(p.id, 'personal_3')}> <span>3. Die/Retire</span></label>
</div>
</div>
{/each}
</div>
<!-- End Scene Button -->
<div class="scene-upkeep-controls text-center">
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
End Scene & Proceed to Ranking
</button>
</div>
</div>

View File

@@ -1,130 +1,35 @@
<script> <script>
import { apiRequest } from '../../lib/api'; import ObstacleItem from './ObstacleItem.svelte';
import { getCardDisplay, isJoker } from '../../lib/cards';
import Card from '../Card.svelte';
export let state; export let state;
let error = '';
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open'); $: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]'))); $: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
// When a Challenge is active, the involved Obstacles move up into the Challenge
// A face-card Obstacle is worth the challenged Rat's Rank. When the Obstacle is // panel; the rest of the list collapses so attention stays on the Challenge.
// part of an open Challenge we know who that is — return their rank, else null. $: challengeActive = openChallenges.length > 0;
function challengedRankFor(obstacleId) { $: looseObstacles = state.obstacles.filter(o => !challengedObstacleIds.has(o.id));
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obstacleId));
if (!ch) return null;
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
}
async function playCard(obstacleId, cardCode) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
obstacle_id: obstacleId,
card_code: cardCode
});
} catch(e) {
error = e.message;
}
}
async function playJoker(obstacleId, cardCode) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-joker`, 'POST', {
obstacle_id: obstacleId,
card_code: cardCode
});
} catch(e) {
error = e.message;
}
}
async function clearObstacle(obstacleId) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
} catch(e) {
error = e.message;
}
}
</script> </script>
{#if error} {#if challengeActive}
<div class="alert alert-danger">{error}</div> {#if looseObstacles.length}
{/if} <details class="obstacle-collapse">
<summary>🌊 {looseObstacles.length} other Obstacle{looseObstacles.length === 1 ? '' : 's'} on the list — show</summary>
{#each state.obstacles as obs} <div class="obstacles-container">
{@const column_cards = JSON.parse(obs.played_cards || '[]')} {#each looseObstacles as obs (obs.id)}
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card} <ObstacleItem {state} {obs} />
{@const success_count = column_cards.filter(c => c.success).length}
{@const is_completed = success_count >= state.players.length}
{@const in_challenge = challengedObstacleIds.has(obs.id)}
<div class="obstacle-item suit-{obs.suit.toLowerCase()} {is_completed ? 'completed' : ''} {in_challenge ? 'in-challenge' : ''}"
data-obstacle-id={obs.id}
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
on:drop={(e) => {
if (is_completed) return;
e.preventDefault();
e.currentTarget.classList.remove("drag-over");
const cardCode = e.dataTransfer.getData('text/plain');
if (cardCode) {
if (isJoker(cardCode)) playJoker(obs.id, cardCode);
else playCard(obs.id, cardCode);
}
}}>
<div class="obstacle-card-wrapper">
<Card card={active_card_code} size="medium" title="Active Card: {getCardDisplay(active_card_code)}" />
</div>
<div class="obstacle-details">
<h4>{obs.title} {#if in_challenge}<span class="in-challenge-tag">⚔️ In Challenge</span>{/if}</h4>
<p class="desc">{obs.description}</p>
</div>
<!-- Value Display -->
<div class="obstacle-value-display text-center">
<span class="val-label">Current Difficulty</span>
<span class="val-number">
{#if ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1))}
{@const rank = challengedRankFor(obs.id)}
{rank !== null ? `${rank} (Rat's Rank)` : "Challenged Rat's Rank"}
{:else}
{obs.current_value}
{/if}
</span>
</div>
<!-- Successes Tracker -->
<div class="obstacle-successes-display text-center">
<span class="val-label">Successes</span>
<span class="val-number">
{success_count} / {state.players.length}
</span>
</div>
<!-- Played Cards Column -->
<div class="played-column">
<h5>Card History (Column)</h5>
<div class="column-cards-flex">
<Card card={obs.original_card} size="mini" />
{#each column_cards as pc}
<Card card={pc.card} size="mini" rotated success={pc.success}
owner={pc.player_name.slice(0, 4)}
title="{pc.player_name} played {getCardDisplay(pc.card)}" />
{/each} {/each}
</div> </div>
</div> </details>
{:else}
{#if is_completed && state.player.role === 'deep'} <p class="empty-text">All Obstacles are tied up in the Challenge above.</p>
<div class="clear-obstacle-row text-center"> {/if}
<button class="btn btn-success btn-full" on:click={() => clearObstacle(obs.id)}>Clear Obstacle</button>
</div>
{/if}
</div>
{:else} {:else}
<p class="empty-text">No active obstacles. Deep players can end the scene!</p> <div class="obstacles-container">
{/each} {#each state.obstacles as obs (obs.id)}
<ObstacleItem {state} {obs} />
{:else}
<p class="empty-text">No active obstacles. The Deep can end the scene!</p>
{/each}
</div>
{/if}

View File

@@ -6,7 +6,7 @@ import { apiRequest } from './api';
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' }; export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit. // Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
export const SUIT_THEMES = { const SUIT_THEMES = {
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation", C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility", D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans", S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
@@ -46,6 +46,7 @@ export function cardObstacleInfo(code, table = OBSTACLE_TABLE) {
// Default tooltip for a card: its suit theme when played, plus what it // Default tooltip for a card: its suit theme when played, plus what it
// represents as an Obstacle (relevant when challenging another Pi-Rat). // represents as an Obstacle (relevant when challenging another Pi-Rat).
// Pass the obstacleTable store's value as `table` to recompute reactively. // Pass the obstacleTable store's value as `table` to recompute reactively.
// Plain-string form, kept for aria-label / native fallbacks.
export function cardTooltip(code, table = OBSTACLE_TABLE) { export function cardTooltip(code, table = OBSTACLE_TABLE) {
if (!code) return ''; if (!code) return '';
if (isJoker(code)) { if (isJoker(code)) {
@@ -58,6 +59,42 @@ export function cardTooltip(code, table = OBSTACLE_TABLE) {
return `${theme}\nAs an Obstacle: ${obstacle.title}${obstacle.description}`; return `${theme}\nAs an Obstacle: ${obstacle.title}${obstacle.description}`;
} }
const FACE_VALUES = ['J', 'Q', 'K'];
function esc(s) {
return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
// Rich (HTML) tooltip describing a card — what it does when *played*, and what
// it means when it surfaces as an Obstacle. Built only from static rulebook
// data (SUIT_THEMES + the obstacle table), so the HTML is trusted. Feed the
// result to the `tooltip` action as `{ html: cardTooltipHtml(...) }`.
export function cardTooltipHtml(code, table = OBSTACLE_TABLE) {
if (!code) return '';
if (isJoker(code)) {
return '<div class="tt-head"><span class="tt-card tt-joker">🃏 Joker</span></div>'
+ '<div class="tt-row"><span class="tt-label">Played</span>Discard one Obstacle (and its column) outright, then draw a replacement.</div>'
+ '<div class="tt-row"><span class="tt-label">As an Obstacle</span>Discarded — but every following scene needs one more Obstacle.</div>';
}
const suit = cardSuit(code);
const val = cardValue(code);
const isRed = suit === 'H' || suit === 'D';
const colorClass = isRed ? 'tt-red' : 'tt-black';
let html = `<div class="tt-head"><span class="tt-card ${colorClass}">${esc(val)}${SUIT_EMOJI[suit] || esc(suit)}</span>`
+ `<span class="tt-color tt-color-${isRed ? 'red' : 'black'}">${isRed ? 'Red' : 'Black'}</span></div>`;
html += `<div class="tt-theme">${esc(SUIT_THEMES[suit] || '')}</div>`;
if (FACE_VALUES.includes(val)) {
html += '<div class="tt-row"><span class="tt-label">Played by a Pi-Rat</span>Triggers a Secret Technique — automatic success.</div>';
html += "<div class=\"tt-row\"><span class=\"tt-label\">As an Obstacle</span>Its value equals the challenged Pi-Rat's Rank.</div>";
} else {
const obstacle = table?.[suit]?.[val];
if (obstacle) {
html += `<div class="tt-row"><span class="tt-label">As an Obstacle</span><b>${esc(obstacle.title)}</b> — ${esc(obstacle.description)}</div>`;
}
}
return html;
}
export function isJoker(code) { export function isJoker(code) {
return !!code && code.startsWith('Joker'); return !!code && code.startsWith('Joker');
} }
@@ -89,3 +126,20 @@ export function displayName(p) {
export function playerName(players, id) { export function playerName(players, id) {
return displayName(players.find(p => p.id === id)); return displayName(players.find(p => p.id === id));
} }
// The Pi-Rat's name with the "Recruit" honorific swapped for "Captain" (or
// "Captain" prepended once they've earned a real Name). Used to mark the Captain
// in the scene roster without a separate icon.
export function captainName(name) {
if (!name) return 'Captain';
if (name.startsWith('Recruit ')) return 'Captain ' + name.slice('Recruit '.length);
return 'Captain ' + name;
}
// displayName, but the Captain is shown as "Captain <name> (player)".
export function crewLabel(p, captainId) {
if (!p) return '???';
const rat = p.id === captainId ? captainName(p.name) : p.name;
if (p.player_name && p.player_name !== p.name) return `${rat} (${p.player_name})`;
return rat;
}

View File

@@ -1242,13 +1242,3 @@ function loadTechniquePool() {
export async function getTechniqueSuggestion(exclude = []) { export async function getTechniqueSuggestion(exclude = []) {
return pickFrom(await loadTechniquePool(), exclude); return pickFrom(await loadTechniquePool(), exclude);
} }
// Draws `count` distinct technique suggestions.
export async function getTechniqueSuggestions(count, exclude = []) {
const pool = await loadTechniquePool();
const picked = [];
for (let i = 0; i < count; i++) {
picked.push(pickFrom(pool, [...exclude, ...picked]));
}
return picked;
}

View File

@@ -2,11 +2,21 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title'; import { setPageTitle } from '../lib/title';
import { getSessions } from '../lib/sessions';
export let params = {}; export let params = {};
let gameId = params.id; let gameId = params.id;
let adminKey = ''; let adminKey = '';
// Return to this browser's own Pi-Rat dashboard for the game (the most
// recently used session), not the public join screen. Falls back to join
// if this browser has no saved session for the game.
$: mySession = getSessions().find(s => s.gameId === gameId);
$: backLink = mySession
? `/#/game/${gameId}/player/${mySession.playerId}`
: `/#/game/${gameId}/join`;
$: backLabel = mySession ? '← Back to Game' : 'Back to Game Join Page';
let data = null; let data = null;
let error = ''; let error = '';
let copiedPlayerId = null; let copiedPlayerId = null;
@@ -187,7 +197,7 @@
</table> </table>
<div class="mt-8"> <div class="mt-8">
<a href="/#/game/{gameId}/join" class="btn btn-secondary">Back to Game Join Page</a> <a href={backLink} class="btn btn-secondary">{backLabel}</a>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -16,6 +16,9 @@
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 EventLog from '../components/EventLog.svelte'; import EventLog from '../components/EventLog.svelte';
import NameModal from '../components/NameModal.svelte';
import GatModal from '../components/GatModal.svelte';
import RankBonusModal from '../components/RankBonusModal.svelte';
export let params = {}; export let params = {};
let gameId = params.id; let gameId = params.id;
@@ -209,7 +212,13 @@
{/if} {/if}
</div> </div>
<EventLog {state} /> <!-- The scene phase renders its own inline Event Log as the third column. -->
{#if state.game.phase !== 'scene'}
<EventLog {state} />
{/if}
<NameModal {state} />
<GatModal {state} />
<RankBonusModal {state} />
{:else if !error} {:else if !error}
<div class="loading-container"> <div class="loading-container">
<p>Loading game state...</p> <p>Loading game state...</p>

View File

@@ -111,10 +111,6 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player], capt
return base_size + 1 return base_size + 1
return base_size return base_size
def is_player_captain(player: Player, game: Game) -> bool:
"""The Captain is a persistent position tracked on the Game (set once the crew steals a ship)."""
return game.captain_player_id is not None and game.captain_player_id == player.id
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict: def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict:
"""Snapshot every player's max hand size, for detecting increases after a Rank/Captain change.""" """Snapshot every player's max hand size, for detecting increases after a Rank/Captain change."""
return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players} return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
@@ -139,6 +135,18 @@ def change_player_rank(db: Session, game: Game, player: Player, delta: int):
db.commit() db.commit()
apply_hand_increases(db, game, old_maxes) apply_hand_increases(db, game, old_maxes)
def is_eligible_nominee(voter, nominee) -> bool:
"""A rank-up nominee must be another living, in-play Pi-Rat. Excludes the
voter, previous-scene Deep players, and dead / awaiting-recruit players
(ranking them up is wasted — recruits reset to Rank 1). Shared by the
between-scenes rank vote and the 3rd-objective story bonus."""
return (
nominee.id != voter.id
and nominee.role != "deep"
and not nominee.is_dead
and not nominee.needs_reroll
)
def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""): def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""):
"""Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain.""" """Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
if game.captain_player_id == player_id: if game.captain_player_id == player_id:

View File

@@ -36,7 +36,8 @@ def create_challenge(
deep_player_id: str, deep_player_id: str,
target_player_id: str, target_player_id: str,
obstacle_ids: List[str], obstacle_ids: List[str],
stakes: str = "" stakes_success: str = "",
stakes_failure: str = ""
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat.""" """A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat."""
game = get_game(db, game_id) game = get_game(db, game_id)
@@ -74,15 +75,20 @@ def create_challenge(
acting_player_id=target.id, acting_player_id=target.id,
challenger_player_id=deep_player.id, challenger_player_id=deep_player.id,
obstacle_ids=json.dumps(obstacle_ids), obstacle_ids=json.dumps(obstacle_ids),
stakes=stakes.strip(), stakes_success=stakes_success.strip(),
stakes_failure=stakes_failure.strip(),
) )
db.add(challenge) db.add(challenge)
db.commit() db.commit()
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids) obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids)
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}." msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
if stakes.strip(): success_text = stakes_success.strip()
msg += f" Stakes: {stakes.strip()}" failure_text = stakes_failure.strip()
if success_text:
msg += f" On success: {success_text}"
if failure_text:
msg += f" On failure: {failure_text}"
add_game_event(db, game.id, msg, kind="challenge") add_game_event(db, game.id, msg, kind="challenge")
return True, "Challenge called!" return True, "Challenge called!"
@@ -207,6 +213,9 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
if challenge.tax_type == "gat": if challenge.tax_type == "gat":
acting.completed_personal_1 = False acting.completed_personal_1 = False
refuser.completed_personal_1 = True refuser.completed_personal_1 = True
# The Gat (and its description) returns to its owner.
refuser.gat_description = acting.gat_description
acting.gat_description = ""
else: else:
# Return the stolen name string; the failed thief reverts to # Return the stolen name string; the failed thief reverts to
# their smell-based recruit identity. # their smell-based recruit identity.
@@ -338,6 +347,9 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
if challenge.tax_type == "gat": if challenge.tax_type == "gat":
requester.completed_personal_1 = True requester.completed_personal_1 = True
responder.completed_personal_1 = False responder.completed_personal_1 = False
# The Gat changes hands along with its description (like a stolen Name).
requester.gat_description = responder.gat_description
responder.gat_description = ""
event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!" 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

View File

@@ -435,6 +435,14 @@ def maybe_finish_assign_techniques(db: Session, game: Game):
# 1. Randomly assign starting ranks # 1. Randomly assign starting ranks
random.shuffle(players_list) random.shuffle(players_list)
# Teaching mode: an admin opted to be the forced Rank-3 Deep for the first
# scene, so seed them at the front instead of leaving it to chance.
if game.teaching_deep_player_id:
teacher = next((p for p in players_list if p.id == game.teaching_deep_player_id), None)
if teacher:
players_list.remove(teacher)
players_list.insert(0, teacher)
# "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene." # "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene."
# "Randomly choose one Player to start the game at Rank 1. This Player must play their Pi-Rat during the first scene." # "Randomly choose one Player to start the game at Rank 1. This Player must play their Pi-Rat during the first scene."
# "All remaining Players start at Rank 2. These Players may choose to play either during the first scene." # "All remaining Players start at Rank 2. These Players may choose to play either during the first scene."

View File

@@ -1,5 +1,4 @@
import json import json
import random
from typing import Tuple, Dict, Any from typing import Tuple, Dict, Any
from sqlmodel import Session from sqlmodel import Session
from .models import Game, Player, Obstacle from .models import Game, Player, Obstacle
@@ -7,7 +6,8 @@ from . import cards
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile, get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS,
is_eligible_nominee
) )
# --- Scene Setup Operations --- # --- Scene Setup Operations ---
@@ -307,21 +307,14 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
"""Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly.""" """Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly."""
if obj_type.startswith("crew_"): if obj_type.startswith("crew_"):
game = get_game(db, game_id) game = get_game(db, game_id)
if not game: return if not game:
return
if obj_type == "crew_1": if obj_type == "crew_1":
# Stealing a ship no longer auto-anoints a Captain — the crew has to
# earn and choose one themselves (Crew Objective 2 + the Captaincy UI).
game.completed_crew_1 = status game.completed_crew_1 = status
db.add(game) db.add(game)
db.commit() db.commit()
if status and not game.captain_player_id:
# "Once the crew controls a ship, the highest-Ranked Pi-Rat becomes the de facto Captain."
pi_rats = [p for p in game.players if p.role != "deep" and not p.is_dead and not p.is_ghost]
if pi_rats:
max_rank = max(p.rank for p in pi_rats)
candidates = [p for p in pi_rats if p.rank == max_rank]
set_captain(db, game, random.choice(candidates).id, "Highest-ranked Pi-Rat aboard the stolen ship")
elif not status and game.captain_player_id:
# The ship is lost: the Captain's position goes with it.
set_captain(db, game, None, "The ship was lost")
elif obj_type == "crew_2": elif obj_type == "crew_2":
game.completed_crew_2 = status game.completed_crew_2 = status
db.add(game) db.add(game)
@@ -343,8 +336,11 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
if obj_type == "personal_1": if obj_type == "personal_1":
if status and not player.completed_personal_1: if status and not player.completed_personal_1:
rank_diff = 1 rank_diff = 1
player.needs_gat_description = True
elif not status and player.completed_personal_1: elif not status and player.completed_personal_1:
rank_diff = -1 rank_diff = -1
player.needs_gat_description = False
player.gat_description = ""
player.completed_personal_1 = status player.completed_personal_1 = status
elif obj_type == "personal_2": elif obj_type == "personal_2":
@@ -379,6 +375,25 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
status_text = "completed" if status else "unmarked" status_text = "completed" if status else "unmarked"
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective") add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
def grant_story_bonus_rank(db: Session, game: Game, granter: Player, target):
"""A Pi-Rat who finished their story (3rd Personal Objective) grants another
eligible Pi-Rat +1 Rank. `target=None` forfeits the bonus (e.g. no eligible
crewmate); either way the needs_rank_3_bonus prompt clears. Naming an
ineligible target is refused so the prompt stays up rather than waste the Rank."""
if not granter.needs_rank_3_bonus:
return False, "There is no story Rank bonus to grant."
if target is not None and not is_eligible_nominee(granter, target):
return False, "Pick a living crewmate who isn't this scene's Deep."
if target is not None:
change_player_rank(db, game, target, 1)
add_game_event(db, game.id, f"{granter.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.", kind="rank")
else:
add_game_event(db, game.id, f"{granter.name} completed their story, but had no crewmate to pass a Rank to.", kind="rank")
granter.needs_rank_3_bonus = False
db.add(granter)
db.commit()
return True, "Rank granted."
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str): def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
obstacle = db.get(Obstacle, obstacle_id) obstacle = db.get(Obstacle, obstacle_id)
if obstacle: if obstacle:

View File

@@ -5,22 +5,11 @@ from .models import Game, Player, Vote
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event, get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
calculate_max_hand_size, change_player_rank, set_captain calculate_max_hand_size, change_player_rank, set_captain, is_eligible_nominee
) )
# --- Between Scenes Operations --- # --- Between Scenes Operations ---
def is_eligible_nominee(voter, nominee) -> bool:
"""A rank-up nominee must be another living, in-play Pi-Rat. Excludes the
voter, previous-scene Deep players, and dead / awaiting-recruit players
(ranking them up is wasted — recruits reset to Rank 1)."""
return (
nominee.id != voter.id
and nominee.role != "deep"
and not nominee.is_dead
and not nominee.needs_reroll
)
def eligible_vote_nominees(game: Game, voter) -> list: def eligible_vote_nominees(game: Game, voter) -> list:
"""The crewmates `voter` may nominate to rank up. An empty list means the """The crewmates `voter` may nominate to rank up. An empty list means the
voter has no valid pick and skips voting entirely (no deadlock).""" voter has no valid pick and skips voting entirely (no deadlock)."""
@@ -37,12 +26,8 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
nominee = get_player(db, nominated_id) nominee = get_player(db, nominated_id)
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id: if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
return False, "Player not found." return False, "Player not found."
if voter_id == nominated_id: if not is_eligible_nominee(voter, nominee):
return False, "You cannot nominate yourself. Nice try." return False, "You can only nominate a living crewmate who isn't this scene's Deep — and not yourself."
if nominee.role == "deep":
return False, "Deep Players from the previous scene cannot be nominated."
if nominee.is_dead or nominee.needs_reroll:
return False, "You can't nominate a Pi-Rat who's out of play. Pick a living crewmate."
# Remove any existing vote by this voter for this game # Remove any existing vote by this voter for this game
existing = db.exec( existing = db.exec(
@@ -83,24 +68,30 @@ def process_between_scenes_votes(db: Session, game: Game):
Then sets players who were Deep to discard and redraw. Then sets players who were Deep to discard and redraw.
""" """
votes = game.votes votes = game.votes
# Reset the previous scene's winner before tallying this one.
game.last_rank_up_player_id = None
db.add(game)
if not votes: if not votes:
db.commit()
return return
# Count votes # Count votes
counts = {} counts = {}
for v in votes: for v in votes:
counts[v.nominated_player_id] = counts.get(v.nominated_player_id, 0) + 1 counts[v.nominated_player_id] = counts.get(v.nominated_player_id, 0) + 1
if counts: if counts:
max_votes = max(counts.values()) max_votes = max(counts.values())
winners = [pid for pid, val in counts.items() if val == max_votes] winners = [pid for pid, val in counts.items() if val == max_votes]
# If there is a clear winner (or random in case of tie) # If there is a clear winner (or random in case of tie)
if winners: if winners:
winner_id = random.choice(winners) winner_id = random.choice(winners)
winner = get_player(db, winner_id) winner = get_player(db, winner_id)
if winner: if winner:
change_player_rank(db, game, winner, 1) change_player_rank(db, game, winner, 1)
game.last_rank_up_player_id = winner.id
db.add(game)
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank") add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank")
# Clear all votes # Clear all votes
@@ -112,6 +103,7 @@ 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

View File

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

View File

@@ -0,0 +1,32 @@
"""add teaching_deep_player_id to game
Revision ID: 19ee9ae5ca0e
Revises: e6294f6f8a81
Create Date: 2026-06-14 09:11:17.810765
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '19ee9ae5ca0e'
down_revision = 'e6294f6f8a81'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.add_column(sa.Column('teaching_deep_player_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.drop_column('teaching_deep_player_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""add last_rank_up_player_id to game
Revision ID: 3bbf6812c261
Revises: 19ee9ae5ca0e
Create Date: 2026-06-14 09:21:11.037751
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '3bbf6812c261'
down_revision = '19ee9ae5ca0e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.add_column(sa.Column('last_rank_up_player_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.drop_column('last_rank_up_player_id')
# ### end Alembic commands ###

View File

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

View File

@@ -18,6 +18,8 @@ class Game(SQLModel, table=True):
completed_crew_2: bool = Field(default=False) # Choose a Captain completed_crew_2: bool = Field(default=False) # Choose a Captain
completed_crew_3: bool = Field(default=False) # Commit Piracy completed_crew_3: bool = Field(default=False) # Commit Piracy
captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship
teaching_deep_player_id: Optional[str] = Field(default=None) # Teaching mode: an admin who seeds themselves as the forced Rank-3 Deep for the first scene (set in the lobby, honored when starting ranks are rolled)
last_rank_up_player_id: Optional[str] = Field(default=None) # Winner of the most recent rank-up vote, shown on the post-voting upkeep screen; cleared when the next scene begins
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True) players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True) obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True)
@@ -66,8 +68,10 @@ class Player(SQLModel, table=True):
completed_personal_2: bool = Field(default=False) # Earn a Name completed_personal_2: bool = Field(default=False) # Earn a Name
completed_personal_3: bool = Field(default=False) # Die like a Pirate completed_personal_3: bool = Field(default=False) # Die like a Pirate
gat_description: str = Field(default="") # Free-text description of the Pi-Rat's Gat, entered via a modal when they earn one
# States for Milestones & Death # States for Milestones & Death
needs_name: bool = Field(default=False) needs_name: bool = Field(default=False)
needs_gat_description: bool = Field(default=False) # Prompts the gat-description modal after earning a Gat objective
needs_rank_3_bonus: bool = Field(default=False) needs_rank_3_bonus: bool = Field(default=False)
is_dead: bool = Field(default=False) is_dead: bool = Field(default=False)
is_ghost: bool = Field(default=False) is_ghost: bool = Field(default=False)
@@ -110,7 +114,9 @@ class Challenge(SQLModel, table=True):
challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges) obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges)
temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
stakes: str = Field(default="") stakes: str = Field(default="") # Deprecated single-field stakes; kept for old rollback-snapshot compatibility. New challenges use stakes_success/stakes_failure.
stakes_success: str = Field(default="") # What the Deep promises happens if the Pi-Rat beats the Challenge
stakes_failure: str = Field(default="") # What the Deep threatens happens if the Pi-Rat fails
plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}] plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
status: str = Field(default="open") # "open", "succeeded", "failed" status: str = Field(default="open") # "open", "succeeded", "failed"

View File

@@ -33,6 +33,36 @@ def set_dev_mode(
crud.add_game_event(db, game.id, f"🛠 Dev Mode turned {'ON' if enabled else 'OFF'} by {player.name}.", kind="info") 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.
# Only meaningful before starting ranks are rolled (i.e. before scene setup).
_PRE_RANK_PHASES = {"lobby", "character_creation", "swap_techniques", "assign_techniques"}
@router.post("/game/{game_id}/player/{player_id}/teaching-mode")
def set_teaching_mode(
game_id: str,
player_id: str,
enabled: bool = Form(...),
db: Session = Depends(get_session)
):
game, player = _get_admin(db, game_id, player_id)
if game.phase not in _PRE_RANK_PHASES:
raise HTTPException(status_code=400, detail="Starting ranks have already been rolled.")
if enabled:
game.teaching_deep_player_id = player.id
elif game.teaching_deep_player_id == player.id:
game.teaching_deep_player_id = None
else:
# Disabling someone else's teaching mode shouldn't silently clear it.
return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id}
db.add(game)
db.commit()
crud.add_game_event(
db, game.id,
f"📚 {player.name} {'will teach as the Deep' if enabled else 'cancelled teaching mode'} for the first scene.",
kind="info",
)
return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id}
# Dev Mode shortcut: auto-complete character creation for everyone and move on # Dev Mode shortcut: auto-complete character creation for everyone and move on
@router.post("/game/{game_id}/player/{player_id}/skip-character-creation") @router.post("/game/{game_id}/player/{player_id}/skip-character-creation")
def skip_character_creation_route( def skip_character_creation_route(

View File

@@ -14,7 +14,8 @@ def create_challenge_route(
player_id: str, player_id: str,
target_player_id: str = Form(...), target_player_id: str = Form(...),
obstacle_ids: str = Form(...), # JSON list of obstacle ids obstacle_ids: str = Form(...), # JSON list of obstacle ids
stakes: str = Form(""), stakes_success: str = Form(""),
stakes_failure: str = Form(""),
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
try: try:
@@ -22,7 +23,7 @@ def create_challenge_route(
assert isinstance(ids, list) assert isinstance(ids, list)
except Exception: except Exception:
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400) return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes) ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes_success, stakes_failure)
if not ok: if not ok:
return JSONResponse({"error": msg}, status_code=400) return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"} return {"status": "ok"}

View File

@@ -88,28 +88,6 @@ def player_delegate_question(
crud.delegate_question(db, player_id, question_type, target_player_id) crud.delegate_question(db, player_id, question_type, target_player_id)
return {"status": "ok"} return {"status": "ok"}
# Revoke a delegated question task
@router.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}")
def player_revoke_delegation(
game_id: str,
player_id: str,
question_type: str, # "like" or "hate"
db: Session = Depends(get_session)
):
player = crud.get_player(db, player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
if question_type == "like":
player.other_like_from_player_id = None
player.other_like = ""
elif question_type == "hate":
player.other_hate_from_player_id = None
player.other_hate = ""
db.add(player)
db.commit()
return {"status": "ok"}
# Answer a delegated question for another player # Answer a delegated question for another player
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}") @router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
def player_submit_delegated_answer( def player_submit_delegated_answer(

View File

@@ -89,19 +89,27 @@ def toggle_objective_route(
): ):
if type.startswith("crew_"): if type.startswith("crew_"):
game = crud.get_game(db, game_id) game = crud.get_game(db, game_id)
if not game: raise HTTPException(status_code=404) if not game:
raise HTTPException(status_code=404)
current_status = False current_status = False
if type == "crew_1": current_status = game.completed_crew_1 if type == "crew_1":
elif type == "crew_2": current_status = game.completed_crew_2 current_status = game.completed_crew_1
elif type == "crew_3": current_status = game.completed_crew_3 elif type == "crew_2":
current_status = game.completed_crew_2
elif type == "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) player = crud.get_player(db, player_id)
if not player: raise HTTPException(status_code=404, detail="Player not found") if not player:
raise HTTPException(status_code=404, detail="Player not found")
current_status = False current_status = False
if type == "personal_1": current_status = player.completed_personal_1 if type == "personal_1":
elif type == "personal_2": current_status = player.completed_personal_2 current_status = player.completed_personal_1
elif type == "personal_3": current_status = player.completed_personal_3 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) crud.toggle_objective(db, game_id, player_id, type, not current_status)
return {"status": "ok"} return {"status": "ok"}
@@ -122,23 +130,39 @@ def set_name_route(
db.commit() db.commit()
return {"status": "ok"} return {"status": "ok"}
# Pi-Rat bonus rank up for another player # Pi-Rat describes the Gat they just earned
@router.post("/game/{game_id}/player/{player_id}/set-gat-description")
def set_gat_description_route(
game_id: str,
player_id: str,
description: str = Form(...),
db: Session = Depends(get_session)
):
player = crud.get_player(db, player_id)
if player and player.needs_gat_description:
player.gat_description = description
player.needs_gat_description = False
db.add(player)
db.commit()
return {"status": "ok"}
# A Pi-Rat who finished their story (3rd Personal Objective) grants another Pi-Rat a
# bonus Rank. An empty target_player_id forfeits the bonus (e.g. no eligible crewmate).
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up") @router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
def bonus_rank_up_route( def bonus_rank_up_route(
game_id: str, game_id: str,
player_id: str, player_id: str,
target_player_id: str = Form(...), target_player_id: str = Form(""),
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
player = crud.get_player(db, player_id)
target = crud.get_player(db, target_player_id)
game = crud.get_game(db, game_id) game = crud.get_game(db, game_id)
if player and target and game and player.needs_rank_3_bonus: player = crud.get_player(db, player_id)
player.needs_rank_3_bonus = False if not game or not player:
db.add(player) raise HTTPException(status_code=404, detail="Game or Player not found")
db.commit() target = crud.get_player(db, target_player_id) if target_player_id else None
crud.change_player_rank(db, game, target, 1) ok, msg = crud.grant_story_bonus_rank(db, game, player, target)
crud.add_game_event(db, game_id, f"{player.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.") if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"} return {"status": "ok"}
# Pi-Rat becomes a ghost # Pi-Rat becomes a ghost

View File

@@ -194,12 +194,14 @@ def test_scene_start_and_challenges(session):
assert "Challenge" in msg assert "Challenge" in msg
# The Deep calls a Challenge applying the obstacle # The Deep calls a Challenge applying the obstacle
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="The cheese is on the line") ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_success="The cheese is yours", stakes_failure="The cheese is on the line")
assert ok, msg assert ok, msg
session.refresh(game) session.refresh(game)
challenge = game.challenges[0] challenge = game.challenges[0]
assert challenge.status == "open" assert challenge.status == "open"
assert challenge.acting_player_id == p2.id assert challenge.acting_player_id == p2.id
assert challenge.stakes_success == "The cheese is yours"
assert challenge.stakes_failure == "The cheese is on the line"
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red" orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
@@ -343,7 +345,6 @@ def test_captaincy_and_hand_sizes(session):
# No captain until the crew controls a ship # No captain until the crew controls a ship
assert game.captain_player_id is None assert game.captain_player_id is None
assert not crud.is_player_captain(p2, game)
# Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards # Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 4 assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 4
@@ -355,20 +356,19 @@ def test_captaincy_and_hand_sizes(session):
session.commit() session.commit()
assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 4 assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 4
# Completing Crew Objective 1 (Steal a Ship) makes the highest-ranked Pi-Rat the Captain # Stealing a ship does NOT auto-assign a Captain — the crew must earn and
# choose one. Completing Crew Objective 1 leaves the captaincy vacant.
crud.toggle_objective(session, game.id, p1.id, "crew_1", True) crud.toggle_objective(session, game.id, p1.id, "crew_1", True)
session.refresh(game) session.refresh(game)
assert game.captain_player_id in (p2.id, p3.id)
captain = crud.get_player(session, game.captain_player_id)
assert crud.is_player_captain(captain, game)
# Captain privilege: +1 hand size
assert crud.calculate_max_hand_size(captain, game.players, game.captain_player_id) == 5
# Losing the ship vacates the Captaincy
crud.toggle_objective(session, game.id, p1.id, "crew_1", False)
session.refresh(game)
assert game.captain_player_id is None assert game.captain_player_id is None
# The crew chooses a Captain explicitly (via the Captaincy UI / set_captain).
crud.set_captain(session, game, p2.id)
session.refresh(game)
assert game.captain_player_id == p2.id
# Captain privilege: +1 hand size
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 5
def test_captain_tax_on_failed_challenge(session): def test_captain_tax_on_failed_challenge(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
# p3 (rank 2) becomes captain # p3 (rank 2) becomes captain
@@ -631,6 +631,41 @@ def test_player_death_toggle(session):
assert not player.is_dead assert not player.is_dead
assert not player.completed_personal_3 assert not player.completed_personal_3
def test_story_bonus_rank_grant(session):
game, deep, (granter, mate) = make_scene_game(session, num_pirats=2)
crud.toggle_objective(session, game.id, granter.id, "personal_3", True)
session.refresh(granter)
assert granter.needs_rank_3_bonus
start_rank = mate.rank
ok, _ = crud.grant_story_bonus_rank(session, game, granter, mate)
assert ok
session.refresh(granter)
session.refresh(mate)
assert not granter.needs_rank_3_bonus
assert mate.rank == start_rank + 1
def test_story_bonus_rank_guards_and_forfeit(session):
game, deep, (granter, mate) = make_scene_game(session, num_pirats=2)
# No bonus pending yet -> refused.
ok, _ = crud.grant_story_bonus_rank(session, game, granter, mate)
assert not ok
crud.toggle_objective(session, game.id, granter.id, "personal_3", True)
session.refresh(granter)
mate_rank = mate.rank
# Can't grant to yourself, nor to the Deep -> refused, prompt stays up.
for bad_target in (granter, deep):
ok, _ = crud.grant_story_bonus_rank(session, game, granter, bad_target)
assert not ok
session.refresh(granter)
assert granter.needs_rank_3_bonus
# Forfeit (no eligible crewmate) clears the prompt without changing any Rank.
ok, _ = crud.grant_story_bonus_rank(session, game, granter, None)
assert ok
session.refresh(granter)
session.refresh(mate)
assert not granter.needs_rank_3_bonus
assert mate.rank == mate_rank
def test_become_ghost_flow(session): def test_become_ghost_flow(session):
game = crud.create_game(session) game = crud.create_game(session)
player = crud.add_player(session, game.id, "DeadRat") player = crud.add_player(session, game.id, "DeadRat")
@@ -2078,7 +2113,7 @@ def test_rollback_round_trip_restores_state(session):
cp_known = crud.capture_checkpoint(session, game) cp_known = crud.capture_checkpoint(session, game)
# A destructive action: open a challenge and play a card against the obstacle. # A destructive action: open a challenge and play a card against the obstacle.
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="x") crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_failure="x")
crud.capture_checkpoint(session, game) crud.capture_checkpoint(session, game)
crud.play_challenge_card(session, p2.id, obs.id, "10D") crud.play_challenge_card(session, p2.id, obs.id, "10D")
crud.capture_checkpoint(session, game) crud.capture_checkpoint(session, game)
@@ -2242,7 +2277,7 @@ def test_rollback_middleware_capture_and_route_end_to_end():
obs.current_value = 5 obs.current_value = 5
session.add_all([p2, obs]) session.add_all([p2, obs])
session.commit() session.commit()
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="x") crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_failure="x")
# A floor checkpoint representing the pre-play state. # A floor checkpoint representing the pre-play state.
floor = crud.capture_checkpoint(session, game) floor = crud.capture_checkpoint(session, game)