Dead code cleanups

This commit is contained in:
2026-06-15 07:46:32 -07:00
parent 045857a8fd
commit daeac91d07
21 changed files with 578 additions and 473 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.
## 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.

40
TODO.md
View File

@@ -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.)*

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -1,5 +1,6 @@
<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 size = 'large'; // 'large' | 'medium' | 'mini'
@@ -7,7 +8,7 @@
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 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 style = '';
@@ -16,20 +17,23 @@
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
$: techName = techs && !joker ? techs[cardValue(card)] : null;
// Tooltip: explicit title wins; otherwise the suit's theme plus what the
// card would represent as an Obstacle (recomputes once the table loads)
$: tooltip = title || cardTooltip(card, $obstacleTable);
// Tooltip: an explicit title wins (plain text); otherwise a rich graphical
// tooltip describing the card itself (suit theme + Obstacle meaning), which
// recomputes once the obstacle table loads.
$: tipContent = title ? title : { html: cardTooltipHtml(card, $obstacleTable) };
$: ariaLabel = title || cardTooltip(card, $obstacleTable);
</script>
{#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="suit">{suit}</span>
{#if owner}<span class="owner">{owner}</span>{/if}
</div>
{:else}
<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>
<div class="card-corner top-left">
<span class="val">{val}</span>
@@ -44,7 +48,7 @@
</div>
{#if techName}
<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>
{/if}
<div class="card-corner bottom-right">

View File

@@ -1,8 +1,11 @@
<script>
import { tick } from 'svelte';
import { tick, onMount } from 'svelte';
import { apiRequest } from '../lib/api';
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 BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
@@ -27,6 +30,8 @@
};
let open = false;
// The content is visible whenever the floating log is open OR it's pinned inline.
$: shown = open || inline;
let logEl;
let events = []; // ascending by timestamp; accumulated across polls + history loads
let seenIds = new Set();
@@ -84,21 +89,25 @@
}
if (added) {
events = events;
if (open && atBottom) {
if (shown && atBottom) {
await tick();
scrollToBottom();
}
// Toast only for genuinely new events (not the initial seed or a
// rollback reseed) and only while the log is closed.
if (initialized && !reset && !open && newest) {
// 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;
}
// Opening the log makes the preview redundant.
$: if (open) toast = null;
// 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;
@@ -170,8 +179,8 @@
}
</script>
<div class="floating-event-log {open ? 'open' : ''}">
{#if toast && !open}
<div class="floating-event-log {shown ? 'open' : ''} {inline ? 'inline' : ''}">
{#if toast && !shown}
{#key toast.id}
<button
class="event-toast log-kind-{toast.kind}"
@@ -184,11 +193,15 @@
</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}
<div class="log-error">{error}</div>
{/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);

View File

@@ -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;
</script>
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
<!-- LEFT COLUMN: The Crew -->
<CrewColumn {state} />
<!-- MIDDLE COLUMN: The Shared Table -->
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
<div class="table-column">
<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">
<ChallengePanel {state} />
<ObstacleBoard {state} />
</div>
</div>
</div>
<!-- RIGHT COLUMN: Private Hand / Deep Controls -->
<div class="private-column">
{#if state.player.role === "deep"}
<DeepControlPanel {state} />
{:else}
<!-- Player Hand Card -->
{#if !isDeep}
<div class="card glass-panel hand-card">
<h3>🃏 Your Hand</h3>
<p class="section-desc">Keep your cards secret! When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.</p>
@@ -57,5 +44,24 @@
</div>
</div>
{/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>

View File

@@ -1,17 +1,27 @@
<script>
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;
let error = '';
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) : [];
$: 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;
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
$: scenePirats = state.players.filter(p => p.role === 'pirat');
$: isDeep = state.player.role === 'deep';
function playerName(id) {
return lookupName(state.players, id);
@@ -53,6 +63,31 @@
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>
{#if error}
@@ -89,17 +124,26 @@
{/if}
{#if ch.challenge_type === 'pvp'}
<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>
{:else}
<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)` : ''}.
Other Pi-Rats may assist (assistants don't draw back).
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).
</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 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(' · ')}
</p>
{/if}
@@ -140,10 +184,53 @@
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
<div class="challenge-actions">
{#each hand.filter(c => !isJoker(c)) as card}
<button class="btn btn-secondary" 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}
</div>
</div>
{/if}
</div>
{/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

@@ -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;
}
}
</script>
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
@@ -145,19 +171,35 @@
</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}
<div class="sheet-group margin-top">
<h4>Personal Objectives</h4>
{#if canManageObjectives}
<p class="info-text" style="font-size: 0.8rem;">As the Deep, tick these off in sequence (1 → 2 → 3).</p>
{/if}
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" checked={target.completed_personal_1} disabled>
<input type="checkbox" checked={target.completed_personal_1} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_1')}>
<span>1. Gat</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={target.completed_personal_2} disabled>
<input type="checkbox" checked={target.completed_personal_2} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_2')}>
<span>2. Name</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={target.completed_personal_3} disabled>
<input type="checkbox" checked={target.completed_personal_3} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_3')}>
<span>3. Die/Retire</span>
</label>
</div>

View File

@@ -1,5 +1,6 @@
<script>
import { slide } from 'svelte/transition';
import { apiRequest } from '../../lib/api';
import { crewLabel } from '../../lib/cards';
import CharacterSheet from './CharacterSheet.svelte';
@@ -7,6 +8,19 @@
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));
@@ -56,18 +70,20 @@
</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>
<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>
<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>
<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>

View File

@@ -1,157 +0,0 @@
<script>
import { apiRequest } from '../../lib/api';
import { displayName } from '../../lib/cards';
export let state;
let error = '';
let challengeTargetId = '';
let stakesSuccess = '';
let stakesFailure = '';
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_success: stakesSuccess,
stakes_failure: stakesFailure
});
challengeTargetId = '';
stakesSuccess = '';
stakesFailure = '';
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="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>
<!-- 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>
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker } from '../../lib/cards';
import Card from '../Card.svelte';
import ObstacleItem from './ObstacleItem.svelte';
export let state;
let error = '';
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
// A face-card Obstacle is worth the challenged Rat's Rank. When the Obstacle is
// part of an open Challenge we know who that is — return their rank, else null.
function challengedRankFor(obstacleId) {
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;
}
}
// When a Challenge is active, the involved Obstacles move up into the Challenge
// panel; the rest of the list collapses so attention stays on the Challenge.
$: challengeActive = openChallenges.length > 0;
$: looseObstacles = state.obstacles.filter(o => !challengedObstacleIds.has(o.id));
</script>
{#if error}
<div class="alert alert-danger">{error}</div>
{/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)}
<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)}" />
{#if challengeActive}
{#if looseObstacles.length}
<details class="obstacle-collapse">
<summary>🌊 {looseObstacles.length} other Obstacle{looseObstacles.length === 1 ? '' : 's'} on the list — show</summary>
<div class="obstacles-container">
{#each looseObstacles as obs (obs.id)}
<ObstacleItem {state} {obs} />
{/each}
</div>
</div>
{#if is_completed && state.player.role === 'deep'}
<div class="clear-obstacle-row text-center">
<button class="btn btn-success btn-full" on:click={() => clearObstacle(obs.id)}>Clear Obstacle</button>
</div>
</details>
{:else}
<p class="empty-text">All Obstacles are tied up in the Challenge above.</p>
{/if}
</div>
{:else}
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
{/each}
<div class="obstacles-container">
{#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: '♦️' };
// 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 => ({ '&': '&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) {
return !!code && code.startsWith('Joker');
}

View File

@@ -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;
}

View File

@@ -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}
</div>
<!-- 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}
<div class="loading-container">
<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
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:

View File

@@ -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:

View File

@@ -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(

View File

@@ -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(

View File

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

View File

@@ -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")