Dead code cleanups
This commit is contained in:
@@ -12,3 +12,11 @@ After editing `src/pirats/models.py`, generate a migration and sanity-check it:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list exists only to upgrade pre-Alembic databases to the baseline and must not grow.
|
Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list exists only to upgrade pre-Alembic databases to the baseline and must not grow.
|
||||||
|
|
||||||
|
## Learning during testing
|
||||||
|
|
||||||
|
When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions.
|
||||||
|
|
||||||
|
## Work order
|
||||||
|
|
||||||
|
You will be working on tasks in [TODO.md](./TODO.md]. Work on at most two tasks at a time (one is preferable, but two is fine if they dove tail really nicely), and after you finish each task, make a git commit and update the TODO list to check off the completed task.
|
||||||
|
|||||||
40
TODO.md
40
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
|
## 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.
|
- [ ] On the home page, rejoin a crew should be above muster a crew, and there should be a gap between the two boxes
|
||||||
|
|
||||||
- [ ] **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.
|
|
||||||
|
|
||||||
## Words Words Words
|
## 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.
|
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates.
|
||||||
|
|
||||||
## Cleanup
|
## Gameplay gaps
|
||||||
|
|
||||||
- [ ] **Look for dead code.** While implementing the rollback feature, we discovered /scene/rollback, an unused and buggy single-card-play rollback route that was added but never hooked up to the UI. Look for other code that matches that pattern -- orphan functions and routes that never get called.
|
- [x] **Finish the Rank-3 story bonus.** A Pi-Rat who completes their 3rd Personal Objective (RULEBOOK §93) now gets a "⭐ Your Story is Complete!" modal (`RankBonusModal.svelte`, keyed on `needs_rank_3_bonus`) to grant another eligible crewmate +1 Rank — or forfeit it when none qualify. *(Done 2026-06-15: logic in `crud_scene.grant_story_bonus_rank` reusing the shared `is_eligible_nominee` guard; route slimmed to the (ok, msg) convention; unit tests added; verified end-to-end in the browser.)*
|
||||||
|
|
||||||
- [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.
|
|
||||||
|
|||||||
@@ -462,3 +462,82 @@
|
|||||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
/* --- Scene board layout --- */
|
/* --- Scene board layout --- */
|
||||||
.scene-view-layout {
|
.scene-view-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 180px 1.4fr 1fr;
|
grid-template-columns: 180px minmax(0, 1.7fr) minmax(300px, 1fr);
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The middle column stacks hand / challenge / obstacle cards. */
|
||||||
|
.table-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stretch the log column to the full row height so its sticky panel can travel. */
|
||||||
|
.log-column {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.scene-view-layout {
|
.scene-view-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -175,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-item.in-challenge {
|
.obstacle-item.in-challenge {
|
||||||
box-shadow: 0 0 0 2px var(--danger);
|
box-shadow: 0 0 0 2px var(--deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -196,10 +207,56 @@
|
|||||||
|
|
||||||
.obstacle-card-wrapper {
|
.obstacle-card-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-caption {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Obstacle color (red/black) — fixed by the original card; matching it draws back */
|
||||||
|
.obstacle-color-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 0.12rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-color-tag::before {
|
||||||
|
content: '';
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-color-tag.red {
|
||||||
|
color: var(--suit-red);
|
||||||
|
background: color-mix(in srgb, var(--suit-red) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-color-tag.red::before { background: var(--suit-red); }
|
||||||
|
|
||||||
|
.obstacle-color-tag.black {
|
||||||
|
color: var(--suit-black);
|
||||||
|
background: color-mix(in srgb, var(--suit-black) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-color-tag.black::before { background: var(--suit-black); }
|
||||||
|
|
||||||
.suit-badge {
|
.suit-badge {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@@ -221,7 +278,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-details .in-challenge-tag {
|
.obstacle-details .in-challenge-tag {
|
||||||
color: var(--danger);
|
color: var(--deep);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,19 +351,25 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Challenges --- */
|
/* --- Challenges --- (framed in the Deep's teal so red/black always means suit color) */
|
||||||
|
/* The challenge-area card IS the box; the challenge content sits flush inside it. */
|
||||||
|
.challenge-area-card {
|
||||||
|
border-color: color-mix(in srgb, var(--deep) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.challenge-item {
|
.challenge-item {
|
||||||
background: color-mix(in srgb, var(--danger) 5%, transparent);
|
|
||||||
border: 1px solid var(--danger);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.challenge-item + .challenge-item {
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.challenge-item h4 {
|
.challenge-item h4 {
|
||||||
color: var(--danger);
|
color: var(--deep);
|
||||||
font-size: 1.15rem;
|
font-size: 1.25rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,37 +400,31 @@
|
|||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Deep control panel --- */
|
/* --- Challenge area (middle column) --- */
|
||||||
.deep-panel-card {
|
/* Obstacles applied to an active Challenge, pulled up into the Challenge panel */
|
||||||
max-height: 80vh;
|
.challenge-obstacles {
|
||||||
overflow-y: auto;
|
|
||||||
border-color: color-mix(in srgb, var(--deep) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deep-divider {
|
|
||||||
border: 0;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(to right, transparent, color-mix(in srgb, var(--deep) 40%, transparent), transparent);
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.obstacles-checkboxes {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 1rem;
|
||||||
background: var(--well);
|
margin-top: 0.75rem;
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
max-height: 150px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--edge);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.objective-player-block {
|
/* The Deep's call-a-challenge + end-scene controls live below any open Challenges */
|
||||||
background: var(--well);
|
.deep-challenge-controls {
|
||||||
padding: 0.5rem 0.75rem;
|
margin-top: 0.5rem;
|
||||||
border-radius: var(--radius-sm);
|
}
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
|
/* When a Challenge is active the rest of the Obstacle list collapses out of the way */
|
||||||
|
.obstacle-collapse > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-collapse[open] > summary {
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-upkeep-controls {
|
.scene-upkeep-controls {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, obstacleTable } from '../lib/cards';
|
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, cardTooltipHtml, obstacleTable } from '../lib/cards';
|
||||||
|
import { tooltip } from '../lib/tooltip';
|
||||||
|
|
||||||
export let card; // card code, e.g. "10D" or "Joker1"
|
export let card; // card code, e.g. "10D" or "Joker1"
|
||||||
export let size = 'large'; // 'large' | 'medium' | 'mini'
|
export let size = 'large'; // 'large' | 'medium' | 'mini'
|
||||||
@@ -7,7 +8,7 @@
|
|||||||
export let rotated = false; // mini: rendered as a played (rotated) column card
|
export let rotated = false; // mini: rendered as a played (rotated) column card
|
||||||
export let success = null; // mini: true/false adds success/failure styling
|
export let success = null; // mini: true/false adds success/failure styling
|
||||||
export let owner = ''; // mini: short label of who played the card
|
export let owner = ''; // mini: short label of who played the card
|
||||||
export let title = '';
|
export let title = ''; // explicit override; otherwise the card describes itself
|
||||||
export let techs = null; // large: {J, Q, K} technique names for the face-card overlay
|
export let techs = null; // large: {J, Q, K} technique names for the face-card overlay
|
||||||
export let style = '';
|
export let style = '';
|
||||||
|
|
||||||
@@ -16,20 +17,23 @@
|
|||||||
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
|
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
|
||||||
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
|
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
|
||||||
$: techName = techs && !joker ? techs[cardValue(card)] : null;
|
$: techName = techs && !joker ? techs[cardValue(card)] : null;
|
||||||
// Tooltip: explicit title wins; otherwise the suit's theme plus what the
|
// Tooltip: an explicit title wins (plain text); otherwise a rich graphical
|
||||||
// card would represent as an Obstacle (recomputes once the table loads)
|
// tooltip describing the card itself (suit theme + Obstacle meaning), which
|
||||||
$: tooltip = title || cardTooltip(card, $obstacleTable);
|
// recomputes once the obstacle table loads.
|
||||||
|
$: tipContent = title ? title : { html: cardTooltipHtml(card, $obstacleTable) };
|
||||||
|
$: ariaLabel = title || cardTooltip(card, $obstacleTable);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if size === 'mini'}
|
{#if size === 'mini'}
|
||||||
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}" title={tooltip}>
|
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}"
|
||||||
|
use:tooltip={tipContent} aria-label={ariaLabel}>
|
||||||
<span class="val">{val}</span>
|
<span class="val">{val}</span>
|
||||||
<span class="suit">{suit}</span>
|
<span class="suit">{suit}</span>
|
||||||
{#if owner}<span class="owner">{owner}</span>{/if}
|
{#if owner}<span class="owner">{owner}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
|
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
|
||||||
title={tooltip} {draggable} {style}
|
use:tooltip={tipContent} aria-label={ariaLabel} {draggable} {style}
|
||||||
on:dragstart on:dragend on:click>
|
on:dragstart on:dragend on:click>
|
||||||
<div class="card-corner top-left">
|
<div class="card-corner top-left">
|
||||||
<span class="val">{val}</span>
|
<span class="val">{val}</span>
|
||||||
@@ -44,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if techName}
|
{#if techName}
|
||||||
<div class="card-tech-overlay">
|
<div class="card-tech-overlay">
|
||||||
<div class="tech-tag" title="Automatic success when played">{cardValue(card)}: "{techName}"</div>
|
<div class="tech-tag" use:tooltip={"Automatic success when played"}>{cardValue(card)}: "{techName}"</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="card-corner bottom-right">
|
<div class="card-corner bottom-right">
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script>
|
<script>
|
||||||
import { tick } from 'svelte';
|
import { tick, onMount } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
// Inline mode pins the log open as a column (scene phase) instead of the
|
||||||
|
// floating, collapsible corner panel used in every other phase.
|
||||||
|
export let inline = false;
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
||||||
@@ -27,6 +30,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let open = false;
|
let open = false;
|
||||||
|
// The content is visible whenever the floating log is open OR it's pinned inline.
|
||||||
|
$: shown = open || inline;
|
||||||
let logEl;
|
let logEl;
|
||||||
let events = []; // ascending by timestamp; accumulated across polls + history loads
|
let events = []; // ascending by timestamp; accumulated across polls + history loads
|
||||||
let seenIds = new Set();
|
let seenIds = new Set();
|
||||||
@@ -84,21 +89,25 @@
|
|||||||
}
|
}
|
||||||
if (added) {
|
if (added) {
|
||||||
events = events;
|
events = events;
|
||||||
if (open && atBottom) {
|
if (shown && atBottom) {
|
||||||
await tick();
|
await tick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
// Toast only for genuinely new events (not the initial seed or a
|
// Toast only for genuinely new events (not the initial seed or a
|
||||||
// rollback reseed) and only while the log is closed.
|
// rollback reseed) and only while the floating log is closed.
|
||||||
if (initialized && !reset && !open && newest) {
|
if (initialized && !reset && !shown && newest) {
|
||||||
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
|
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
initialized = true;
|
initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opening the log makes the preview redundant.
|
// An open/inline log makes the preview toast redundant.
|
||||||
$: if (open) toast = null;
|
$: if (shown) toast = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (inline) scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
function clearToast(id) {
|
function clearToast(id) {
|
||||||
if (toast && toast.id === id) toast = null;
|
if (toast && toast.id === id) toast = null;
|
||||||
@@ -170,8 +179,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="floating-event-log {open ? 'open' : ''}">
|
<div class="floating-event-log {shown ? 'open' : ''} {inline ? 'inline' : ''}">
|
||||||
{#if toast && !open}
|
{#if toast && !shown}
|
||||||
{#key toast.id}
|
{#key toast.id}
|
||||||
<button
|
<button
|
||||||
class="event-toast log-kind-{toast.kind}"
|
class="event-toast log-kind-{toast.kind}"
|
||||||
@@ -184,11 +193,15 @@
|
|||||||
</button>
|
</button>
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if inline}
|
||||||
|
<div class="log-header">📜 Event Log</div>
|
||||||
|
{:else}
|
||||||
<button class="toggle-log-btn" on:click={toggleOpen}>
|
<button class="toggle-log-btn" on:click={toggleOpen}>
|
||||||
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if open}
|
{#if shown}
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="log-error">{error}</div>
|
<div class="log-error">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -253,6 +266,29 @@
|
|||||||
.floating-event-log:not(.open) {
|
.floating-event-log:not(.open) {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
/* Inline mode: pinned as the scene's third column instead of floating. */
|
||||||
|
.floating-event-log.inline {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.floating-event-log.inline {
|
||||||
|
position: static;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.log-header {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
.toggle-log-btn {
|
.toggle-log-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
@@ -2,40 +2,27 @@
|
|||||||
import Card from './Card.svelte';
|
import Card from './Card.svelte';
|
||||||
import ChallengePanel from './scene/ChallengePanel.svelte';
|
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||||
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||||
import DeepControlPanel from './scene/DeepControlPanel.svelte';
|
|
||||||
import CrewColumn from './scene/CrewColumn.svelte';
|
import CrewColumn from './scene/CrewColumn.svelte';
|
||||||
|
import EventLog from './EventLog.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
// The Challenge area is shown when something is happening there, or to the Deep
|
||||||
|
// (who calls Challenges and ends the scene from it).
|
||||||
|
$: showChallengeArea = isDeep || openChallenges.length > 0;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||||
<!-- LEFT COLUMN: The Crew -->
|
<!-- LEFT COLUMN: The Crew -->
|
||||||
<CrewColumn {state} />
|
<CrewColumn {state} />
|
||||||
|
|
||||||
<!-- MIDDLE COLUMN: The Shared Table -->
|
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
|
||||||
<div class="table-column">
|
<div class="table-column">
|
||||||
<div class="card glass-panel obstacle-list-card">
|
{#if !isDeep}
|
||||||
<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 -->
|
|
||||||
<div class="card glass-panel hand-card">
|
<div class="card glass-panel hand-card">
|
||||||
<h3>🃏 Your Hand</h3>
|
<h3>🃏 Your Hand</h3>
|
||||||
<p class="section-desc">Keep your cards secret! When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.</p>
|
<p class="section-desc">Keep your cards secret! When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.</p>
|
||||||
@@ -57,5 +44,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showChallengeArea}
|
||||||
|
<div class="card glass-panel challenge-area-card">
|
||||||
|
<ChallengePanel {state} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card glass-panel obstacle-list-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌊 The Obstacle List</h3>
|
||||||
|
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
|
||||||
|
</div>
|
||||||
|
<ObstacleBoard {state} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: The Event Log -->
|
||||||
|
<div class="log-column">
|
||||||
|
<EventLog {state} inline />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltip, obstacleTable } from '../../lib/cards';
|
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltipHtml, obstacleTable } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
|
import ObstacleItem from './ObstacleItem.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
let error = '';
|
let error = '';
|
||||||
let taxTargetId = '';
|
let taxTargetId = '';
|
||||||
|
// Deep: call-a-challenge form (moved here from the old Deep Control Panel)
|
||||||
|
let showCreate = false;
|
||||||
|
let challengeTargetId = '';
|
||||||
|
let stakesSuccess = '';
|
||||||
|
let stakesFailure = '';
|
||||||
|
let selectedObstacles = {};
|
||||||
|
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||||
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
|
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
|
||||||
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
|
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
|
||||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
|
||||||
function playerName(id) {
|
function playerName(id) {
|
||||||
return lookupName(state.players, id);
|
return lookupName(state.players, id);
|
||||||
@@ -53,6 +63,31 @@
|
|||||||
taxTargetId = '';
|
taxTargetId = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createChallenge() {
|
||||||
|
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
|
||||||
|
if (await post('challenge/create', {
|
||||||
|
target_player_id: challengeTargetId,
|
||||||
|
obstacle_ids: JSON.stringify(ids),
|
||||||
|
stakes_success: stakesSuccess,
|
||||||
|
stakes_failure: stakesFailure,
|
||||||
|
})) {
|
||||||
|
challengeTargetId = '';
|
||||||
|
stakesSuccess = '';
|
||||||
|
stakesFailure = '';
|
||||||
|
selectedObstacles = {};
|
||||||
|
showCreate = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endScene() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -89,17 +124,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if ch.challenge_type === 'pvp'}
|
{#if ch.challenge_type === 'pvp'}
|
||||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||||
Temporary Obstacle: <strong title={cardTooltip(ch.temp_card, $obstacleTable)}>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable) }}>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||||
Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'}
|
Attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
|
||||||
— attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
|
Drag a card onto an Obstacle below to play it. Other Pi-Rats may assist (assistants don't draw back).
|
||||||
Other Pi-Rats may assist (assistants don't draw back).
|
|
||||||
</p>
|
</p>
|
||||||
|
<div class="challenge-obstacles">
|
||||||
|
{#each chObstacleIds as oid}
|
||||||
|
{@const obs = state.obstacles.find(o => o.id === oid)}
|
||||||
|
{#if obs}
|
||||||
|
<ObstacleItem {state} {obs} embedded />
|
||||||
|
{:else}
|
||||||
|
<p class="info-text" style="margin: 0;">An applied Obstacle was discarded.</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if chPlays.length > 0}
|
{#if chPlays.length > 0}
|
||||||
<p class="info-text" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;">
|
<p class="info-text" style="margin: 0.5rem 0 0 0; font-size: 0.85rem;">
|
||||||
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
|
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -140,10 +184,53 @@
|
|||||||
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
||||||
<div class="challenge-actions">
|
<div class="challenge-actions">
|
||||||
{#each hand.filter(c => !isJoker(c)) as card}
|
{#each hand.filter(c => !isJoker(c)) as card}
|
||||||
<button class="btn btn-secondary" title={cardTooltip(card, $obstacleTable)} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
<button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Deep controls: call a Challenge, and end the scene when none is active -->
|
||||||
|
<!-- The Deep calls Challenges and ends the scene only when none is active. -->
|
||||||
|
{#if isDeep && openChallenges.length === 0}
|
||||||
|
<div class="deep-challenge-controls">
|
||||||
|
<button class="btn {showCreate ? 'btn-secondary' : 'btn-primary'} btn-full" on:click={() => showCreate = !showCreate}>
|
||||||
|
{showCreate ? '✕ Cancel' : '⚔️ Call a Challenge'}
|
||||||
|
</button>
|
||||||
|
{#if showCreate}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
|
||||||
|
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<option value="">Challenge which Pi-Rat?</option>
|
||||||
|
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||||
|
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<div style="margin-bottom: 0.5rem;">
|
||||||
|
{#each state.obstacles as obs}
|
||||||
|
{@const taken = challengedObstacleIds.has(obs.id)}
|
||||||
|
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
|
||||||
|
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
|
||||||
|
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<input type="text" class="input-field" placeholder="On success (optional): what they win…" bind:value={stakesSuccess} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<input type="text" class="input-field" placeholder="On failure (optional): what it costs them…" bind:value={stakesFailure} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<button class="btn btn-primary btn-full" on:click={createChallenge}
|
||||||
|
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
|
||||||
|
Call Challenge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="scene-upkeep-controls text-center">
|
||||||
|
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
|
||||||
|
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
|
||||||
|
End Scene & Proceed to Ranking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
|
|
||||||
$: viewer = state.player;
|
$: viewer = state.player;
|
||||||
$: isSelf = target.id === viewer.id;
|
$: 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.
|
// The viewer throws one of their OWN cards in a duel.
|
||||||
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
|
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
|
||||||
$: canDuel = !isSelf
|
$: canDuel = !isSelf
|
||||||
@@ -41,6 +47,26 @@
|
|||||||
error = e.message;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||||
@@ -145,19 +171,35 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="sheet-group margin-top">
|
||||||
<h4>Personal Objectives</h4>
|
<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">
|
<div class="objectives-checklist">
|
||||||
<label class="checkbox-label">
|
<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>
|
<span>1. Gat</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-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>
|
<span>2. Name</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-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>
|
<span>3. Die/Retire</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
import { crewLabel } from '../../lib/cards';
|
import { crewLabel } from '../../lib/cards';
|
||||||
import CharacterSheet from './CharacterSheet.svelte';
|
import CharacterSheet from './CharacterSheet.svelte';
|
||||||
|
|
||||||
@@ -7,6 +8,19 @@
|
|||||||
|
|
||||||
let openTargetId = null;
|
let openTargetId = null;
|
||||||
let showObjectives = false;
|
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.
|
// 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));
|
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
||||||
@@ -56,18 +70,20 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if showObjectives}
|
{#if showObjectives}
|
||||||
<div class="crew-objectives-detail" transition:slide>
|
<div class="crew-objectives-detail" transition:slide>
|
||||||
|
{#if objError}<div class="alert alert-danger">{objError}</div>{/if}
|
||||||
<label class="checkbox-label">
|
<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>
|
<span>Steal a Ship</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-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>
|
<span>Choose a Captain</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-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>
|
<span>Commit Piracy</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,130 +1,35 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../../lib/api';
|
import ObstacleItem from './ObstacleItem.svelte';
|
||||||
import { getCardDisplay, isJoker } from '../../lib/cards';
|
|
||||||
import Card from '../Card.svelte';
|
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
let error = '';
|
|
||||||
|
|
||||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||||
|
// When a Challenge is active, the involved Obstacles move up into the Challenge
|
||||||
// A face-card Obstacle is worth the challenged Rat's Rank. When the Obstacle is
|
// panel; the rest of the list collapses so attention stays on the Challenge.
|
||||||
// part of an open Challenge we know who that is — return their rank, else null.
|
$: challengeActive = openChallenges.length > 0;
|
||||||
function challengedRankFor(obstacleId) {
|
$: looseObstacles = state.obstacles.filter(o => !challengedObstacleIds.has(o.id));
|
||||||
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obstacleId));
|
|
||||||
if (!ch) return null;
|
|
||||||
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playCard(obstacleId, cardCode) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
|
|
||||||
obstacle_id: obstacleId,
|
|
||||||
card_code: cardCode
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playJoker(obstacleId, cardCode) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-joker`, 'POST', {
|
|
||||||
obstacle_id: obstacleId,
|
|
||||||
card_code: cardCode
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearObstacle(obstacleId) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if error}
|
{#if challengeActive}
|
||||||
<div class="alert alert-danger">{error}</div>
|
{#if looseObstacles.length}
|
||||||
{/if}
|
<details class="obstacle-collapse">
|
||||||
|
<summary>🌊 {looseObstacles.length} other Obstacle{looseObstacles.length === 1 ? '' : 's'} on the list — show</summary>
|
||||||
{#each state.obstacles as obs}
|
<div class="obstacles-container">
|
||||||
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
|
{#each looseObstacles as obs (obs.id)}
|
||||||
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card}
|
<ObstacleItem {state} {obs} />
|
||||||
{@const success_count = column_cards.filter(c => c.success).length}
|
|
||||||
{@const is_completed = success_count >= state.players.length}
|
|
||||||
{@const in_challenge = challengedObstacleIds.has(obs.id)}
|
|
||||||
<div class="obstacle-item suit-{obs.suit.toLowerCase()} {is_completed ? 'completed' : ''} {in_challenge ? 'in-challenge' : ''}"
|
|
||||||
data-obstacle-id={obs.id}
|
|
||||||
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
|
||||||
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
|
||||||
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
|
|
||||||
on:drop={(e) => {
|
|
||||||
if (is_completed) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.currentTarget.classList.remove("drag-over");
|
|
||||||
const cardCode = e.dataTransfer.getData('text/plain');
|
|
||||||
if (cardCode) {
|
|
||||||
if (isJoker(cardCode)) playJoker(obs.id, cardCode);
|
|
||||||
else playCard(obs.id, cardCode);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<div class="obstacle-card-wrapper">
|
|
||||||
<Card card={active_card_code} size="medium" title="Active Card: {getCardDisplay(active_card_code)}" />
|
|
||||||
</div>
|
|
||||||
<div class="obstacle-details">
|
|
||||||
<h4>{obs.title} {#if in_challenge}<span class="in-challenge-tag">⚔️ In Challenge</span>{/if}</h4>
|
|
||||||
<p class="desc">{obs.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Value Display -->
|
|
||||||
<div class="obstacle-value-display text-center">
|
|
||||||
<span class="val-label">Current Difficulty</span>
|
|
||||||
<span class="val-number">
|
|
||||||
{#if ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1))}
|
|
||||||
{@const rank = challengedRankFor(obs.id)}
|
|
||||||
{rank !== null ? `${rank} (Rat's Rank)` : "Challenged Rat's Rank"}
|
|
||||||
{:else}
|
|
||||||
{obs.current_value}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Successes Tracker -->
|
|
||||||
<div class="obstacle-successes-display text-center">
|
|
||||||
<span class="val-label">Successes</span>
|
|
||||||
<span class="val-number">
|
|
||||||
{success_count} / {state.players.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Played Cards Column -->
|
|
||||||
<div class="played-column">
|
|
||||||
<h5>Card History (Column)</h5>
|
|
||||||
<div class="column-cards-flex">
|
|
||||||
<Card card={obs.original_card} size="mini" />
|
|
||||||
{#each column_cards as pc}
|
|
||||||
<Card card={pc.card} size="mini" rotated success={pc.success}
|
|
||||||
owner={pc.player_name.slice(0, 4)}
|
|
||||||
title="{pc.player_name} played {getCardDisplay(pc.card)}" />
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
|
{:else}
|
||||||
{#if is_completed && state.player.role === 'deep'}
|
<p class="empty-text">All Obstacles are tied up in the Challenge above.</p>
|
||||||
<div class="clear-obstacle-row text-center">
|
|
||||||
<button class="btn btn-success btn-full" on:click={() => clearObstacle(obs.id)}>Clear Obstacle</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
<div class="obstacles-container">
|
||||||
{/each}
|
{#each state.obstacles as obs (obs.id)}
|
||||||
|
<ObstacleItem {state} {obs} />
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text">No active obstacles. The Deep can end the scene!</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { apiRequest } from './api';
|
|||||||
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
|
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
|
||||||
|
|
||||||
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
|
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
|
||||||
export const SUIT_THEMES = {
|
const SUIT_THEMES = {
|
||||||
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
|
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
|
||||||
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
|
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
|
||||||
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
|
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
|
||||||
@@ -46,6 +46,7 @@ export function cardObstacleInfo(code, table = OBSTACLE_TABLE) {
|
|||||||
// Default tooltip for a card: its suit theme when played, plus what it
|
// Default tooltip for a card: its suit theme when played, plus what it
|
||||||
// represents as an Obstacle (relevant when challenging another Pi-Rat).
|
// represents as an Obstacle (relevant when challenging another Pi-Rat).
|
||||||
// Pass the obstacleTable store's value as `table` to recompute reactively.
|
// Pass the obstacleTable store's value as `table` to recompute reactively.
|
||||||
|
// Plain-string form, kept for aria-label / native fallbacks.
|
||||||
export function cardTooltip(code, table = OBSTACLE_TABLE) {
|
export function cardTooltip(code, table = OBSTACLE_TABLE) {
|
||||||
if (!code) return '';
|
if (!code) return '';
|
||||||
if (isJoker(code)) {
|
if (isJoker(code)) {
|
||||||
@@ -58,6 +59,42 @@ export function cardTooltip(code, table = OBSTACLE_TABLE) {
|
|||||||
return `${theme}\nAs an Obstacle: ${obstacle.title} — ${obstacle.description}`;
|
return `${theme}\nAs an Obstacle: ${obstacle.title} — ${obstacle.description}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FACE_VALUES = ['J', 'Q', 'K'];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rich (HTML) tooltip describing a card — what it does when *played*, and what
|
||||||
|
// it means when it surfaces as an Obstacle. Built only from static rulebook
|
||||||
|
// data (SUIT_THEMES + the obstacle table), so the HTML is trusted. Feed the
|
||||||
|
// result to the `tooltip` action as `{ html: cardTooltipHtml(...) }`.
|
||||||
|
export function cardTooltipHtml(code, table = OBSTACLE_TABLE) {
|
||||||
|
if (!code) return '';
|
||||||
|
if (isJoker(code)) {
|
||||||
|
return '<div class="tt-head"><span class="tt-card tt-joker">🃏 Joker</span></div>'
|
||||||
|
+ '<div class="tt-row"><span class="tt-label">Played</span>Discard one Obstacle (and its column) outright, then draw a replacement.</div>'
|
||||||
|
+ '<div class="tt-row"><span class="tt-label">As an Obstacle</span>Discarded — but every following scene needs one more Obstacle.</div>';
|
||||||
|
}
|
||||||
|
const suit = cardSuit(code);
|
||||||
|
const val = cardValue(code);
|
||||||
|
const isRed = suit === 'H' || suit === 'D';
|
||||||
|
const colorClass = isRed ? 'tt-red' : 'tt-black';
|
||||||
|
let html = `<div class="tt-head"><span class="tt-card ${colorClass}">${esc(val)}${SUIT_EMOJI[suit] || esc(suit)}</span>`
|
||||||
|
+ `<span class="tt-color tt-color-${isRed ? 'red' : 'black'}">${isRed ? 'Red' : 'Black'}</span></div>`;
|
||||||
|
html += `<div class="tt-theme">${esc(SUIT_THEMES[suit] || '')}</div>`;
|
||||||
|
if (FACE_VALUES.includes(val)) {
|
||||||
|
html += '<div class="tt-row"><span class="tt-label">Played by a Pi-Rat</span>Triggers a Secret Technique — automatic success.</div>';
|
||||||
|
html += "<div class=\"tt-row\"><span class=\"tt-label\">As an Obstacle</span>Its value equals the challenged Pi-Rat's Rank.</div>";
|
||||||
|
} else {
|
||||||
|
const obstacle = table?.[suit]?.[val];
|
||||||
|
if (obstacle) {
|
||||||
|
html += `<div class="tt-row"><span class="tt-label">As an Obstacle</span><b>${esc(obstacle.title)}</b> — ${esc(obstacle.description)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
export function isJoker(code) {
|
export function isJoker(code) {
|
||||||
return !!code && code.startsWith('Joker');
|
return !!code && code.startsWith('Joker');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1242,13 +1242,3 @@ function loadTechniquePool() {
|
|||||||
export async function getTechniqueSuggestion(exclude = []) {
|
export async function getTechniqueSuggestion(exclude = []) {
|
||||||
return pickFrom(await loadTechniquePool(), exclude);
|
return pickFrom(await loadTechniquePool(), exclude);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draws `count` distinct technique suggestions.
|
|
||||||
export async function getTechniqueSuggestions(count, exclude = []) {
|
|
||||||
const pool = await loadTechniquePool();
|
|
||||||
const picked = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
picked.push(pickFrom(pool, [...exclude, ...picked]));
|
|
||||||
}
|
|
||||||
return picked;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import EventLog from '../components/EventLog.svelte';
|
import EventLog from '../components/EventLog.svelte';
|
||||||
import NameModal from '../components/NameModal.svelte';
|
import NameModal from '../components/NameModal.svelte';
|
||||||
import GatModal from '../components/GatModal.svelte';
|
import GatModal from '../components/GatModal.svelte';
|
||||||
|
import RankBonusModal from '../components/RankBonusModal.svelte';
|
||||||
|
|
||||||
export let params = {};
|
export let params = {};
|
||||||
let gameId = params.id;
|
let gameId = params.id;
|
||||||
@@ -211,9 +212,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- The scene phase renders its own inline Event Log as the third column. -->
|
||||||
|
{#if state.game.phase !== 'scene'}
|
||||||
<EventLog {state} />
|
<EventLog {state} />
|
||||||
|
{/if}
|
||||||
<NameModal {state} />
|
<NameModal {state} />
|
||||||
<GatModal {state} />
|
<GatModal {state} />
|
||||||
|
<RankBonusModal {state} />
|
||||||
{:else if !error}
|
{:else if !error}
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<p>Loading game state...</p>
|
<p>Loading game state...</p>
|
||||||
|
|||||||
@@ -111,10 +111,6 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player], capt
|
|||||||
return base_size + 1
|
return base_size + 1
|
||||||
return base_size
|
return base_size
|
||||||
|
|
||||||
def is_player_captain(player: Player, game: Game) -> bool:
|
|
||||||
"""The Captain is a persistent position tracked on the Game (set once the crew steals a ship)."""
|
|
||||||
return game.captain_player_id is not None and game.captain_player_id == player.id
|
|
||||||
|
|
||||||
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict:
|
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict:
|
||||||
"""Snapshot every player's max hand size, for detecting increases after a Rank/Captain change."""
|
"""Snapshot every player's max hand size, for detecting increases after a Rank/Captain change."""
|
||||||
return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
|
return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
|
||||||
@@ -139,6 +135,18 @@ def change_player_rank(db: Session, game: Game, player: Player, delta: int):
|
|||||||
db.commit()
|
db.commit()
|
||||||
apply_hand_increases(db, game, old_maxes)
|
apply_hand_increases(db, game, old_maxes)
|
||||||
|
|
||||||
|
def is_eligible_nominee(voter, nominee) -> bool:
|
||||||
|
"""A rank-up nominee must be another living, in-play Pi-Rat. Excludes the
|
||||||
|
voter, previous-scene Deep players, and dead / awaiting-recruit players
|
||||||
|
(ranking them up is wasted — recruits reset to Rank 1). Shared by the
|
||||||
|
between-scenes rank vote and the 3rd-objective story bonus."""
|
||||||
|
return (
|
||||||
|
nominee.id != voter.id
|
||||||
|
and nominee.role != "deep"
|
||||||
|
and not nominee.is_dead
|
||||||
|
and not nominee.needs_reroll
|
||||||
|
)
|
||||||
|
|
||||||
def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""):
|
def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""):
|
||||||
"""Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
|
"""Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
|
||||||
if game.captain_player_id == player_id:
|
if game.captain_player_id == player_id:
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from . import cards
|
|||||||
from .crud_base import (
|
from .crud_base import (
|
||||||
get_player, get_game, get_player_hand, set_player_hand,
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
||||||
change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS
|
change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS,
|
||||||
|
is_eligible_nominee
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Scene Setup Operations ---
|
# --- Scene Setup Operations ---
|
||||||
@@ -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"
|
status_text = "completed" if status else "unmarked"
|
||||||
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
|
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
|
||||||
|
|
||||||
|
def grant_story_bonus_rank(db: Session, game: Game, granter: Player, target):
|
||||||
|
"""A Pi-Rat who finished their story (3rd Personal Objective) grants another
|
||||||
|
eligible Pi-Rat +1 Rank. `target=None` forfeits the bonus (e.g. no eligible
|
||||||
|
crewmate); either way the needs_rank_3_bonus prompt clears. Naming an
|
||||||
|
ineligible target is refused so the prompt stays up rather than waste the Rank."""
|
||||||
|
if not granter.needs_rank_3_bonus:
|
||||||
|
return False, "There is no story Rank bonus to grant."
|
||||||
|
if target is not None and not is_eligible_nominee(granter, target):
|
||||||
|
return False, "Pick a living crewmate who isn't this scene's Deep."
|
||||||
|
if target is not None:
|
||||||
|
change_player_rank(db, game, target, 1)
|
||||||
|
add_game_event(db, game.id, f"{granter.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.", kind="rank")
|
||||||
|
else:
|
||||||
|
add_game_event(db, game.id, f"{granter.name} completed their story, but had no crewmate to pass a Rank to.", kind="rank")
|
||||||
|
granter.needs_rank_3_bonus = False
|
||||||
|
db.add(granter)
|
||||||
|
db.commit()
|
||||||
|
return True, "Rank granted."
|
||||||
|
|
||||||
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
||||||
obstacle = db.get(Obstacle, obstacle_id)
|
obstacle = db.get(Obstacle, obstacle_id)
|
||||||
if obstacle:
|
if obstacle:
|
||||||
|
|||||||
@@ -5,22 +5,11 @@ from .models import Game, Player, Vote
|
|||||||
from .crud_base import (
|
from .crud_base import (
|
||||||
get_player, get_game, get_player_hand, set_player_hand,
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
|
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
|
||||||
calculate_max_hand_size, change_player_rank, set_captain
|
calculate_max_hand_size, change_player_rank, set_captain, is_eligible_nominee
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Between Scenes Operations ---
|
# --- Between Scenes Operations ---
|
||||||
|
|
||||||
def is_eligible_nominee(voter, nominee) -> bool:
|
|
||||||
"""A rank-up nominee must be another living, in-play Pi-Rat. Excludes the
|
|
||||||
voter, previous-scene Deep players, and dead / awaiting-recruit players
|
|
||||||
(ranking them up is wasted — recruits reset to Rank 1)."""
|
|
||||||
return (
|
|
||||||
nominee.id != voter.id
|
|
||||||
and nominee.role != "deep"
|
|
||||||
and not nominee.is_dead
|
|
||||||
and not nominee.needs_reroll
|
|
||||||
)
|
|
||||||
|
|
||||||
def eligible_vote_nominees(game: Game, voter) -> list:
|
def eligible_vote_nominees(game: Game, voter) -> list:
|
||||||
"""The crewmates `voter` may nominate to rank up. An empty list means the
|
"""The crewmates `voter` may nominate to rank up. An empty list means the
|
||||||
voter has no valid pick and skips voting entirely (no deadlock)."""
|
voter has no valid pick and skips voting entirely (no deadlock)."""
|
||||||
@@ -37,12 +26,8 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
|
|||||||
nominee = get_player(db, nominated_id)
|
nominee = get_player(db, nominated_id)
|
||||||
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
|
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
|
||||||
return False, "Player not found."
|
return False, "Player not found."
|
||||||
if voter_id == nominated_id:
|
if not is_eligible_nominee(voter, nominee):
|
||||||
return False, "You cannot nominate yourself. Nice try."
|
return False, "You can only nominate a living crewmate who isn't this scene's Deep — and not yourself."
|
||||||
if nominee.role == "deep":
|
|
||||||
return False, "Deep Players from the previous scene cannot be nominated."
|
|
||||||
if nominee.is_dead or nominee.needs_reroll:
|
|
||||||
return False, "You can't nominate a Pi-Rat who's out of play. Pick a living crewmate."
|
|
||||||
|
|
||||||
# Remove any existing vote by this voter for this game
|
# Remove any existing vote by this voter for this game
|
||||||
existing = db.exec(
|
existing = db.exec(
|
||||||
|
|||||||
@@ -88,28 +88,6 @@ def player_delegate_question(
|
|||||||
crud.delegate_question(db, player_id, question_type, target_player_id)
|
crud.delegate_question(db, player_id, question_type, target_player_id)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Revoke a delegated question task
|
|
||||||
@router.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}")
|
|
||||||
def player_revoke_delegation(
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
question_type: str, # "like" or "hate"
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
|
||||||
if question_type == "like":
|
|
||||||
player.other_like_from_player_id = None
|
|
||||||
player.other_like = ""
|
|
||||||
elif question_type == "hate":
|
|
||||||
player.other_hate_from_player_id = None
|
|
||||||
player.other_hate = ""
|
|
||||||
db.add(player)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
# Answer a delegated question for another player
|
# Answer a delegated question for another player
|
||||||
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
||||||
def player_submit_delegated_answer(
|
def player_submit_delegated_answer(
|
||||||
|
|||||||
@@ -146,23 +146,23 @@ def set_gat_description_route(
|
|||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "ok"}
|
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")
|
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
|
||||||
def bonus_rank_up_route(
|
def bonus_rank_up_route(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
target_player_id: str = Form(...),
|
target_player_id: str = Form(""),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
target = crud.get_player(db, target_player_id)
|
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
if player and target and game and player.needs_rank_3_bonus:
|
player = crud.get_player(db, player_id)
|
||||||
player.needs_rank_3_bonus = False
|
if not game or not player:
|
||||||
db.add(player)
|
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||||
db.commit()
|
target = crud.get_player(db, target_player_id) if target_player_id else None
|
||||||
crud.change_player_rank(db, game, target, 1)
|
ok, msg = crud.grant_story_bonus_rank(db, game, player, target)
|
||||||
crud.add_game_event(db, game_id, f"{player.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.")
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Pi-Rat becomes a ghost
|
# Pi-Rat becomes a ghost
|
||||||
|
|||||||
@@ -345,7 +345,6 @@ def test_captaincy_and_hand_sizes(session):
|
|||||||
|
|
||||||
# No captain until the crew controls a ship
|
# No captain until the crew controls a ship
|
||||||
assert game.captain_player_id is None
|
assert game.captain_player_id is None
|
||||||
assert not crud.is_player_captain(p2, game)
|
|
||||||
|
|
||||||
# Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards
|
# Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards
|
||||||
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 4
|
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 4
|
||||||
@@ -367,7 +366,6 @@ def test_captaincy_and_hand_sizes(session):
|
|||||||
crud.set_captain(session, game, p2.id)
|
crud.set_captain(session, game, p2.id)
|
||||||
session.refresh(game)
|
session.refresh(game)
|
||||||
assert game.captain_player_id == p2.id
|
assert game.captain_player_id == p2.id
|
||||||
assert crud.is_player_captain(p2, game)
|
|
||||||
# Captain privilege: +1 hand size
|
# Captain privilege: +1 hand size
|
||||||
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 5
|
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.is_dead
|
||||||
assert not player.completed_personal_3
|
assert not player.completed_personal_3
|
||||||
|
|
||||||
|
def test_story_bonus_rank_grant(session):
|
||||||
|
game, deep, (granter, mate) = make_scene_game(session, num_pirats=2)
|
||||||
|
crud.toggle_objective(session, game.id, granter.id, "personal_3", True)
|
||||||
|
session.refresh(granter)
|
||||||
|
assert granter.needs_rank_3_bonus
|
||||||
|
start_rank = mate.rank
|
||||||
|
ok, _ = crud.grant_story_bonus_rank(session, game, granter, mate)
|
||||||
|
assert ok
|
||||||
|
session.refresh(granter)
|
||||||
|
session.refresh(mate)
|
||||||
|
assert not granter.needs_rank_3_bonus
|
||||||
|
assert mate.rank == start_rank + 1
|
||||||
|
|
||||||
|
def test_story_bonus_rank_guards_and_forfeit(session):
|
||||||
|
game, deep, (granter, mate) = make_scene_game(session, num_pirats=2)
|
||||||
|
# No bonus pending yet -> refused.
|
||||||
|
ok, _ = crud.grant_story_bonus_rank(session, game, granter, mate)
|
||||||
|
assert not ok
|
||||||
|
crud.toggle_objective(session, game.id, granter.id, "personal_3", True)
|
||||||
|
session.refresh(granter)
|
||||||
|
mate_rank = mate.rank
|
||||||
|
# Can't grant to yourself, nor to the Deep -> refused, prompt stays up.
|
||||||
|
for bad_target in (granter, deep):
|
||||||
|
ok, _ = crud.grant_story_bonus_rank(session, game, granter, bad_target)
|
||||||
|
assert not ok
|
||||||
|
session.refresh(granter)
|
||||||
|
assert granter.needs_rank_3_bonus
|
||||||
|
# Forfeit (no eligible crewmate) clears the prompt without changing any Rank.
|
||||||
|
ok, _ = crud.grant_story_bonus_rank(session, game, granter, None)
|
||||||
|
assert ok
|
||||||
|
session.refresh(granter)
|
||||||
|
session.refresh(mate)
|
||||||
|
assert not granter.needs_rank_3_bonus
|
||||||
|
assert mate.rank == mate_rank
|
||||||
|
|
||||||
def test_become_ghost_flow(session):
|
def test_become_ghost_flow(session):
|
||||||
game = crud.create_game(session)
|
game = crud.create_game(session)
|
||||||
player = crud.add_player(session, game.id, "DeadRat")
|
player = crud.add_player(session, game.id, "DeadRat")
|
||||||
|
|||||||
Reference in New Issue
Block a user