Dead code cleanups

This commit is contained in:
2026-06-15 07:46:32 -07:00
parent 045857a8fd
commit daeac91d07
21 changed files with 578 additions and 473 deletions

View File

@@ -462,3 +462,82 @@
background: color-mix(in srgb, var(--text) 10%, transparent);
}
/* --- Graphical tooltip (lib/tooltip.js action) --- */
.tooltip-pop {
position: fixed;
z-index: 3000;
max-width: 280px;
padding: 0.6rem 0.7rem;
background: var(--surface-raised);
border: 1px solid var(--edge-accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
color: var(--text);
font-family: var(--font-body);
font-size: 0.82rem;
line-height: 1.4;
pointer-events: none;
opacity: 0;
transform: translateY(3px);
transition: opacity 0.12s ease, transform 0.12s ease;
}
.tooltip-pop.visible {
opacity: 1;
transform: translateY(0);
}
.tooltip-pop .tt-head {
display: flex;
align-items: baseline;
gap: 0.4rem;
margin-bottom: 0.3rem;
}
.tooltip-pop .tt-card {
font-family: var(--font-heading);
font-weight: 900;
font-size: 1rem;
}
.tooltip-pop .tt-card.tt-red { color: var(--suit-red); }
.tooltip-pop .tt-card.tt-black { color: var(--suit-black); }
.tooltip-pop .tt-card.tt-joker { color: var(--accent); }
.tooltip-pop .tt-color {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
padding: 0.05rem 0.35rem;
border-radius: 999px;
}
.tooltip-pop .tt-color-red {
color: var(--suit-red);
background: color-mix(in srgb, var(--suit-red) 16%, transparent);
}
.tooltip-pop .tt-color-black {
color: var(--suit-black);
background: color-mix(in srgb, var(--suit-black) 16%, transparent);
}
.tooltip-pop .tt-theme {
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.tooltip-pop .tt-row {
margin-top: 0.25rem;
}
.tooltip-pop .tt-label {
display: block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
font-weight: 700;
}

View File

@@ -1,11 +1,22 @@
/* --- Scene board layout --- */
.scene-view-layout {
display: grid;
grid-template-columns: 180px 1.4fr 1fr;
grid-template-columns: 180px minmax(0, 1.7fr) minmax(300px, 1fr);
gap: 1.5rem;
align-items: start;
}
/* The middle column stacks hand / challenge / obstacle cards. */
.table-column {
display: flex;
flex-direction: column;
}
/* Stretch the log column to the full row height so its sticky panel can travel. */
.log-column {
align-self: stretch;
}
@media (max-width: 1100px) {
.scene-view-layout {
grid-template-columns: 1fr;
@@ -175,7 +186,7 @@
}
.obstacle-item.in-challenge {
box-shadow: 0 0 0 2px var(--danger);
box-shadow: 0 0 0 2px var(--deep);
}
@media (max-width: 768px) {
@@ -196,10 +207,56 @@
.obstacle-card-wrapper {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.3rem;
}
.card-caption {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
/* Obstacle color (red/black) — fixed by the original card; matching it draws back */
.obstacle-color-tag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.4rem;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.12rem 0.5rem;
border-radius: 999px;
cursor: help;
}
.obstacle-color-tag::before {
content: '';
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
display: inline-block;
}
.obstacle-color-tag.red {
color: var(--suit-red);
background: color-mix(in srgb, var(--suit-red) 14%, transparent);
}
.obstacle-color-tag.red::before { background: var(--suit-red); }
.obstacle-color-tag.black {
color: var(--suit-black);
background: color-mix(in srgb, var(--suit-black) 14%, transparent);
}
.obstacle-color-tag.black::before { background: var(--suit-black); }
.suit-badge {
font-weight: 900;
font-size: 1.1rem;
@@ -221,7 +278,7 @@
}
.obstacle-details .in-challenge-tag {
color: var(--danger);
color: var(--deep);
font-size: 0.8rem;
}
@@ -294,19 +351,25 @@
grid-column: 1 / -1;
}
/* --- Challenges --- */
/* --- Challenges --- (framed in the Deep's teal so red/black always means suit color) */
/* The challenge-area card IS the box; the challenge content sits flush inside it. */
.challenge-area-card {
border-color: color-mix(in srgb, var(--deep) 45%, transparent);
}
.challenge-item {
background: color-mix(in srgb, var(--danger) 5%, transparent);
border: 1px solid var(--danger);
border-radius: var(--radius-md);
padding: 1rem 1.25rem;
margin-bottom: 1rem;
text-align: left;
}
.challenge-item + .challenge-item {
border-top: 1px solid var(--edge-soft);
padding-top: 1rem;
}
.challenge-item h4 {
color: var(--danger);
font-size: 1.15rem;
color: var(--deep);
font-size: 1.25rem;
margin: 0;
}
@@ -337,37 +400,31 @@
margin: 0 0 0.5rem 0;
}
/* --- Deep control panel --- */
.deep-panel-card {
max-height: 80vh;
overflow-y: auto;
border-color: color-mix(in srgb, var(--deep) 40%, transparent);
}
.deep-divider {
border: 0;
height: 1px;
background: linear-gradient(to right, transparent, color-mix(in srgb, var(--deep) 40%, transparent), transparent);
margin: 2rem 0;
}
.obstacles-checkboxes {
/* --- Challenge area (middle column) --- */
/* Obstacles applied to an active Challenge, pulled up into the Challenge panel */
.challenge-obstacles {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--well);
padding: 1rem;
border-radius: var(--radius-sm);
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--edge);
gap: 1rem;
margin-top: 0.75rem;
}
.objective-player-block {
background: var(--well);
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
/* The Deep's call-a-challenge + end-scene controls live below any open Challenges */
.deep-challenge-controls {
margin-top: 0.5rem;
}
/* When a Challenge is active the rest of the Obstacle list collapses out of the way */
.obstacle-collapse > summary {
cursor: pointer;
color: var(--text-muted);
font-family: var(--font-heading);
font-size: 0.9rem;
padding: 0.35rem 0;
}
.obstacle-collapse[open] > summary {
margin-bottom: 1rem;
}
.scene-upkeep-controls {

View File

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

View File

@@ -1,8 +1,11 @@
<script>
import { tick } from 'svelte';
import { tick, onMount } from 'svelte';
import { apiRequest } from '../lib/api';
export let state;
// Inline mode pins the log open as a column (scene phase) instead of the
// floating, collapsible corner panel used in every other phase.
export let inline = false;
const PAGE_SIZE = 50;
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
@@ -27,6 +30,8 @@
};
let open = false;
// The content is visible whenever the floating log is open OR it's pinned inline.
$: shown = open || inline;
let logEl;
let events = []; // ascending by timestamp; accumulated across polls + history loads
let seenIds = new Set();
@@ -84,21 +89,25 @@
}
if (added) {
events = events;
if (open && atBottom) {
if (shown && atBottom) {
await tick();
scrollToBottom();
}
// Toast only for genuinely new events (not the initial seed or a
// rollback reseed) and only while the log is closed.
if (initialized && !reset && !open && newest) {
// rollback reseed) and only while the floating log is closed.
if (initialized && !reset && !shown && newest) {
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
}
}
initialized = true;
}
// Opening the log makes the preview redundant.
$: if (open) toast = null;
// An open/inline log makes the preview toast redundant.
$: if (shown) toast = null;
onMount(() => {
if (inline) scrollToBottom();
});
function clearToast(id) {
if (toast && toast.id === id) toast = null;
@@ -170,8 +179,8 @@
}
</script>
<div class="floating-event-log {open ? 'open' : ''}">
{#if toast && !open}
<div class="floating-event-log {shown ? 'open' : ''} {inline ? 'inline' : ''}">
{#if toast && !shown}
{#key toast.id}
<button
class="event-toast log-kind-{toast.kind}"
@@ -184,11 +193,15 @@
</button>
{/key}
{/if}
<button class="toggle-log-btn" on:click={toggleOpen}>
{open ? '⬇️ Hide Log' : '📜 Event Log'}
</button>
{#if inline}
<div class="log-header">📜 Event Log</div>
{:else}
<button class="toggle-log-btn" on:click={toggleOpen}>
{open ? '⬇️ Hide Log' : '📜 Event Log'}
</button>
{/if}
{#if open}
{#if shown}
{#if error}
<div class="log-error">{error}</div>
{/if}
@@ -253,6 +266,29 @@
.floating-event-log:not(.open) {
width: auto;
}
/* Inline mode: pinned as the scene's third column instead of floating. */
.floating-event-log.inline {
position: sticky;
top: 1rem;
right: auto;
bottom: auto;
width: 100%;
max-height: calc(100vh - 2rem);
}
@media (max-width: 1100px) {
.floating-event-log.inline {
position: static;
max-height: 60vh;
}
}
.log-header {
padding: 10px;
text-align: center;
font-weight: bold;
font-family: var(--font-heading);
border-bottom: 1px solid var(--edge-soft);
color: var(--text);
}
.toggle-log-btn {
background: transparent;
color: var(--text);

View File

@@ -2,40 +2,27 @@
import Card from './Card.svelte';
import ChallengePanel from './scene/ChallengePanel.svelte';
import ObstacleBoard from './scene/ObstacleBoard.svelte';
import DeepControlPanel from './scene/DeepControlPanel.svelte';
import CrewColumn from './scene/CrewColumn.svelte';
import EventLog from './EventLog.svelte';
export let state;
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
$: isDeep = state.player.role === 'deep';
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
// The Challenge area is shown when something is happening there, or to the Deep
// (who calls Challenges and ends the scene from it).
$: showChallengeArea = isDeep || openChallenges.length > 0;
</script>
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
<!-- LEFT COLUMN: The Crew -->
<CrewColumn {state} />
<!-- MIDDLE COLUMN: The Shared Table -->
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
<div class="table-column">
<div class="card glass-panel obstacle-list-card">
<div class="card-header">
<h3>🌊 The Obstacle List</h3>
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
</div>
<div class="obstacles-container" id="scene-obstacles-container">
<ChallengePanel {state} />
<ObstacleBoard {state} />
</div>
</div>
</div>
<!-- RIGHT COLUMN: Private Hand / Deep Controls -->
<div class="private-column">
{#if state.player.role === "deep"}
<DeepControlPanel {state} />
{:else}
<!-- Player Hand Card -->
{#if !isDeep}
<div class="card glass-panel hand-card">
<h3>🃏 Your Hand</h3>
<p class="section-desc">Keep your cards secret! When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.</p>
@@ -57,5 +44,24 @@
</div>
</div>
{/if}
{#if showChallengeArea}
<div class="card glass-panel challenge-area-card">
<ChallengePanel {state} />
</div>
{/if}
<div class="card glass-panel obstacle-list-card">
<div class="card-header">
<h3>🌊 The Obstacle List</h3>
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
</div>
<ObstacleBoard {state} />
</div>
</div>
<!-- RIGHT COLUMN: The Event Log -->
<div class="log-column">
<EventLog {state} inline />
</div>
</div>

View File

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

View File

@@ -14,6 +14,12 @@
$: viewer = state.player;
$: isSelf = target.id === viewer.id;
$: isDeep = viewer.role === 'deep';
$: isCaptain = target.id === state.game.captain_player_id;
// The Deep manages captaincy and ticks personal objectives from a Pi-Rat's sheet
// (both moved here from the old Deep Control Panel).
$: canManageCaptain = isDeep && target.role === 'pirat' && !target.is_dead;
$: canManageObjectives = isDeep && target.role === 'pirat';
// The viewer throws one of their OWN cards in a duel.
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
$: canDuel = !isSelf
@@ -41,6 +47,26 @@
error = e.message;
}
}
async function setCaptain(makeCaptain) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/set-captain`, 'POST', {
target_player_id: makeCaptain ? target.id : ''
});
} catch (e) {
error = e.message;
}
}
async function toggleObjective(type) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${target.id}/objective/toggle`, 'POST', { type });
} catch (e) {
error = e.message;
}
}
</script>
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
@@ -145,19 +171,35 @@
</div>
{/if}
{#if canManageCaptain}
<div class="sheet-group margin-top">
<h4>🏴‍☠️ Captaincy</h4>
{#if isCaptain}
<p class="info-text" style="font-size: 0.85rem;">{target.name} holds the Captaincy until they step down, lose a duel, die, or the ship is lost.</p>
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(false)}>Remove as Captain</button>
{:else}
<p class="info-text" style="font-size: 0.85rem;">Hand this Pi-Rat the Captaincy (e.g. after a leadership duel or resignation).</p>
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(true)}>Make Captain</button>
{/if}
</div>
{/if}
<div class="sheet-group margin-top">
<h4>Personal Objectives</h4>
{#if canManageObjectives}
<p class="info-text" style="font-size: 0.8rem;">As the Deep, tick these off in sequence (1 → 2 → 3).</p>
{/if}
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" checked={target.completed_personal_1} disabled>
<input type="checkbox" checked={target.completed_personal_1} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_1')}>
<span>1. Gat</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={target.completed_personal_2} disabled>
<input type="checkbox" checked={target.completed_personal_2} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_2')}>
<span>2. Name</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={target.completed_personal_3} disabled>
<input type="checkbox" checked={target.completed_personal_3} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_3')}>
<span>3. Die/Retire</span>
</label>
</div>

View File

@@ -1,5 +1,6 @@
<script>
import { slide } from 'svelte/transition';
import { apiRequest } from '../../lib/api';
import { crewLabel } from '../../lib/cards';
import CharacterSheet from './CharacterSheet.svelte';
@@ -7,6 +8,19 @@
let openTargetId = null;
let showObjectives = false;
let objError = '';
$: isDeep = state.player.role === 'deep';
// The Deep ticks Crew Objectives off here (moved from the old Deep panel).
async function toggleCrew(type) {
objError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/objective/toggle`, 'POST', { type });
} catch (e) {
objError = e.message;
}
}
// Pi-Rats first, the Deep last; otherwise keep join order.
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
@@ -56,18 +70,20 @@
</button>
{#if showObjectives}
<div class="crew-objectives-detail" transition:slide>
{#if objError}<div class="alert alert-danger">{objError}</div>{/if}
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
<input type="checkbox" checked={state.game.completed_crew_1} disabled={!isDeep} on:change={() => toggleCrew('crew_1')}>
<span>Steal a Ship</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
<input type="checkbox" checked={state.game.completed_crew_2} disabled={!isDeep} on:change={() => toggleCrew('crew_2')}>
<span>Choose a Captain</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
<input type="checkbox" checked={state.game.completed_crew_3} disabled={!isDeep} on:change={() => toggleCrew('crew_3')}>
<span>Commit Piracy</span>
</label>
{#if isDeep}<p class="info-text" style="font-size: 0.75rem; margin: 0.25rem 0 0;">As the Deep, tick these off as the crew earns them.</p>{/if}
</div>
{/if}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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