Compare commits
15 Commits
3bb4940df7
...
daeac91d07
| Author | SHA1 | Date | |
|---|---|---|---|
| daeac91d07 | |||
| 045857a8fd | |||
| 2ea91c066a | |||
| 8614a6c820 | |||
| 44bbb88836 | |||
| 7825ad2cca | |||
| 473fd74a00 | |||
| ceaf88ef22 | |||
| 39dc07d6b6 | |||
| 5a8919d967 | |||
| b37b655118 | |||
| bfe982251e | |||
| 5a9d712c95 | |||
| 45fefc0c71 | |||
| f40724c9d2 |
@@ -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.
|
||||
|
||||
11
TODO.md
11
TODO.md
@@ -1,17 +1,18 @@
|
||||
## Major features
|
||||
- [x] **Rollback of game state.** Full-state JSON snapshots in a `Checkpoint` table, captured per action by the broadcast middleware, restored by `crud_rollback.rollback_to_checkpoint`; scene-confined; server-enforced for Admins and Deep players. Rolling back is non-destructive — it moves a head pointer (`Game.rollback_head_checkpoint_id`), so later log entries grey out and can be redone (roll forward); the abandoned future is only discarded when a new action is taken. UI is the per-event rollback/redo buttons + greyed entries + "rolled back" banner in `EventLog.svelte`.
|
||||
## UI Polish
|
||||
|
||||
- [ ] 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
|
||||
|
||||
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
|
||||
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
|
||||
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
|
||||
- [ ] **Version number and change log**
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates.
|
||||
|
||||
## 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.
|
||||
- [ ] **Clean up ruff E701 warnings**
|
||||
- [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.)*
|
||||
|
||||
@@ -398,25 +398,6 @@
|
||||
color: var(--ghost);
|
||||
}
|
||||
|
||||
.captain-badge {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-heading);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.captain-badge.vacant {
|
||||
background: color-mix(in srgb, var(--text) 4%, transparent);
|
||||
border: 1px dashed var(--edge);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Waiting indicator --- */
|
||||
.waiting-box, .waiting-indicator {
|
||||
@@ -428,3 +409,135 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* --- Modal overlay (shared by popups) --- */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
position: relative;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.75rem;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-deep);
|
||||
}
|
||||
|
||||
.modal-box h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.modal-box.sheet-modal {
|
||||
max-width: 520px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
}
|
||||
|
||||
/* --- Graphical tooltip (lib/tooltip.js action) --- */
|
||||
.tooltip-pop {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
max-width: 280px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--edge-accent);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-deep);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.tooltip-pop.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-card {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 900;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-card.tt-red { color: var(--suit-red); }
|
||||
.tooltip-pop .tt-card.tt-black { color: var(--suit-black); }
|
||||
.tooltip-pop .tt-card.tt-joker { color: var(--accent); }
|
||||
|
||||
.tooltip-pop .tt-color {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 700;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-color-red {
|
||||
color: var(--suit-red);
|
||||
background: color-mix(in srgb, var(--suit-red) 16%, transparent);
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-color-black {
|
||||
color: var(--suit-black);
|
||||
background: color-mix(in srgb, var(--suit-black) 16%, transparent);
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-theme {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-row {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,22 @@
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.teaching-box {
|
||||
text-align: left;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.teaching-box .checkbox-label.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.teaching-box .info-text {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
/* --- Scene board layout --- */
|
||||
.scene-view-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 2rem;
|
||||
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) {
|
||||
@@ -11,6 +23,128 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Crew bubble column --- */
|
||||
.crew-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.crew-bubbles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.crew-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.1rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: var(--well);
|
||||
border: 1px solid var(--edge);
|
||||
border-left: 4px solid var(--pirat);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.crew-bubble:hover {
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.crew-bubble.is-deep {
|
||||
border-left-color: var(--deep);
|
||||
background: color-mix(in srgb, var(--deep) 6%, transparent);
|
||||
}
|
||||
|
||||
.crew-bubble.is-you {
|
||||
border-color: var(--accent);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.crew-bubble.is-ghost { opacity: 0.7; filter: grayscale(0.6); }
|
||||
.crew-bubble.is-dead { opacity: 0.6; filter: grayscale(0.8); }
|
||||
|
||||
.crew-bubble .bubble-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.crew-bubble .bubble-name {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.crew-bubble .bubble-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Crew Objectives toggle at the end of the roster */
|
||||
.crew-objectives-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.crew-objectives-toggle:hover {
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.crew-objectives-toggle .co-count {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.crew-objectives-detail {
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: var(--well);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* On narrow screens the crew column becomes a horizontal strip above the board */
|
||||
@media (max-width: 1100px) {
|
||||
.crew-bubbles {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.crew-bubble {
|
||||
width: auto;
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
.crew-objectives-toggle {
|
||||
width: auto;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.crew-objectives-detail {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -32,44 +166,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Scene status banner (captain + roster) */
|
||||
.scene-status-banner {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--well);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.roster-chips {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.roster-chips .roster-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-heading);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.roster-chips .player-chip {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 12px;
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* --- Obstacles --- */
|
||||
.obstacles-container {
|
||||
display: flex;
|
||||
@@ -90,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) {
|
||||
@@ -111,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;
|
||||
@@ -136,7 +278,7 @@
|
||||
}
|
||||
|
||||
.obstacle-details .in-challenge-tag {
|
||||
color: var(--danger);
|
||||
color: var(--deep);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@@ -209,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;
|
||||
}
|
||||
|
||||
@@ -252,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 {
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* Between scenes there's only the voting/roster panel — center it. */
|
||||
.between-grid.single {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 640px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.between-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
editingBasic = true;
|
||||
}
|
||||
|
||||
// Discard any edits made since clicking Revise and return to the saved view.
|
||||
function cancelReviseBasic() {
|
||||
editingBasic = false;
|
||||
}
|
||||
|
||||
// Like/Hate tasks are assigned automatically when character creation starts.
|
||||
// This is a fallback for games that entered the phase before assignments existed.
|
||||
onMount(async () => {
|
||||
@@ -63,6 +68,10 @@
|
||||
let tech1 = '', tech2 = '', tech3 = '';
|
||||
let savingTechs = false;
|
||||
let techError = '';
|
||||
// True while editing previously-submitted techniques (vs. first-time entry),
|
||||
// so we know to offer a Cancel button. techSnapshot holds the saved values.
|
||||
let revisingTechs = false;
|
||||
let techSnapshot = [];
|
||||
|
||||
async function submitTechniques() {
|
||||
savingTechs = true;
|
||||
@@ -71,6 +80,7 @@
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||
tech1, tech2, tech3
|
||||
});
|
||||
revisingTechs = false;
|
||||
} catch(e) {
|
||||
techError = e.message;
|
||||
} finally {
|
||||
@@ -79,12 +89,33 @@
|
||||
}
|
||||
|
||||
async function reviseTechniques() {
|
||||
techSnapshot = [...createdTechniques];
|
||||
[tech1 = '', tech2 = '', tech3 = ''] = createdTechniques;
|
||||
revisingTechs = true;
|
||||
techError = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unsubmit-techniques`, 'POST');
|
||||
} catch(e) {
|
||||
techError = e.message;
|
||||
revisingTechs = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Throw away edits and re-submit the techniques as they were before Revise.
|
||||
async function cancelReviseTechniques() {
|
||||
savingTechs = true;
|
||||
techError = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||
tech1: techSnapshot[0] || '',
|
||||
tech2: techSnapshot[1] || '',
|
||||
tech3: techSnapshot[2] || ''
|
||||
});
|
||||
revisingTechs = false;
|
||||
} catch(e) {
|
||||
techError = e.message;
|
||||
} finally {
|
||||
savingTechs = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +158,9 @@
|
||||
let jackTech = '', queenTech = '', kingTech = '';
|
||||
let assigningFace = false;
|
||||
let faceError = '';
|
||||
// True while revising a previously-assigned J/Q/K layout, so we offer Cancel.
|
||||
let revisingFace = false;
|
||||
let faceSnapshot = {};
|
||||
|
||||
async function assignFaceTechniques() {
|
||||
assigningFace = true;
|
||||
@@ -137,6 +171,7 @@
|
||||
queen: queenTech,
|
||||
king: kingTech
|
||||
});
|
||||
revisingFace = false;
|
||||
} catch(e) {
|
||||
faceError = e.message;
|
||||
} finally {
|
||||
@@ -145,14 +180,39 @@
|
||||
}
|
||||
|
||||
async function reviseFaceTechniques() {
|
||||
faceSnapshot = {
|
||||
jack: state.player.tech_jack,
|
||||
queen: state.player.tech_queen,
|
||||
king: state.player.tech_king
|
||||
};
|
||||
jackTech = state.player.tech_jack;
|
||||
queenTech = state.player.tech_queen;
|
||||
kingTech = state.player.tech_king;
|
||||
revisingFace = true;
|
||||
faceError = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unassign-face-techniques`, 'POST');
|
||||
} catch(e) {
|
||||
faceError = e.message;
|
||||
revisingFace = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Throw away edits and re-assign the J/Q/K layout as it was before Revise.
|
||||
async function cancelReviseFaceTechniques() {
|
||||
assigningFace = true;
|
||||
faceError = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/assign-face-techniques`, 'POST', {
|
||||
jack: faceSnapshot.jack,
|
||||
queen: faceSnapshot.queen,
|
||||
king: faceSnapshot.king
|
||||
});
|
||||
revisingFace = false;
|
||||
} catch(e) {
|
||||
faceError = e.message;
|
||||
} finally {
|
||||
assigningFace = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +305,12 @@
|
||||
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
||||
{#if basicComplete}
|
||||
<button type="button" class="btn btn-secondary" disabled={savingBasic} on:click={cancelReviseBasic}>Cancel</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -350,6 +415,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
||||
{#if revisingTechs}
|
||||
<button type="button" class="btn btn-secondary btn-full mt-4" disabled={savingTechs} on:click={cancelReviseTechniques}>Cancel — keep my original techniques</button>
|
||||
{/if}
|
||||
</form>
|
||||
{:else if phase === 'character_creation'}
|
||||
<div class="submitted-techniques text-center mt-4">
|
||||
@@ -470,6 +538,13 @@
|
||||
>
|
||||
{assigningFace ? 'Assigning...' : 'Assign to Cards'}
|
||||
</button>
|
||||
{#if revisingFace}
|
||||
<button
|
||||
class="btn btn-secondary w-full mt-4"
|
||||
disabled={assigningFace}
|
||||
on:click={cancelReviseFaceTechniques}
|
||||
>Cancel — keep my original assignment</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -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();
|
||||
@@ -35,6 +40,11 @@
|
||||
let atBottom = true;
|
||||
let error = '';
|
||||
let timelineVersion = null;
|
||||
// Toast that briefly previews a freshly-arrived event while the log is
|
||||
// collapsed, then animates down into the Event Log button.
|
||||
let toast = null; // { id, message, kind }
|
||||
let initialized = false; // suppresses a toast flood on the first state load
|
||||
|
||||
|
||||
// Rollback buttons appear during a scene, for Admins and Deep players (server-enforced too).
|
||||
$: canRollback = state.game?.phase === 'scene'
|
||||
@@ -57,30 +67,51 @@
|
||||
// action from a rolled-back state truncates the abandoned future and bumps the
|
||||
// version, which tells us to drop what we'd accumulated and reseed.
|
||||
const version = state.game?.rollback_timeline_version ?? 0;
|
||||
let reset = false;
|
||||
if (timelineVersion !== null && version !== timelineVersion) {
|
||||
events = [];
|
||||
seenIds = new Set();
|
||||
reset = true;
|
||||
}
|
||||
timelineVersion = version;
|
||||
if (events.length === 0) {
|
||||
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
||||
}
|
||||
let added = false;
|
||||
let newest = null;
|
||||
for (const e of polled) {
|
||||
if (!seenIds.has(e.id)) {
|
||||
seenIds.add(e.id);
|
||||
events.push(e);
|
||||
added = true;
|
||||
newest = e;
|
||||
}
|
||||
}
|
||||
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 floating log is closed.
|
||||
if (initialized && !reset && !shown && newest) {
|
||||
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
|
||||
}
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
// An open/inline log makes the preview toast redundant.
|
||||
$: if (shown) toast = null;
|
||||
|
||||
onMount(() => {
|
||||
if (inline) scrollToBottom();
|
||||
});
|
||||
|
||||
function clearToast(id) {
|
||||
if (toast && toast.id === id) toast = null;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
||||
@@ -148,12 +179,29 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="floating-event-log {open ? 'open' : ''}">
|
||||
<div class="floating-event-log {shown ? 'open' : ''} {inline ? 'inline' : ''}">
|
||||
{#if toast && !shown}
|
||||
{#key toast.id}
|
||||
<button
|
||||
class="event-toast log-kind-{toast.kind}"
|
||||
title="Open the Event Log"
|
||||
on:click={toggleOpen}
|
||||
on:animationend={() => clearToast(toast.id)}
|
||||
>
|
||||
<span class="log-icon">{KIND_ICONS[toast.kind] || KIND_ICONS.info}</span>
|
||||
<span class="toast-message">{toast.message}</span>
|
||||
</button>
|
||||
{/key}
|
||||
{/if}
|
||||
{#if inline}
|
||||
<div class="log-header">📜 Event Log</div>
|
||||
{:else}
|
||||
<button class="toggle-log-btn" on:click={toggleOpen}>
|
||||
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if open}
|
||||
{#if shown}
|
||||
{#if error}
|
||||
<div class="log-error">{error}</div>
|
||||
{/if}
|
||||
@@ -218,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);
|
||||
@@ -352,6 +423,48 @@
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Toast preview of a fresh event, anchored above the button */
|
||||
.event-toast {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 320px;
|
||||
max-width: 80vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
||||
border: 1px solid var(--edge);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-deep);
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transform-origin: bottom right;
|
||||
animation: event-toast-into-button 3.4s ease-in forwards;
|
||||
}
|
||||
.event-toast .toast-message {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
@keyframes event-toast-into-button {
|
||||
0% { opacity: 0; transform: translateY(-6px) scale(0.96); }
|
||||
8% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
75% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(40px) scale(0.4); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.event-toast { animation-duration: 2.5s; animation-timing-function: linear; }
|
||||
}
|
||||
|
||||
/* Accent colors by event kind */
|
||||
.log-kind-challenge { border-left-color: var(--danger); }
|
||||
.log-kind-card { border-left-color: var(--deep); }
|
||||
|
||||
88
frontend/src/components/GatModal.svelte
Normal file
88
frontend/src/components/GatModal.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
|
||||
export let state;
|
||||
|
||||
let descInput = '';
|
||||
let error = '';
|
||||
let submitting = false;
|
||||
|
||||
async function submitDescription() {
|
||||
if (!descInput.trim() || submitting) return;
|
||||
error = '';
|
||||
submitting = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-gat-description`, 'POST', {
|
||||
description: descInput.trim()
|
||||
});
|
||||
descInput = '';
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Enter') submitDescription();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if state.player.needs_gat_description}
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal-box glass-panel">
|
||||
<h3>🔫 You Got a Gat!</h3>
|
||||
<p class="info-text">
|
||||
Every Pi-Rat's Gat is one of a kind. What does yours look like?
|
||||
</p>
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={descInput}
|
||||
class="input-field"
|
||||
placeholder="e.g. A pearl-handled flintlock that smells of cheese"
|
||||
on:keydown={onKeydown}
|
||||
autofocus
|
||||
>
|
||||
<button
|
||||
class="btn btn-gold btn-full"
|
||||
on:click={submitDescription}
|
||||
disabled={!descInput.trim() || submitting}
|
||||
>
|
||||
Claim Gat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.modal-box {
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
padding: 1.75rem;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-deep);
|
||||
text-align: center;
|
||||
}
|
||||
.modal-box h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.modal-box .input-field {
|
||||
width: 100%;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,19 @@
|
||||
starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$: teaching = state.game.teaching_deep_player_id === state.player.id;
|
||||
$: teachingTakenByOther = state.game.teaching_deep_player_id && !teaching;
|
||||
|
||||
async function toggleTeaching() {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/teaching-mode`, 'POST', {
|
||||
enabled: !teaching
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle teaching mode:", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="lobby-view text-center">
|
||||
@@ -72,6 +85,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if state.player.is_admin}
|
||||
<div class="teaching-box glass-panel">
|
||||
<label class="checkbox-label" class:disabled={teachingTakenByOther}>
|
||||
<input type="checkbox" checked={teaching} disabled={teachingTakenByOther} on:change={toggleTeaching}>
|
||||
<span>📚 <strong>Teaching mode:</strong> I'll be the Deep for the first scene</span>
|
||||
</label>
|
||||
<p class="info-text">
|
||||
{#if teachingTakenByOther}
|
||||
Another admin has already volunteered to teach as the Deep.
|
||||
{:else}
|
||||
Seeds you as the Rank-3 player who must play the Deep in scene 1, so you can run the table for new crews. Otherwise it's assigned at random.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="lobby-action">
|
||||
{#if state.player.is_admin}
|
||||
{#if state.players.length >= 3 || state.game.dev_mode}
|
||||
|
||||
88
frontend/src/components/NameModal.svelte
Normal file
88
frontend/src/components/NameModal.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
|
||||
export let state;
|
||||
|
||||
let newNameInput = '';
|
||||
let error = '';
|
||||
let submitting = false;
|
||||
|
||||
async function submitNewName() {
|
||||
if (!newNameInput.trim() || submitting) return;
|
||||
error = '';
|
||||
submitting = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
|
||||
new_name: newNameInput.trim()
|
||||
});
|
||||
newNameInput = '';
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Enter') submitNewName();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if state.player.needs_name}
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal-box glass-panel">
|
||||
<h3>🏴☠️ You Earned a Name!</h3>
|
||||
<p class="info-text">
|
||||
You completed your second objective and ranked up. No more going by your smell — what is your new Pirate Name?
|
||||
</p>
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newNameInput}
|
||||
class="input-field"
|
||||
placeholder="Enter your Pirate Name..."
|
||||
on:keydown={onKeydown}
|
||||
autofocus
|
||||
>
|
||||
<button
|
||||
class="btn btn-gold btn-full"
|
||||
on:click={submitNewName}
|
||||
disabled={!newNameInput.trim() || submitting}
|
||||
>
|
||||
Claim Name
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.modal-box {
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
padding: 1.75rem;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-deep);
|
||||
text-align: center;
|
||||
}
|
||||
.modal-box h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.modal-box .input-field {
|
||||
width: 100%;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -56,6 +56,11 @@
|
||||
editingBasic = true;
|
||||
}
|
||||
|
||||
// Discard any edits made since clicking Revise and return to the saved view.
|
||||
function cancelReviseBasic() {
|
||||
editingBasic = false;
|
||||
}
|
||||
|
||||
$: basicComplete = !!(me.avatar_look && me.avatar_smell && me.first_words && me.good_at_math);
|
||||
$: likeDone = !me.other_like_from_player_id || !!me.other_like;
|
||||
$: hateDone = !me.other_hate_from_player_id || !!me.other_hate;
|
||||
@@ -191,7 +196,12 @@
|
||||
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
||||
{#if basicComplete}
|
||||
<button type="button" class="btn btn-secondary" disabled={savingBasic} on:click={cancelReviseBasic}>Cancel</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,75 +1,28 @@
|
||||
<script>
|
||||
import Card from './Card.svelte';
|
||||
import { displayName } from '../lib/cards';
|
||||
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||
import DeepControlPanel from './scene/DeepControlPanel.svelte';
|
||||
import CharacterSheet from './scene/CharacterSheet.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) : [];
|
||||
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
|
||||
$: isCaptain = state.game.captain_player_id === state.player.id;
|
||||
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
||||
$: 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 Shared Table -->
|
||||
<!-- LEFT COLUMN: The Crew -->
|
||||
<CrewColumn {state} />
|
||||
|
||||
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
|
||||
<div class="table-column">
|
||||
|
||||
<!-- Game Deck and Obstacles Roster -->
|
||||
<div class="card glass-panel obstacle-list-card">
|
||||
<div class="card-header">
|
||||
<h3>🌊 The Obstacle List</h3>
|
||||
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
|
||||
</div>
|
||||
|
||||
<div class="obstacles-container" id="scene-obstacles-container">
|
||||
<!-- TOP STATUS BAR: Scene Info and Captain -->
|
||||
<div class="scene-status-banner">
|
||||
<!-- Captain Badge -->
|
||||
<div>
|
||||
{#if captain}
|
||||
<span class="captain-badge">
|
||||
🏴☠️ Captain: {displayName(captain)} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="captain-badge vacant">
|
||||
🏴☠️ Captain: None{state.game.completed_crew_1 ? '' : ' (steal a ship first!)'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Active Scene Roster Mini-list -->
|
||||
<div class="roster-chips">
|
||||
<span class="roster-label">Active Roster:</span>
|
||||
{#each state.players as p}
|
||||
{#if p.role === 'deep'}
|
||||
<span class="player-chip deep-chip">
|
||||
🌊 {displayName(p)} (Deep)
|
||||
</span>
|
||||
{:else if p.role === 'pirat'}
|
||||
<span class="player-chip pirat-chip {p.is_ghost ? 'is-ghost' : ''} {p.is_dead ? 'is-dead' : ''} {p.id === state.game.captain_player_id ? 'is-captain' : ''}">
|
||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {displayName(p)} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChallengePanel {state} />
|
||||
<ObstacleBoard {state} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
||||
<div class="private-column">
|
||||
{#if state.player.role === "deep"}
|
||||
<DeepControlPanel {state} />
|
||||
{:else}
|
||||
<!-- Player Hand Card -->
|
||||
{#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>
|
||||
@@ -90,8 +43,25 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CharacterSheet {state} />
|
||||
{/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>
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
|
||||
// Helpers
|
||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||
// Winner of the rank-up vote, surfaced on the post-voting (deep_upkeep) screen.
|
||||
$: rankUpWinner = state.game.last_rank_up_player_id
|
||||
? state.players.find(p => p.id === state.game.last_rank_up_player_id)
|
||||
: null;
|
||||
|
||||
// A nominee must be another living, in-play Pi-Rat: not the voter, not a
|
||||
// previous-scene Deep, and not dead / awaiting-recruit (they'd reset to
|
||||
@@ -194,7 +198,7 @@
|
||||
<p style="margin: 0;">🐀 A fresh recruit is on the way! Once upkeep wraps up, you and the crew will create your new Pi-Rat together.</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="between-grid">
|
||||
<div class="between-grid" class:single={state.game.phase !== 'deep_upkeep'}>
|
||||
<!-- Ranks and Voting Card -->
|
||||
<div class="card glass-panel voting-card">
|
||||
<h3>1. Pirate Ranking (Voting)</h3>
|
||||
@@ -202,7 +206,11 @@
|
||||
|
||||
{#if state.game.phase === 'deep_upkeep'}
|
||||
<div class="voted-confirmation-box glass-panel">
|
||||
<p class="success-text">✔️ Voting complete! Results tallied.</p>
|
||||
{#if rankUpWinner}
|
||||
<p class="success-text">🏆 {displayName(rankUpWinner)} won the vote and ranked up to Rank {rankUpWinner.rank}!</p>
|
||||
{:else}
|
||||
<p class="success-text">✔️ Voting complete! No one ranked up this time.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if hasVoted}
|
||||
<div class="voted-confirmation-box glass-panel">
|
||||
@@ -242,9 +250,22 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="roster-sheet-summary margin-top text-left glass-panel">
|
||||
<h4>Crew Rank Ledger:</h4>
|
||||
<ul class="ranks-ledger">
|
||||
{#each state.players as p}
|
||||
<li>
|
||||
<strong>{displayName(p)}:</strong> Rank {p.rank}
|
||||
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resting Deep Player Card -->
|
||||
<!-- Resting Deep Player Card — only the actual hand refresh, during deep_upkeep -->
|
||||
{#if state.game.phase === 'deep_upkeep'}
|
||||
<div class="card glass-panel deep-rest-card">
|
||||
<h3>2. Resting Deep Upkeep</h3>
|
||||
|
||||
@@ -252,9 +273,20 @@
|
||||
<div class="deep-rest-panel glass-panel">
|
||||
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p>
|
||||
|
||||
{#if state.game.phase === 'between_scenes'}
|
||||
<p class="info-text text-center gold-text" style="margin-top: 1rem;">Voting is currently in progress. Hand refresh will begin after voting concludes.</p>
|
||||
{:else if state.game.phase === 'deep_upkeep'}
|
||||
{#if state.player.is_ready}
|
||||
<!-- Confirmation: the refresh went through; show the freshly-drawn hand. -->
|
||||
<div class="voted-confirmation-box glass-panel" style="margin-top: 1rem;">
|
||||
<p class="success-text">✔️ Hand refreshed! You're holding {hand.length} card{hand.length === 1 ? '' : 's'} for the next scene.</p>
|
||||
</div>
|
||||
<div class="upkeep-flex" style="justify-content: center; margin-top: 1rem;">
|
||||
{#each hand as card}
|
||||
<Card {card} />
|
||||
{:else}
|
||||
<p class="empty-text text-center w-full">No cards in hand.</p>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="info-text text-center" style="margin-top: 1rem;">Waiting for the rest of the crew to finish upkeep...</p>
|
||||
{:else}
|
||||
<p class="info-text" style="margin-top: 1rem;">
|
||||
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
|
||||
Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
|
||||
@@ -308,31 +340,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if state.game.phase === 'between_scenes'}
|
||||
<p class="info-text text-center">Your Pi-Rat was active. Your hand cards carry over to the next scene.</p>
|
||||
{:else if state.game.phase === 'deep_upkeep'}
|
||||
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="roster-sheet-summary margin-top text-left glass-panel">
|
||||
<h4>Crew Rank Ledger:</h4>
|
||||
<ul class="ranks-ledger">
|
||||
{#each state.players as p}
|
||||
<li>
|
||||
<strong>{displayName(p)}:</strong> Rank {p.rank}
|
||||
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Scene Ready Up -->
|
||||
<div class="action-box margin-top">
|
||||
{#if state.game.phase === 'deep_upkeep'}
|
||||
{#if state.player.role === 'deep'}
|
||||
{#if state.player.role === 'deep' && !state.player.is_ready}
|
||||
<p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
|
||||
{:else}
|
||||
<div class="waiting-box">
|
||||
|
||||
@@ -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}
|
||||
@@ -78,22 +113,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ch.stakes}
|
||||
{#if ch.stakes_success}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>✅ On success:</strong> {ch.stakes_success}</p>
|
||||
{/if}
|
||||
{#if ch.stakes_failure}
|
||||
<p class="info-text" style="margin: 0.25rem 0 0 0;"><strong>❌ On failure:</strong> {ch.stakes_failure}</p>
|
||||
{/if}
|
||||
{#if ch.stakes && !ch.stakes_success && !ch.stakes_failure}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
|
||||
{/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}
|
||||
@@ -134,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}
|
||||
|
||||
@@ -1,59 +1,87 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { getCardDisplay, isJoker, displayName, cardObstacleInfo, obstacleTable } from '../../lib/cards';
|
||||
import { getCardDisplay, isJoker, displayName, crewLabel, captainName, cardObstacleInfo, obstacleTable } from '../../lib/cards';
|
||||
|
||||
export let state;
|
||||
export let target; // the player whose sheet is being viewed
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
function close() { dispatch('close'); }
|
||||
|
||||
let error = '';
|
||||
let newNameInput = '';
|
||||
let pvpOpponentId = '';
|
||||
let pvpCard = '';
|
||||
|
||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||
$: 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
|
||||
&& viewer.role === 'pirat' && !viewer.is_dead && !viewer.needs_reroll
|
||||
&& target.role === 'pirat' && !target.is_dead;
|
||||
// What the chosen duel card becomes as a temporary Obstacle for the defender
|
||||
$: pvpCardObstacle = cardObstacleInfo(pvpCard, $obstacleTable);
|
||||
|
||||
function getPlayerName(id) {
|
||||
if (!id) return 'a crewmate';
|
||||
const p = state.players.find(pl => pl.id === id);
|
||||
return p ? displayName(p) : 'Unknown';
|
||||
}
|
||||
$: duelTargets = scenePirats.filter(p => p.id !== state.player.id && !p.is_dead);
|
||||
// What the chosen duel card becomes as a temporary Obstacle for the defender
|
||||
$: pvpCardObstacle = cardObstacleInfo(pvpCard, $obstacleTable);
|
||||
|
||||
async function submitNewName() {
|
||||
if (!newNameInput.trim()) return;
|
||||
async function createPvp() {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
|
||||
new_name: newNameInput.trim()
|
||||
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/challenge/pvp/create`, 'POST', {
|
||||
defender_id: target.id,
|
||||
card_code: pvpCard
|
||||
});
|
||||
newNameInput = '';
|
||||
pvpCard = '';
|
||||
close();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function createPvp() {
|
||||
async function setCaptain(makeCaptain) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
|
||||
defender_id: pvpOpponentId,
|
||||
card_code: pvpCard
|
||||
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/set-captain`, 'POST', {
|
||||
target_player_id: makeCaptain ? target.id : ''
|
||||
});
|
||||
pvpOpponentId = '';
|
||||
pvpCard = '';
|
||||
} 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>
|
||||
|
||||
<div class="card glass-panel sheet-card">
|
||||
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
||||
{#if state.player.player_name && state.player.player_name !== state.player.name}
|
||||
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||
|
||||
<div class="modal-backdrop" on:click|self={close}>
|
||||
<div class="modal-box sheet-modal glass-panel">
|
||||
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||
|
||||
<h3>
|
||||
{#if target.role === 'deep'}🌊{:else if target.is_ghost}👻{:else if target.is_dead}💀{:else}🐀{/if}
|
||||
{target.id === state.game.captain_player_id ? captainName(target.name) : target.name}'s Character Sheet
|
||||
</h3>
|
||||
{#if target.player_name && target.player_name !== target.name}
|
||||
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
|
||||
Played by {state.player.player_name}{#if !state.player.completed_personal_2} — they'll go by their smell until they earn a Name!{/if}
|
||||
Played by {target.player_name}{#if !target.completed_personal_2} — they'll go by their smell until they earn a Name!{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -62,39 +90,28 @@
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.needs_reroll}
|
||||
{#if isSelf && state.player.needs_reroll}
|
||||
<div class="prompt-box accent text-center">
|
||||
<h4>🐀 Recruit Incoming!</h4>
|
||||
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You don't have a Pi-Rat in this scene. Sit back, enjoy the chaos, and you'll create your recruit with the crew between scenes.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.is_dead}
|
||||
{#if isSelf && state.player.is_dead}
|
||||
<div class="prompt-box danger text-center">
|
||||
<h4>💀 You have Died / Retired!</h4>
|
||||
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">Your story arc is complete. You will choose to become a Ghost or roll a new character during the upkeep phase between scenes.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.is_ghost}
|
||||
{#if isSelf && state.player.is_ghost}
|
||||
<div class="prompt-box ghostly text-center">
|
||||
<h4>👻 Playing as a Ghost</h4>
|
||||
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You are in the Ghost World (grayscale view). You cannot interact well with the living but you can still help them resolve challenges!</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.needs_name}
|
||||
<div class="prompt-box accent">
|
||||
<h4>🏴☠️ You Earned a Name!</h4>
|
||||
<p class="info-text" style="margin-bottom: 0.5rem;">You completed your second objective and ranked up! What is your new Pirate Name?</p>
|
||||
<div class="input-row">
|
||||
<input type="text" bind:value={newNameInput} class="input-field" placeholder="Enter new name...">
|
||||
<button class="btn btn-gold" on:click={submitNewName} disabled={!newNameInput.trim()}>Claim Name</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.tax_banned}
|
||||
{#if isSelf && state.player.tax_banned}
|
||||
<div class="prompt-box danger text-center">
|
||||
<p class="info-text" style="margin: 0; font-size: 0.9rem;">🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.</p>
|
||||
</div>
|
||||
@@ -102,43 +119,43 @@
|
||||
|
||||
<div class="sheet-group">
|
||||
<h4>Description:</h4>
|
||||
<p><strong>Look:</strong> {state.player.avatar_look}</p>
|
||||
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
|
||||
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
|
||||
<p><strong>Look:</strong> {target.avatar_look}</p>
|
||||
<p><strong>Smell:</strong> {target.avatar_smell}</p>
|
||||
<p><strong>First Words:</strong> "{target.first_words}"</p>
|
||||
{#if target.gat_description}
|
||||
<p><strong>🔫 Gat:</strong> {target.gat_description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if state.player.other_like || state.player.other_hate}
|
||||
{#if target.other_like || target.other_hate}
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>What the Crew Thinks:</h4>
|
||||
{#if state.player.other_like}
|
||||
<p><strong>👍 Likes:</strong> "{state.player.other_like}" <span class="text-muted">— {getPlayerName(state.player.other_like_from_player_id)}</span></p>
|
||||
{#if target.other_like}
|
||||
<p><strong>👍 Likes:</strong> "{target.other_like}" <span class="text-muted">— {getPlayerName(target.other_like_from_player_id)}</span></p>
|
||||
{/if}
|
||||
{#if state.player.other_hate}
|
||||
<p><strong>👎 Hates:</strong> "{state.player.other_hate}" <span class="text-muted">— {getPlayerName(state.player.other_hate_from_player_id)}</span></p>
|
||||
{#if target.other_hate}
|
||||
<p><strong>👎 Hates:</strong> "{target.other_hate}" <span class="text-muted">— {getPlayerName(target.other_hate_from_player_id)}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Secret techniques are visible only on your own sheet -->
|
||||
{#if isSelf}
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
||||
<ul class="techniques-list-sheet">
|
||||
<li><strong>Jack (J):</strong> "{state.player.tech_jack}"</li>
|
||||
<li><strong>Queen (Q):</strong> "{state.player.tech_queen}"</li>
|
||||
<li><strong>King (K):</strong> "{state.player.tech_king}"</li>
|
||||
<li><strong>Jack (J):</strong> "{target.tech_jack}"</li>
|
||||
<li><strong>Queen (Q):</strong> "{target.tech_queen}"</li>
|
||||
<li><strong>King (K):</strong> "{target.tech_king}"</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Duel another Pi-Rat -->
|
||||
{#if !state.player.is_dead && !state.player.needs_reroll && duelTargets.length > 0}
|
||||
<!-- Duel this Pi-Rat (target is fixed to this sheet) -->
|
||||
{#if canDuel}
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>⚔️ Duel a Pi-Rat</h4>
|
||||
<p class="info-text" style="font-size: 0.85rem;">Challenge a crewmate (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
|
||||
<select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<option value="">Challenge whom?</option>
|
||||
{#each duelTargets as p}
|
||||
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<h4>⚔️ Duel {crewLabel(target, state.game.captain_player_id)}</h4>
|
||||
<p class="info-text" style="font-size: 0.85rem;">Challenge them (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
|
||||
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<option value="">Throw which card?</option>
|
||||
{#each hand.filter(c => !isJoker(c)) as card}
|
||||
@@ -150,44 +167,43 @@
|
||||
As an Obstacle, {getCardDisplay(pvpCard)} is <strong>{pvpCardObstacle.title}</strong>: {pvpCardObstacle.description}
|
||||
</p>
|
||||
{/if}
|
||||
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
|
||||
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpCard}>Throw Down!</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canManageCaptain}
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>🏴☠️ Captaincy</h4>
|
||||
{#if isCaptain}
|
||||
<p class="info-text" style="font-size: 0.85rem;">{target.name} holds the Captaincy until they step down, lose a duel, die, or the ship is lost.</p>
|
||||
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(false)}>Remove as Captain</button>
|
||||
{:else}
|
||||
<p class="info-text" style="font-size: 0.85rem;">Hand this Pi-Rat the Captaincy (e.g. after a leadership duel or resignation).</p>
|
||||
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(true)}>Make Captain</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>Crew Objectives</h4>
|
||||
<div class="objectives-checklist">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
|
||||
<span>Steal a Ship</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
|
||||
<span>Choose a Captain</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
|
||||
<span>Commit Piracy</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>Personal Objectives</h4>
|
||||
{#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={state.player.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={state.player.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={state.player.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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
94
frontend/src/components/scene/CrewColumn.svelte
Normal file
94
frontend/src/components/scene/CrewColumn.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<script>
|
||||
import { slide } from 'svelte/transition';
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { crewLabel } from '../../lib/cards';
|
||||
import CharacterSheet from './CharacterSheet.svelte';
|
||||
|
||||
export let state;
|
||||
|
||||
let openTargetId = null;
|
||||
let showObjectives = false;
|
||||
let objError = '';
|
||||
|
||||
$: isDeep = state.player.role === 'deep';
|
||||
|
||||
// The Deep ticks Crew Objectives off here (moved from the old Deep panel).
|
||||
async function toggleCrew(type) {
|
||||
objError = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/objective/toggle`, 'POST', { type });
|
||||
} catch (e) {
|
||||
objError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Pi-Rats first, the Deep last; otherwise keep join order.
|
||||
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
||||
$: captainId = state.game.captain_player_id;
|
||||
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
||||
$: crewDone = [state.game.completed_crew_1, state.game.completed_crew_2, state.game.completed_crew_3].filter(Boolean).length;
|
||||
|
||||
function handSize(p) {
|
||||
try { return JSON.parse(p.hand_cards || '[]').length; } catch { return 0; }
|
||||
}
|
||||
function roleIcon(p) {
|
||||
if (p.role === 'deep') return '🌊';
|
||||
if (p.is_ghost) return '👻';
|
||||
if (p.is_dead) return '💀';
|
||||
return '🐀';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="crew-column">
|
||||
<div class="crew-bubbles">
|
||||
{#each crew as p (p.id)}
|
||||
<button
|
||||
class="crew-bubble"
|
||||
class:is-you={p.id === state.player.id}
|
||||
class:is-deep={p.role === 'deep'}
|
||||
class:is-dead={p.is_dead}
|
||||
class:is-ghost={p.is_ghost}
|
||||
title="View {p.name}'s character sheet"
|
||||
on:click={() => openTargetId = p.id}
|
||||
>
|
||||
<span class="bubble-icon">{roleIcon(p)}</span>
|
||||
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||
<span class="bubble-meta">
|
||||
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Crew Objectives sits at the end of the roster and slides open in place -->
|
||||
<button
|
||||
class="crew-objectives-toggle"
|
||||
aria-expanded={showObjectives}
|
||||
on:click={() => showObjectives = !showObjectives}
|
||||
>
|
||||
<span class="co-label">{showObjectives ? '▾' : '▸'} Crew Objectives</span>
|
||||
<span class="co-count">{crewDone}/3</span>
|
||||
</button>
|
||||
{#if showObjectives}
|
||||
<div class="crew-objectives-detail" transition:slide>
|
||||
{#if objError}<div class="alert alert-danger">{objError}</div>{/if}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_1} disabled={!isDeep} on:change={() => toggleCrew('crew_1')}>
|
||||
<span>Steal a Ship</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_2} disabled={!isDeep} on:change={() => toggleCrew('crew_2')}>
|
||||
<span>Choose a Captain</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_3} disabled={!isDeep} on:change={() => toggleCrew('crew_3')}>
|
||||
<span>Commit Piracy</span>
|
||||
</label>
|
||||
{#if isDeep}<p class="info-text" style="font-size: 0.75rem; margin: 0.25rem 0 0;">As the Deep, tick these off as the crew earns them.</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if openTarget}
|
||||
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
||||
{/if}
|
||||
@@ -1,153 +0,0 @@
|
||||
<script>
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { displayName } from '../../lib/cards';
|
||||
|
||||
export let state;
|
||||
|
||||
let error = '';
|
||||
let challengeTargetId = '';
|
||||
let challengeStakes = '';
|
||||
let selectedObstacles = {};
|
||||
let captainSelectId = '';
|
||||
|
||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||
|
||||
async function createChallenge() {
|
||||
error = '';
|
||||
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', {
|
||||
target_player_id: challengeTargetId,
|
||||
obstacle_ids: JSON.stringify(ids),
|
||||
stakes: challengeStakes
|
||||
});
|
||||
challengeTargetId = '';
|
||||
challengeStakes = '';
|
||||
selectedObstacles = {};
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function setCaptain() {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-captain`, 'POST', {
|
||||
target_player_id: captainSelectId
|
||||
});
|
||||
captainSelectId = '';
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleObjective(targetPlayerId, type) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', { type });
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function endScene() {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card glass-panel deep-panel-card">
|
||||
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Call a Challenge -->
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>⚔️ Call a Challenge</h4>
|
||||
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
|
||||
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<option value="">Challenge which Pi-Rat?</option>
|
||||
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div style="margin-bottom: 0.5rem;">
|
||||
{#each state.obstacles as obs}
|
||||
{@const taken = challengedObstacleIds.has(obs.id)}
|
||||
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
|
||||
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
|
||||
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<input type="text" class="input-field" placeholder="Stakes (optional): what happens on success/failure?" bind:value={challengeStakes} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<button class="btn btn-primary btn-full" on:click={createChallenge}
|
||||
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
|
||||
Call Challenge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Captain Assignment -->
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>🏴☠️ Captaincy</h4>
|
||||
<p class="info-text" style="font-size: 0.85rem;">The Captain holds power until they step down, lose a duel, die, or the ship is lost. Reassign after a leadership duel or resignation.</p>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<select class="select-field" bind:value={captainSelectId} style="flex: 1;">
|
||||
<option value="">No Captain</option>
|
||||
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||
<option value={p.id}>{displayName(p)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn btn-secondary" on:click={setCaptain}>Set</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>Crew Objectives</h4>
|
||||
<div class="objectives-checklist">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_1} on:change={() => toggleObjective(state.player.id, 'crew_1')}>
|
||||
<span>Steal a Ship</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_2} on:change={() => toggleObjective(state.player.id, 'crew_2')}>
|
||||
<span>Choose a Captain</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_3} on:change={() => toggleObjective(state.player.id, 'crew_3')}>
|
||||
<span>Commit Piracy</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>Pi-Rat Personal Objectives</h4>
|
||||
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
|
||||
{#each scenePirats as p}
|
||||
<div class="objective-player-block">
|
||||
<strong>{displayName(p)}</strong>
|
||||
<div class="objectives-checklist" style="margin-top: 0.25rem;">
|
||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_1} on:change={() => toggleObjective(p.id, 'personal_1')}> <span>1. Gat</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_2} on:change={() => toggleObjective(p.id, 'personal_2')}> <span>2. Name</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_3} on:change={() => toggleObjective(p.id, 'personal_3')}> <span>3. Die/Retire</span></label>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- End Scene Button -->
|
||||
<div class="scene-upkeep-controls text-center">
|
||||
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
|
||||
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
|
||||
End Scene & Proceed to Ranking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
</details>
|
||||
{:else}
|
||||
<p class="empty-text">All Obstacles are tied up in the Challenge above.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<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}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
||||
{/each}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { apiRequest } from './api';
|
||||
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
|
||||
|
||||
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
|
||||
export const SUIT_THEMES = {
|
||||
const SUIT_THEMES = {
|
||||
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
|
||||
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
|
||||
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
|
||||
@@ -46,6 +46,7 @@ export function cardObstacleInfo(code, table = OBSTACLE_TABLE) {
|
||||
// Default tooltip for a card: its suit theme when played, plus what it
|
||||
// represents as an Obstacle (relevant when challenging another Pi-Rat).
|
||||
// Pass the obstacleTable store's value as `table` to recompute reactively.
|
||||
// Plain-string form, kept for aria-label / native fallbacks.
|
||||
export function cardTooltip(code, table = OBSTACLE_TABLE) {
|
||||
if (!code) return '';
|
||||
if (isJoker(code)) {
|
||||
@@ -58,6 +59,42 @@ export function cardTooltip(code, table = OBSTACLE_TABLE) {
|
||||
return `${theme}\nAs an Obstacle: ${obstacle.title} — ${obstacle.description}`;
|
||||
}
|
||||
|
||||
const FACE_VALUES = ['J', 'Q', 'K'];
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
}
|
||||
|
||||
// Rich (HTML) tooltip describing a card — what it does when *played*, and what
|
||||
// it means when it surfaces as an Obstacle. Built only from static rulebook
|
||||
// data (SUIT_THEMES + the obstacle table), so the HTML is trusted. Feed the
|
||||
// result to the `tooltip` action as `{ html: cardTooltipHtml(...) }`.
|
||||
export function cardTooltipHtml(code, table = OBSTACLE_TABLE) {
|
||||
if (!code) return '';
|
||||
if (isJoker(code)) {
|
||||
return '<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');
|
||||
}
|
||||
@@ -89,3 +126,20 @@ export function displayName(p) {
|
||||
export function playerName(players, id) {
|
||||
return displayName(players.find(p => p.id === id));
|
||||
}
|
||||
|
||||
// The Pi-Rat's name with the "Recruit" honorific swapped for "Captain" (or
|
||||
// "Captain" prepended once they've earned a real Name). Used to mark the Captain
|
||||
// in the scene roster without a separate icon.
|
||||
export function captainName(name) {
|
||||
if (!name) return 'Captain';
|
||||
if (name.startsWith('Recruit ')) return 'Captain ' + name.slice('Recruit '.length);
|
||||
return 'Captain ' + name;
|
||||
}
|
||||
|
||||
// displayName, but the Captain is shown as "Captain <name> (player)".
|
||||
export function crewLabel(p, captainId) {
|
||||
if (!p) return '???';
|
||||
const rat = p.id === captainId ? captainName(p.name) : p.name;
|
||||
if (p.player_name && p.player_name !== p.name) return `${rat} (${p.player_name})`;
|
||||
return rat;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,21 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { setPageTitle } from '../lib/title';
|
||||
import { getSessions } from '../lib/sessions';
|
||||
|
||||
export let params = {};
|
||||
let gameId = params.id;
|
||||
let adminKey = '';
|
||||
|
||||
// Return to this browser's own Pi-Rat dashboard for the game (the most
|
||||
// recently used session), not the public join screen. Falls back to join
|
||||
// if this browser has no saved session for the game.
|
||||
$: mySession = getSessions().find(s => s.gameId === gameId);
|
||||
$: backLink = mySession
|
||||
? `/#/game/${gameId}/player/${mySession.playerId}`
|
||||
: `/#/game/${gameId}/join`;
|
||||
$: backLabel = mySession ? '← Back to Game' : 'Back to Game Join Page';
|
||||
|
||||
let data = null;
|
||||
let error = '';
|
||||
let copiedPlayerId = null;
|
||||
@@ -187,7 +197,7 @@
|
||||
</table>
|
||||
|
||||
<div class="mt-8">
|
||||
<a href="/#/game/{gameId}/join" class="btn btn-secondary">Back to Game Join Page</a>
|
||||
<a href={backLink} class="btn btn-secondary">{backLabel}</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
import RecruitPhase from '../components/RecruitPhase.svelte';
|
||||
import GameOverPhase from '../components/GameOverPhase.svelte';
|
||||
import EventLog from '../components/EventLog.svelte';
|
||||
import NameModal from '../components/NameModal.svelte';
|
||||
import GatModal from '../components/GatModal.svelte';
|
||||
import RankBonusModal from '../components/RankBonusModal.svelte';
|
||||
|
||||
export let params = {};
|
||||
let gameId = params.id;
|
||||
@@ -209,7 +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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -36,7 +36,8 @@ def create_challenge(
|
||||
deep_player_id: str,
|
||||
target_player_id: str,
|
||||
obstacle_ids: List[str],
|
||||
stakes: str = ""
|
||||
stakes_success: str = "",
|
||||
stakes_failure: str = ""
|
||||
) -> Tuple[bool, str]:
|
||||
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat."""
|
||||
game = get_game(db, game_id)
|
||||
@@ -74,15 +75,20 @@ def create_challenge(
|
||||
acting_player_id=target.id,
|
||||
challenger_player_id=deep_player.id,
|
||||
obstacle_ids=json.dumps(obstacle_ids),
|
||||
stakes=stakes.strip(),
|
||||
stakes_success=stakes_success.strip(),
|
||||
stakes_failure=stakes_failure.strip(),
|
||||
)
|
||||
db.add(challenge)
|
||||
db.commit()
|
||||
|
||||
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids)
|
||||
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
|
||||
if stakes.strip():
|
||||
msg += f" Stakes: {stakes.strip()}"
|
||||
success_text = stakes_success.strip()
|
||||
failure_text = stakes_failure.strip()
|
||||
if success_text:
|
||||
msg += f" On success: {success_text}"
|
||||
if failure_text:
|
||||
msg += f" On failure: {failure_text}"
|
||||
add_game_event(db, game.id, msg, kind="challenge")
|
||||
return True, "Challenge called!"
|
||||
|
||||
@@ -207,6 +213,9 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
|
||||
if challenge.tax_type == "gat":
|
||||
acting.completed_personal_1 = False
|
||||
refuser.completed_personal_1 = True
|
||||
# The Gat (and its description) returns to its owner.
|
||||
refuser.gat_description = acting.gat_description
|
||||
acting.gat_description = ""
|
||||
else:
|
||||
# Return the stolen name string; the failed thief reverts to
|
||||
# their smell-based recruit identity.
|
||||
@@ -338,6 +347,9 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
|
||||
if challenge.tax_type == "gat":
|
||||
requester.completed_personal_1 = True
|
||||
responder.completed_personal_1 = False
|
||||
# The Gat changes hands along with its description (like a stolen Name).
|
||||
requester.gat_description = responder.gat_description
|
||||
responder.gat_description = ""
|
||||
event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!"
|
||||
else:
|
||||
# The Name itself is stolen: the requester literally takes the responder's
|
||||
|
||||
@@ -435,6 +435,14 @@ def maybe_finish_assign_techniques(db: Session, game: Game):
|
||||
# 1. Randomly assign starting ranks
|
||||
random.shuffle(players_list)
|
||||
|
||||
# Teaching mode: an admin opted to be the forced Rank-3 Deep for the first
|
||||
# scene, so seed them at the front instead of leaving it to chance.
|
||||
if game.teaching_deep_player_id:
|
||||
teacher = next((p for p in players_list if p.id == game.teaching_deep_player_id), None)
|
||||
if teacher:
|
||||
players_list.remove(teacher)
|
||||
players_list.insert(0, teacher)
|
||||
|
||||
# "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene."
|
||||
# "Randomly choose one Player to start the game at Rank 1. This Player must play their Pi-Rat during the first scene."
|
||||
# "All remaining Players start at Rank 2. These Players may choose to play either during the first scene."
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
import random
|
||||
from typing import Tuple, Dict, Any
|
||||
from sqlmodel import Session
|
||||
from .models import Game, Player, Obstacle
|
||||
@@ -7,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 ---
|
||||
@@ -307,21 +307,14 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
||||
"""Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly."""
|
||||
if obj_type.startswith("crew_"):
|
||||
game = get_game(db, game_id)
|
||||
if not game: return
|
||||
if not game:
|
||||
return
|
||||
if obj_type == "crew_1":
|
||||
# Stealing a ship no longer auto-anoints a Captain — the crew has to
|
||||
# earn and choose one themselves (Crew Objective 2 + the Captaincy UI).
|
||||
game.completed_crew_1 = status
|
||||
db.add(game)
|
||||
db.commit()
|
||||
if status and not game.captain_player_id:
|
||||
# "Once the crew controls a ship, the highest-Ranked Pi-Rat becomes the de facto Captain."
|
||||
pi_rats = [p for p in game.players if p.role != "deep" and not p.is_dead and not p.is_ghost]
|
||||
if pi_rats:
|
||||
max_rank = max(p.rank for p in pi_rats)
|
||||
candidates = [p for p in pi_rats if p.rank == max_rank]
|
||||
set_captain(db, game, random.choice(candidates).id, "Highest-ranked Pi-Rat aboard the stolen ship")
|
||||
elif not status and game.captain_player_id:
|
||||
# The ship is lost: the Captain's position goes with it.
|
||||
set_captain(db, game, None, "The ship was lost")
|
||||
elif obj_type == "crew_2":
|
||||
game.completed_crew_2 = status
|
||||
db.add(game)
|
||||
@@ -343,8 +336,11 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
||||
if obj_type == "personal_1":
|
||||
if status and not player.completed_personal_1:
|
||||
rank_diff = 1
|
||||
player.needs_gat_description = True
|
||||
elif not status and player.completed_personal_1:
|
||||
rank_diff = -1
|
||||
player.needs_gat_description = False
|
||||
player.gat_description = ""
|
||||
player.completed_personal_1 = status
|
||||
|
||||
elif obj_type == "personal_2":
|
||||
@@ -379,6 +375,25 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
||||
status_text = "completed" if status else "unmarked"
|
||||
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:
|
||||
|
||||
@@ -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(
|
||||
@@ -83,7 +68,11 @@ def process_between_scenes_votes(db: Session, game: Game):
|
||||
Then sets players who were Deep to discard and redraw.
|
||||
"""
|
||||
votes = game.votes
|
||||
# Reset the previous scene's winner before tallying this one.
|
||||
game.last_rank_up_player_id = None
|
||||
db.add(game)
|
||||
if not votes:
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# Count votes
|
||||
@@ -101,6 +90,8 @@ def process_between_scenes_votes(db: Session, game: Game):
|
||||
winner = get_player(db, winner_id)
|
||||
if winner:
|
||||
change_player_rank(db, game, winner, 1)
|
||||
game.last_rank_up_player_id = winner.id
|
||||
db.add(game)
|
||||
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank")
|
||||
|
||||
# Clear all votes
|
||||
@@ -112,6 +103,7 @@ def begin_next_scene_setup(db: Session, game: Game):
|
||||
"""Advances to the next scene's setup: bump the scene counter and clear roles."""
|
||||
game.current_scene_number += 1
|
||||
game.phase = "scene_setup"
|
||||
game.last_rank_up_player_id = None # Stale once we leave the post-voting screen
|
||||
for p in game.players:
|
||||
p.role = None
|
||||
p.is_ready = False
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add gat description to player
|
||||
|
||||
Revision ID: 153132c8e583
|
||||
Revises: 5a6e73d2bc4f
|
||||
Create Date: 2026-06-14 09:42:58.953909
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
revision = '153132c8e583'
|
||||
down_revision = '5a6e73d2bc4f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('gat_description', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
|
||||
batch_op.add_column(sa.Column('needs_gat_description', sa.Boolean(), nullable=False))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||
batch_op.drop_column('needs_gat_description')
|
||||
batch_op.drop_column('gat_description')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add teaching_deep_player_id to game
|
||||
|
||||
Revision ID: 19ee9ae5ca0e
|
||||
Revises: e6294f6f8a81
|
||||
Create Date: 2026-06-14 09:11:17.810765
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
revision = '19ee9ae5ca0e'
|
||||
down_revision = 'e6294f6f8a81'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('teaching_deep_player_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||
batch_op.drop_column('teaching_deep_player_id')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add last_rank_up_player_id to game
|
||||
|
||||
Revision ID: 3bbf6812c261
|
||||
Revises: 19ee9ae5ca0e
|
||||
Create Date: 2026-06-14 09:21:11.037751
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
revision = '3bbf6812c261'
|
||||
down_revision = '19ee9ae5ca0e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('last_rank_up_player_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||
batch_op.drop_column('last_rank_up_player_id')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add success/failure stakes to challenge
|
||||
|
||||
Revision ID: 5a6e73d2bc4f
|
||||
Revises: 3bbf6812c261
|
||||
Create Date: 2026-06-14 09:25:46.637295
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
revision = '5a6e73d2bc4f'
|
||||
down_revision = '3bbf6812c261'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('challenge', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('stakes_success', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
|
||||
batch_op.add_column(sa.Column('stakes_failure', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('challenge', schema=None) as batch_op:
|
||||
batch_op.drop_column('stakes_failure')
|
||||
batch_op.drop_column('stakes_success')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -18,6 +18,8 @@ class Game(SQLModel, table=True):
|
||||
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
||||
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
||||
captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship
|
||||
teaching_deep_player_id: Optional[str] = Field(default=None) # Teaching mode: an admin who seeds themselves as the forced Rank-3 Deep for the first scene (set in the lobby, honored when starting ranks are rolled)
|
||||
last_rank_up_player_id: Optional[str] = Field(default=None) # Winner of the most recent rank-up vote, shown on the post-voting upkeep screen; cleared when the next scene begins
|
||||
|
||||
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
|
||||
obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True)
|
||||
@@ -66,8 +68,10 @@ class Player(SQLModel, table=True):
|
||||
completed_personal_2: bool = Field(default=False) # Earn a Name
|
||||
completed_personal_3: bool = Field(default=False) # Die like a Pirate
|
||||
|
||||
gat_description: str = Field(default="") # Free-text description of the Pi-Rat's Gat, entered via a modal when they earn one
|
||||
# States for Milestones & Death
|
||||
needs_name: bool = Field(default=False)
|
||||
needs_gat_description: bool = Field(default=False) # Prompts the gat-description modal after earning a Gat objective
|
||||
needs_rank_3_bonus: bool = Field(default=False)
|
||||
is_dead: bool = Field(default=False)
|
||||
is_ghost: bool = Field(default=False)
|
||||
@@ -110,7 +114,9 @@ class Challenge(SQLModel, table=True):
|
||||
challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
|
||||
obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges)
|
||||
temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
|
||||
stakes: str = Field(default="")
|
||||
stakes: str = Field(default="") # Deprecated single-field stakes; kept for old rollback-snapshot compatibility. New challenges use stakes_success/stakes_failure.
|
||||
stakes_success: str = Field(default="") # What the Deep promises happens if the Pi-Rat beats the Challenge
|
||||
stakes_failure: str = Field(default="") # What the Deep threatens happens if the Pi-Rat fails
|
||||
plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
|
||||
status: str = Field(default="open") # "open", "succeeded", "failed"
|
||||
|
||||
|
||||
@@ -33,6 +33,36 @@ def set_dev_mode(
|
||||
crud.add_game_event(db, game.id, f"🛠 Dev Mode turned {'ON' if enabled else 'OFF'} by {player.name}.", kind="info")
|
||||
return {"status": "ok", "dev_mode": game.dev_mode}
|
||||
|
||||
# Teaching mode: an admin volunteers to be the forced Rank-3 Deep for scene 1.
|
||||
# Only meaningful before starting ranks are rolled (i.e. before scene setup).
|
||||
_PRE_RANK_PHASES = {"lobby", "character_creation", "swap_techniques", "assign_techniques"}
|
||||
|
||||
@router.post("/game/{game_id}/player/{player_id}/teaching-mode")
|
||||
def set_teaching_mode(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
enabled: bool = Form(...),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
game, player = _get_admin(db, game_id, player_id)
|
||||
if game.phase not in _PRE_RANK_PHASES:
|
||||
raise HTTPException(status_code=400, detail="Starting ranks have already been rolled.")
|
||||
if enabled:
|
||||
game.teaching_deep_player_id = player.id
|
||||
elif game.teaching_deep_player_id == player.id:
|
||||
game.teaching_deep_player_id = None
|
||||
else:
|
||||
# Disabling someone else's teaching mode shouldn't silently clear it.
|
||||
return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id}
|
||||
db.add(game)
|
||||
db.commit()
|
||||
crud.add_game_event(
|
||||
db, game.id,
|
||||
f"📚 {player.name} {'will teach as the Deep' if enabled else 'cancelled teaching mode'} for the first scene.",
|
||||
kind="info",
|
||||
)
|
||||
return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id}
|
||||
|
||||
# Dev Mode shortcut: auto-complete character creation for everyone and move on
|
||||
@router.post("/game/{game_id}/player/{player_id}/skip-character-creation")
|
||||
def skip_character_creation_route(
|
||||
|
||||
@@ -14,7 +14,8 @@ def create_challenge_route(
|
||||
player_id: str,
|
||||
target_player_id: str = Form(...),
|
||||
obstacle_ids: str = Form(...), # JSON list of obstacle ids
|
||||
stakes: str = Form(""),
|
||||
stakes_success: str = Form(""),
|
||||
stakes_failure: str = Form(""),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
try:
|
||||
@@ -22,7 +23,7 @@ def create_challenge_route(
|
||||
assert isinstance(ids, list)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
|
||||
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes)
|
||||
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes_success, stakes_failure)
|
||||
if not ok:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -89,19 +89,27 @@ def toggle_objective_route(
|
||||
):
|
||||
if type.startswith("crew_"):
|
||||
game = crud.get_game(db, game_id)
|
||||
if not game: raise HTTPException(status_code=404)
|
||||
if not game:
|
||||
raise HTTPException(status_code=404)
|
||||
current_status = False
|
||||
if type == "crew_1": current_status = game.completed_crew_1
|
||||
elif type == "crew_2": current_status = game.completed_crew_2
|
||||
elif type == "crew_3": current_status = game.completed_crew_3
|
||||
if type == "crew_1":
|
||||
current_status = game.completed_crew_1
|
||||
elif type == "crew_2":
|
||||
current_status = game.completed_crew_2
|
||||
elif type == "crew_3":
|
||||
current_status = game.completed_crew_3
|
||||
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
||||
else:
|
||||
player = crud.get_player(db, player_id)
|
||||
if not player: raise HTTPException(status_code=404, detail="Player not found")
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
current_status = False
|
||||
if type == "personal_1": current_status = player.completed_personal_1
|
||||
elif type == "personal_2": current_status = player.completed_personal_2
|
||||
elif type == "personal_3": current_status = player.completed_personal_3
|
||||
if type == "personal_1":
|
||||
current_status = player.completed_personal_1
|
||||
elif type == "personal_2":
|
||||
current_status = player.completed_personal_2
|
||||
elif type == "personal_3":
|
||||
current_status = player.completed_personal_3
|
||||
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -122,23 +130,39 @@ def set_name_route(
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
# Pi-Rat bonus rank up for another player
|
||||
# Pi-Rat describes the Gat they just earned
|
||||
@router.post("/game/{game_id}/player/{player_id}/set-gat-description")
|
||||
def set_gat_description_route(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
description: str = Form(...),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
player = crud.get_player(db, player_id)
|
||||
if player and player.needs_gat_description:
|
||||
player.gat_description = description
|
||||
player.needs_gat_description = False
|
||||
db.add(player)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
# A Pi-Rat who finished their story (3rd Personal Objective) grants another Pi-Rat a
|
||||
# bonus Rank. An empty target_player_id forfeits the bonus (e.g. no eligible crewmate).
|
||||
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
|
||||
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
|
||||
|
||||
@@ -194,12 +194,14 @@ def test_scene_start_and_challenges(session):
|
||||
assert "Challenge" in msg
|
||||
|
||||
# The Deep calls a Challenge applying the obstacle
|
||||
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="The cheese is on the line")
|
||||
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_success="The cheese is yours", stakes_failure="The cheese is on the line")
|
||||
assert ok, msg
|
||||
session.refresh(game)
|
||||
challenge = game.challenges[0]
|
||||
assert challenge.status == "open"
|
||||
assert challenge.acting_player_id == p2.id
|
||||
assert challenge.stakes_success == "The cheese is yours"
|
||||
assert challenge.stakes_failure == "The cheese is on the line"
|
||||
|
||||
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
|
||||
|
||||
@@ -343,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
|
||||
@@ -355,20 +356,19 @@ def test_captaincy_and_hand_sizes(session):
|
||||
session.commit()
|
||||
assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 4
|
||||
|
||||
# Completing Crew Objective 1 (Steal a Ship) makes the highest-ranked Pi-Rat the Captain
|
||||
# Stealing a ship does NOT auto-assign a Captain — the crew must earn and
|
||||
# choose one. Completing Crew Objective 1 leaves the captaincy vacant.
|
||||
crud.toggle_objective(session, game.id, p1.id, "crew_1", True)
|
||||
session.refresh(game)
|
||||
assert game.captain_player_id in (p2.id, p3.id)
|
||||
captain = crud.get_player(session, game.captain_player_id)
|
||||
assert crud.is_player_captain(captain, game)
|
||||
# Captain privilege: +1 hand size
|
||||
assert crud.calculate_max_hand_size(captain, game.players, game.captain_player_id) == 5
|
||||
|
||||
# Losing the ship vacates the Captaincy
|
||||
crud.toggle_objective(session, game.id, p1.id, "crew_1", False)
|
||||
session.refresh(game)
|
||||
assert game.captain_player_id is None
|
||||
|
||||
# The crew chooses a Captain explicitly (via the Captaincy UI / set_captain).
|
||||
crud.set_captain(session, game, p2.id)
|
||||
session.refresh(game)
|
||||
assert game.captain_player_id == p2.id
|
||||
# Captain privilege: +1 hand size
|
||||
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 5
|
||||
|
||||
def test_captain_tax_on_failed_challenge(session):
|
||||
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
|
||||
# p3 (rank 2) becomes captain
|
||||
@@ -631,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")
|
||||
@@ -2078,7 +2113,7 @@ def test_rollback_round_trip_restores_state(session):
|
||||
cp_known = crud.capture_checkpoint(session, game)
|
||||
|
||||
# A destructive action: open a challenge and play a card against the obstacle.
|
||||
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="x")
|
||||
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_failure="x")
|
||||
crud.capture_checkpoint(session, game)
|
||||
crud.play_challenge_card(session, p2.id, obs.id, "10D")
|
||||
crud.capture_checkpoint(session, game)
|
||||
@@ -2242,7 +2277,7 @@ def test_rollback_middleware_capture_and_route_end_to_end():
|
||||
obs.current_value = 5
|
||||
session.add_all([p2, obs])
|
||||
session.commit()
|
||||
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="x")
|
||||
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_failure="x")
|
||||
|
||||
# A floor checkpoint representing the pre-play state.
|
||||
floor = crud.capture_checkpoint(session, game)
|
||||
|
||||
Reference in New Issue
Block a user