From daeac91d07cdbbe43f302ed9f8ce636d7b009c50 Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Mon, 15 Jun 2026 07:46:32 -0700 Subject: [PATCH] Dead code cleanups --- AGENTS.md | 8 + TODO.md | 40 +---- frontend/src/assets/css/components.css | 79 +++++++++ frontend/src/assets/css/scene-play.css | 131 ++++++++++----- frontend/src/components/Card.svelte | 20 ++- frontend/src/components/EventLog.svelte | 60 +++++-- frontend/src/components/ScenePhase.svelte | 48 +++--- .../components/scene/ChallengePanel.svelte | 101 ++++++++++- .../components/scene/CharacterSheet.svelte | 48 +++++- .../src/components/scene/CrewColumn.svelte | 22 ++- .../components/scene/DeepControlPanel.svelte | 157 ------------------ .../src/components/scene/ObstacleBoard.svelte | 143 +++------------- frontend/src/lib/cards.js | 39 ++++- frontend/src/lib/suggestions.js | 10 -- frontend/src/pages/Dashboard.svelte | 7 +- src/pirats/crud_base.py | 16 +- src/pirats/crud_scene.py | 22 ++- src/pirats/crud_upkeep.py | 21 +-- src/pirats/routes_character.py | 22 --- src/pirats/routes_scene.py | 20 +-- tests/test_game.py | 37 ++++- 21 files changed, 578 insertions(+), 473 deletions(-) delete mode 100644 frontend/src/components/scene/DeepControlPanel.svelte diff --git a/AGENTS.md b/AGENTS.md index b17a26b..fca9205 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. + +## 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. diff --git a/TODO.md b/TODO.md index fa6b80d..293ea35 100644 --- a/TODO.md +++ b/TODO.md @@ -1,34 +1,6 @@ -## Polish - -- [x] **Clean up ruff E701 warnings** - -- [x] **Modal name popup**. When a Pi-Rat gains a name, they get a modal popup they have to fill out before continuing, rather than an optional box on their character sheet - -- [x] **Cancel Revise**. In character creation, if you click "Revise" and then don't want to make any changes after all, there should be a cancel button that throws away any edits you've made since you clicked revise. - -- [x] **Teaching mode**. The creator/admin should have an option to seed themselves as the player who is forced to be the Deep for the first scene - -- [x] Visual feedback confirming hand refresh in Deep Upkeep - -- [x] On the screen after pirat rank up voting, it should display the winner - -- [x] When Deep issues a challenge, stakes should be two fields, success and failure - -- [x] Captain should not be autoassigned. That's something the crew has to earn. - -- [x] The back button on the admin page should go back to the active game, not the join screen - -- [x] Add a toast pop-up for new events entering the event log, that then fades into the event log button - -- [x] When you get a gat, there should be a pop up to enter a description of it, as with your name - -- [x] In between scenes, there really only needs to be one panel with the roster and voting status. There doesn't need to be a whole extra panel to inform players that the deep will later get to refresh their hands. - ## UI Polish -- [ ] **Make card tooltips more graphical** Rather than just being an HTML title attribute, they should be a nicely formatted on-hover UI element. There should also be tooltips in the challenge/obstacle box/resting deep phase card displays (basically, anywhere a card is rendered it should have a tooltip), and these should describe the card, not the event of it being played, since that's covered by the event log. - -- [ ] **Make the color of challenges more clear**. Right now there are all sorts of highlights around the challenge box, and it's not clear if red or black is the "good" color to play on it. Also, double check the rulebook about whether the color of the challenge is based on the original card or the latest card played. +- [ ] 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 @@ -41,12 +13,6 @@ - [ ] **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] **Scene dashboard design refresh.** There should be a column of bubbles/buttons to the left of the obstacle list panel. These represent the crew. You can click a Pi-Rat to pop out their character sheet. If the other player is a Pi-Rat in the scene, there's the "challenge them" UI, which no longer needs the "Challenge whom?" since it's tied to a particular character sheet. Finally, there's the personal objective list. There's also an objectives button (at the top level of the hierarchy) which shows a popup for crew objectives. This entirely replaces the character sheet panel on the current dashboard, as well as the roster at the top of the obstacle list. The captain has a steering wheel emoji next to them. The display should also show current hand size for all players. - -- [ ] Interaction during a challenge should be promoted to the top of the UI - -- [ ] Event log should be a top level panel, not an overlay. +- [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.)* diff --git a/frontend/src/assets/css/components.css b/frontend/src/assets/css/components.css index 6c6cd0f..e85f9de 100644 --- a/frontend/src/assets/css/components.css +++ b/frontend/src/assets/css/components.css @@ -462,3 +462,82 @@ 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; +} + diff --git a/frontend/src/assets/css/scene-play.css b/frontend/src/assets/css/scene-play.css index 2c39f7a..15ee3f6 100644 --- a/frontend/src/assets/css/scene-play.css +++ b/frontend/src/assets/css/scene-play.css @@ -1,11 +1,22 @@ /* --- Scene board layout --- */ .scene-view-layout { display: grid; - grid-template-columns: 180px 1.4fr 1fr; + grid-template-columns: 180px minmax(0, 1.7fr) minmax(300px, 1fr); 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) { .scene-view-layout { grid-template-columns: 1fr; @@ -175,7 +186,7 @@ } .obstacle-item.in-challenge { - box-shadow: 0 0 0 2px var(--danger); + box-shadow: 0 0 0 2px var(--deep); } @media (max-width: 768px) { @@ -196,10 +207,56 @@ .obstacle-card-wrapper { display: flex; + flex-direction: column; justify-content: 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 { font-weight: 900; font-size: 1.1rem; @@ -221,7 +278,7 @@ } .obstacle-details .in-challenge-tag { - color: var(--danger); + color: var(--deep); font-size: 0.8rem; } @@ -294,19 +351,25 @@ 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 { - 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; text-align: left; } +.challenge-item + .challenge-item { + border-top: 1px solid var(--edge-soft); + padding-top: 1rem; +} + .challenge-item h4 { - color: var(--danger); - font-size: 1.15rem; + color: var(--deep); + font-size: 1.25rem; margin: 0; } @@ -337,37 +400,31 @@ margin: 0 0 0.5rem 0; } -/* --- Deep control panel --- */ -.deep-panel-card { - max-height: 80vh; - 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 { +/* --- Challenge area (middle column) --- */ +/* Obstacles applied to an active Challenge, pulled up into the Challenge panel */ +.challenge-obstacles { display: flex; flex-direction: column; - gap: 0.5rem; - background: var(--well); - padding: 1rem; - border-radius: var(--radius-sm); - max-height: 150px; - overflow-y: auto; - border: 1px solid var(--edge); + gap: 1rem; + margin-top: 0.75rem; } -.objective-player-block { - background: var(--well); - padding: 0.5rem 0.75rem; - border-radius: var(--radius-sm); - margin-bottom: 0.5rem; +/* The Deep's call-a-challenge + end-scene controls live below any open Challenges */ +.deep-challenge-controls { + margin-top: 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 { diff --git a/frontend/src/components/Card.svelte b/frontend/src/components/Card.svelte index 93c7f55..665809c 100644 --- a/frontend/src/components/Card.svelte +++ b/frontend/src/components/Card.svelte @@ -1,5 +1,6 @@ {#if size === 'mini'} -
+
{val} {suit} {#if owner}{owner}{/if}
{:else}
{val} @@ -44,7 +48,7 @@
{#if techName}
-
{cardValue(card)}: "{techName}"
+
{cardValue(card)}: "{techName}"
{/if}
diff --git a/frontend/src/components/EventLog.svelte b/frontend/src/components/EventLog.svelte index 6748d74..82b5f15 100644 --- a/frontend/src/components/EventLog.svelte +++ b/frontend/src/components/EventLog.svelte @@ -1,8 +1,11 @@ -
- {#if toast && !open} +
+ {#if toast && !shown} {#key toast.id} + {#if inline} +
📜 Event Log
+ {:else} + + {/if} - {#if open} + {#if shown} {#if error}
{error}
{/if} @@ -253,6 +266,29 @@ .floating-event-log:not(.open) { 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 { background: transparent; color: var(--text); diff --git a/frontend/src/components/ScenePhase.svelte b/frontend/src/components/ScenePhase.svelte index e44ff15..c109def 100644 --- a/frontend/src/components/ScenePhase.svelte +++ b/frontend/src/components/ScenePhase.svelte @@ -2,40 +2,27 @@ import Card from './Card.svelte'; import ChallengePanel from './scene/ChallengePanel.svelte'; import ObstacleBoard from './scene/ObstacleBoard.svelte'; - import DeepControlPanel from './scene/DeepControlPanel.svelte'; import CrewColumn from './scene/CrewColumn.svelte'; + import EventLog from './EventLog.svelte'; export let state; $: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; $: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king }; + $: isDeep = state.player.role === 'deep'; + $: 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;
- +
-
-
-

🌊 The Obstacle List

- 🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left -
- -
- - -
-
-
- - -
- {#if state.player.role === "deep"} - - {:else} - + {#if !isDeep}

🃏 Your Hand

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.

@@ -57,5 +44,24 @@
{/if} + + {#if showChallengeArea} +
+ +
+ {/if} + +
+
+

🌊 The Obstacle List

+ 🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left +
+ +
+
+ + +
+
diff --git a/frontend/src/components/scene/ChallengePanel.svelte b/frontend/src/components/scene/ChallengePanel.svelte index 41bb7e3..fb4e2fa 100644 --- a/frontend/src/components/scene/ChallengePanel.svelte +++ b/frontend/src/components/scene/ChallengePanel.svelte @@ -1,17 +1,27 @@ {#if error} @@ -89,17 +124,26 @@ {/if} {#if ch.challenge_type === 'pvp'}

- Temporary Obstacle: {getCardDisplay(ch.temp_card)} — {playerName(ch.acting_player_id)} must answer it! + Temporary Obstacle: {getCardDisplay(ch.temp_card)} — {playerName(ch.acting_player_id)} must answer it!

{:else}

- Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'} - — attempted by {playerName(ch.acting_player_id)}{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}. - Other Pi-Rats may assist (assistants don't draw back). + Attempted by {playerName(ch.acting_player_id)}{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).

+
+ {#each chObstacleIds as oid} + {@const obs = state.obstacles.find(o => o.id === oid)} + {#if obs} + + {:else} +

An applied Obstacle was discarded.

+ {/if} + {/each} +
{/if} {#if chPlays.length > 0} -

+

Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}

{/if} @@ -140,10 +184,53 @@

Defend yourself! Pick a card:

{#each hand.filter(c => !isJoker(c)) as card} - + {/each}
{/if}
{/each} + + + +{#if isDeep && openChallenges.length === 0} +
+ + {#if showCreate} +
+

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.

+ +
+ {#each state.obstacles as obs} + {@const taken = challengedObstacleIds.has(obs.id)} + + {/each} +
+ + + +
+ {/if} + +
+

End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.

+ +
+
+{/if} diff --git a/frontend/src/components/scene/CharacterSheet.svelte b/frontend/src/components/scene/CharacterSheet.svelte index 951176a..6c6b4a2 100644 --- a/frontend/src/components/scene/CharacterSheet.svelte +++ b/frontend/src/components/scene/CharacterSheet.svelte @@ -14,6 +14,12 @@ $: viewer = state.player; $: isSelf = target.id === viewer.id; + $: isDeep = viewer.role === 'deep'; + $: isCaptain = target.id === state.game.captain_player_id; + // The Deep manages captaincy and ticks personal objectives from a Pi-Rat's sheet + // (both moved here from the old Deep Control Panel). + $: canManageCaptain = isDeep && target.role === 'pirat' && !target.is_dead; + $: canManageObjectives = isDeep && target.role === 'pirat'; // The viewer throws one of their OWN cards in a duel. $: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : []; $: canDuel = !isSelf @@ -41,6 +47,26 @@ 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; + } + } e.key === 'Escape' && close()} /> @@ -145,19 +171,35 @@
{/if} + {#if canManageCaptain} +
+

🏴‍☠️ Captaincy

+ {#if isCaptain} +

{target.name} holds the Captaincy until they step down, lose a duel, die, or the ship is lost.

+ + {:else} +

Hand this Pi-Rat the Captaincy (e.g. after a leadership duel or resignation).

+ + {/if} +
+ {/if} +

Personal Objectives

+ {#if canManageObjectives} +

As the Deep, tick these off in sequence (1 → 2 → 3).

+ {/if}
diff --git a/frontend/src/components/scene/CrewColumn.svelte b/frontend/src/components/scene/CrewColumn.svelte index 89182cf..30f90b0 100644 --- a/frontend/src/components/scene/CrewColumn.svelte +++ b/frontend/src/components/scene/CrewColumn.svelte @@ -1,5 +1,6 @@ - -
-

🌊 Deep Control Panel

- - {#if error} -
{error}
- {/if} - - -
-

⚔️ Call a Challenge

-

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.

- -
- {#each state.obstacles as obs} - {@const taken = challengedObstacleIds.has(obs.id)} - - {/each} -
- - - -
- - -
-

🏴‍☠️ Captaincy

-

The Captain holds power until they step down, lose a duel, die, or the ship is lost. Reassign after a leadership duel or resignation.

-
- - -
-
- -
-

Crew Objectives

-
- - - -
-
- -
-

Pi-Rat Personal Objectives

-

You control completion. Only mark in sequence (1 -> 2 -> 3).

- {#each scenePirats as p} -
- {displayName(p)} -
- - - -
-
- {/each} -
- - -
-

End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.

- -
-
diff --git a/frontend/src/components/scene/ObstacleBoard.svelte b/frontend/src/components/scene/ObstacleBoard.svelte index 5c8f2fb..9204570 100644 --- a/frontend/src/components/scene/ObstacleBoard.svelte +++ b/frontend/src/components/scene/ObstacleBoard.svelte @@ -1,130 +1,35 @@ -{#if error} -
{error}
-{/if} - -{#each state.obstacles as obs} - {@const column_cards = JSON.parse(obs.played_cards || '[]')} - {@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card} - {@const success_count = column_cards.filter(c => c.success).length} - {@const is_completed = success_count >= state.players.length} - {@const in_challenge = challengedObstacleIds.has(obs.id)} -
{ 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); - } - }}> -
- -
-
-

{obs.title} {#if in_challenge}⚔️ In Challenge{/if}

-

{obs.description}

-
- - -
- Current Difficulty - - {#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} - -
- - -
- Successes - - {success_count} / {state.players.length} - -
- - -
-
Card History (Column)
-
- - {#each column_cards as pc} - +{#if challengeActive} + {#if looseObstacles.length} +
+ 🌊 {looseObstacles.length} other Obstacle{looseObstacles.length === 1 ? '' : 's'} on the list — show +
+ {#each looseObstacles as obs (obs.id)} + {/each}
-
- - {#if is_completed && state.player.role === 'deep'} -
- -
- {/if} -
+ + {:else} +

All Obstacles are tied up in the Challenge above.

+ {/if} {:else} -

No active obstacles. Deep players can end the scene!

-{/each} +
+ {#each state.obstacles as obs (obs.id)} + + {:else} +

No active obstacles. The Deep can end the scene!

+ {/each} +
+{/if} diff --git a/frontend/src/lib/cards.js b/frontend/src/lib/cards.js index 70cac8b..95b688e 100644 --- a/frontend/src/lib/cards.js +++ b/frontend/src/lib/cards.js @@ -6,7 +6,7 @@ import { apiRequest } from './api'; export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' }; // Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit. -export const SUIT_THEMES = { +const SUIT_THEMES = { C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation", D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility", S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans", @@ -46,6 +46,7 @@ export function cardObstacleInfo(code, table = OBSTACLE_TABLE) { // Default tooltip for a card: its suit theme when played, plus what it // represents as an Obstacle (relevant when challenging another Pi-Rat). // Pass the obstacleTable store's value as `table` to recompute reactively. +// Plain-string form, kept for aria-label / native fallbacks. export function cardTooltip(code, table = OBSTACLE_TABLE) { if (!code) return ''; if (isJoker(code)) { @@ -58,6 +59,42 @@ export function cardTooltip(code, table = OBSTACLE_TABLE) { return `${theme}\nAs an Obstacle: ${obstacle.title} — ${obstacle.description}`; } +const FACE_VALUES = ['J', 'Q', 'K']; + +function esc(s) { + return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 '
🃏 Joker
' + + '
PlayedDiscard one Obstacle (and its column) outright, then draw a replacement.
' + + '
As an ObstacleDiscarded — but every following scene needs one more Obstacle.
'; + } + const suit = cardSuit(code); + const val = cardValue(code); + const isRed = suit === 'H' || suit === 'D'; + const colorClass = isRed ? 'tt-red' : 'tt-black'; + let html = `
${esc(val)}${SUIT_EMOJI[suit] || esc(suit)}` + + `${isRed ? 'Red' : 'Black'}
`; + html += `
${esc(SUIT_THEMES[suit] || '')}
`; + if (FACE_VALUES.includes(val)) { + html += '
Played by a Pi-RatTriggers a Secret Technique — automatic success.
'; + html += "
As an ObstacleIts value equals the challenged Pi-Rat's Rank.
"; + } else { + const obstacle = table?.[suit]?.[val]; + if (obstacle) { + html += `
As an Obstacle${esc(obstacle.title)} — ${esc(obstacle.description)}
`; + } + } + return html; +} + export function isJoker(code) { return !!code && code.startsWith('Joker'); } diff --git a/frontend/src/lib/suggestions.js b/frontend/src/lib/suggestions.js index c0f9a37..2b098e9 100644 --- a/frontend/src/lib/suggestions.js +++ b/frontend/src/lib/suggestions.js @@ -1242,13 +1242,3 @@ function loadTechniquePool() { export async function getTechniqueSuggestion(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; -} diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 09a4b64..e4d0149 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -18,6 +18,7 @@ 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 = {}; let gameId = params.id; @@ -211,9 +212,13 @@ {/if}
- + + {#if state.game.phase !== 'scene'} + + {/if} + {:else if !error}

Loading game state...

diff --git a/src/pirats/crud_base.py b/src/pirats/crud_base.py index 738d493..b450bac 100644 --- a/src/pirats/crud_base.py +++ b/src/pirats/crud_base.py @@ -111,10 +111,6 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player], capt return base_size + 1 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: """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} @@ -139,6 +135,18 @@ def change_player_rank(db: Session, game: Game, player: Player, delta: int): db.commit() 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 = ""): """Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain.""" if game.captain_player_id == player_id: diff --git a/src/pirats/crud_scene.py b/src/pirats/crud_scene.py index 79f0d90..60dd7a1 100644 --- a/src/pirats/crud_scene.py +++ b/src/pirats/crud_scene.py @@ -6,7 +6,8 @@ from . import cards from .crud_base import ( get_player, get_game, get_player_hand, set_player_hand, 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 --- @@ -374,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" 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): obstacle = db.get(Obstacle, obstacle_id) if obstacle: diff --git a/src/pirats/crud_upkeep.py b/src/pirats/crud_upkeep.py index 1a87b70..77e5aa9 100644 --- a/src/pirats/crud_upkeep.py +++ b/src/pirats/crud_upkeep.py @@ -5,22 +5,11 @@ from .models import Game, Player, Vote from .crud_base import ( get_player, get_game, get_player_hand, set_player_hand, 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 --- -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: """The crewmates `voter` may nominate to rank up. An empty list means the 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) if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id: return False, "Player not found." - if voter_id == nominated_id: - return False, "You cannot nominate yourself. Nice try." - 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." + if not is_eligible_nominee(voter, nominee): + return False, "You can only nominate a living crewmate who isn't this scene's Deep — and not yourself." # Remove any existing vote by this voter for this game existing = db.exec( diff --git a/src/pirats/routes_character.py b/src/pirats/routes_character.py index cfee9b4..c95c5fc 100644 --- a/src/pirats/routes_character.py +++ b/src/pirats/routes_character.py @@ -88,28 +88,6 @@ def player_delegate_question( crud.delegate_question(db, player_id, question_type, target_player_id) 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 @router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}") def player_submit_delegated_answer( diff --git a/src/pirats/routes_scene.py b/src/pirats/routes_scene.py index 25c613e..8b1a1ab 100644 --- a/src/pirats/routes_scene.py +++ b/src/pirats/routes_scene.py @@ -146,23 +146,23 @@ def set_gat_description_route( db.commit() return {"status": "ok"} -# Pi-Rat bonus rank up for another player +# 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") def bonus_rank_up_route( game_id: str, player_id: str, - target_player_id: str = Form(...), + target_player_id: str = Form(""), 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) - if player and target and game and player.needs_rank_3_bonus: - player.needs_rank_3_bonus = False - db.add(player) - db.commit() - crud.change_player_rank(db, game, target, 1) - 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}.") + player = crud.get_player(db, player_id) + if not game or not player: + raise HTTPException(status_code=404, detail="Game or Player not found") + target = crud.get_player(db, target_player_id) if target_player_id else None + ok, msg = crud.grant_story_bonus_rank(db, game, player, target) + if not ok: + return JSONResponse({"error": msg}, status_code=400) return {"status": "ok"} # Pi-Rat becomes a ghost diff --git a/tests/test_game.py b/tests/test_game.py index 725e299..09acf57 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -345,7 +345,6 @@ def test_captaincy_and_hand_sizes(session): # No captain until the crew controls a ship assert game.captain_player_id is None - assert not crud.is_player_captain(p2, game) # Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 4 @@ -367,7 +366,6 @@ def test_captaincy_and_hand_sizes(session): crud.set_captain(session, game, p2.id) session.refresh(game) assert game.captain_player_id == p2.id - assert crud.is_player_captain(p2, game) # Captain privilege: +1 hand size assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 5 @@ -633,6 +631,41 @@ def test_player_death_toggle(session): assert not player.is_dead 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): game = crud.create_game(session) player = crud.add_player(session, game.id, "DeadRat")