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