Fable will fix it! Pt 2

This commit is contained in:
2026-06-11 21:32:00 -07:00
parent a7cab7bcb6
commit a074ff77b1
44 changed files with 1547 additions and 4505 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,52 @@
<script>
import { SUIT_EMOJI, isJoker, cardValue, cardSuit } from '../lib/cards';
export let card; // card code, e.g. "10D" or "Joker1"
export let size = 'large'; // 'large' | 'medium' | 'mini'
export let draggable = false;
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 techs = null; // large: {J, Q, K} technique names for the face-card overlay
export let style = '';
$: joker = isJoker(card);
$: val = joker ? '🃏' : cardValue(card);
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
$: techName = techs && !joker ? techs[cardValue(card)] : null;
</script>
{#if size === 'mini'}
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}" {title}>
<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} {draggable} {style}
on:dragstart on:dragend on:click>
<div class="card-corner top-left">
<span class="val">{val}</span>
<span class="suit">{suit}</span>
</div>
<div class="card-center">
{#if joker}
🃏
{:else}
<span class="giant-symbol" style={size === 'medium' ? 'font-size: 1.5rem;' : ''}>{suit}</span>
{/if}
</div>
{#if techName}
<div class="card-tech-overlay">
<div class="tech-tag" title="Automatic success when played">{cardValue(card)}: "{techName}"</div>
</div>
{/if}
<div class="card-corner bottom-right">
<span class="val">{val}</span>
<span class="suit">{suit}</span>
</div>
</div>
{/if}

View File

@@ -1,7 +1,7 @@
<script>
import { onMount } from 'svelte';
import { apiRequest } from '../lib/api';
import { getSuggestion, getSuggestions } from '../lib/suggestions';
import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions';
export let state;
@@ -142,7 +142,7 @@
// grabbed the same name first (the backend rejects collisions)
if (createdTechniques.length === 0) {
for (let attempt = 0; attempt < 5; attempt++) {
[tech1, tech2, tech3] = getSuggestions('techniques', 3);
[tech1, tech2, tech3] = await getTechniqueSuggestions(3);
await submitTechniques();
if (!techError) break;
}
@@ -313,21 +313,21 @@
<label for="tech1">Technique #1:</label>
<div class="input-row">
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech1 = getSuggestion('techniques', [tech2, tech3])}>Suggest</button>
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech1 = await getTechniqueSuggestion([tech2, tech3])}>Suggest</button>
</div>
</div>
<div class="form-group">
<label for="tech2">Technique #2:</label>
<div class="input-row">
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech2 = getSuggestion('techniques', [tech1, tech3])}>Suggest</button>
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech2 = await getTechniqueSuggestion([tech1, tech3])}>Suggest</button>
</div>
</div>
<div class="form-group">
<label for="tech3">Technique #3:</label>
<div class="input-row">
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech3 = getSuggestion('techniques', [tech1, tech2])}>Suggest</button>
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>

View File

@@ -0,0 +1,280 @@
<script>
import { tick } from 'svelte';
import { apiRequest } from '../lib/api';
export let state;
const PAGE_SIZE = 50;
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
const TOP_LOAD_THRESHOLD = 60; // px from the top that triggers loading older events
const KIND_ICONS = {
join: '👋',
phase: '🧭',
scene: '🎬',
captain: '🏴‍☠️',
challenge: '⚔️',
card: '🃏',
tax: '💰',
objective: '🎯',
vote: '🗳️',
obstacle: '🌊',
joker: '🤡',
victory: '🎉',
warning: '⚠️',
rank: '🎖️',
info: '📜',
};
let open = false;
let logEl;
let events = []; // ascending by timestamp; accumulated across polls + history loads
let seenIds = new Set();
let haveMore = true;
let loadingOlder = false;
let atBottom = true;
$: mergePolled(state.events);
// The poll only carries the newest PAGE_SIZE events, so accumulate them here —
// otherwise events the player paged back to would vanish as the window slides.
async function mergePolled(polled) {
if (!polled) return;
if (events.length === 0) {
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
}
let added = false;
for (const e of polled) {
if (!seenIds.has(e.id)) {
seenIds.add(e.id);
events.push(e);
added = true;
}
}
if (added) {
events = events;
if (open && atBottom) {
await tick();
scrollToBottom();
}
}
}
function scrollToBottom() {
if (logEl) logEl.scrollTop = logEl.scrollHeight;
atBottom = true;
}
function onScroll() {
if (!logEl) return;
atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight <= BOTTOM_TOLERANCE;
if (logEl.scrollTop <= TOP_LOAD_THRESHOLD) loadOlder();
}
async function loadOlder() {
if (loadingOlder || !haveMore || events.length === 0) return;
loadingOlder = true;
try {
const oldest = events[0].timestamp;
const data = await apiRequest(`/game/${state.game.id}/events?before=${oldest}&limit=${PAGE_SIZE}`);
haveMore = data.have_more;
const fresh = data.events.filter(e => !seenIds.has(e.id));
for (const e of fresh) seenIds.add(e.id);
if (fresh.length) {
// Prepending grows the content above the viewport; restore the
// player's position so the log doesn't visually jump.
const prevHeight = logEl.scrollHeight;
const prevTop = logEl.scrollTop;
events = [...fresh, ...events];
await tick();
logEl.scrollTop = prevTop + (logEl.scrollHeight - prevHeight);
}
} catch (e) {
// Transient fetch failure; scrolling again retries.
} finally {
loadingOlder = false;
}
}
async function toggleOpen() {
open = !open;
if (open) {
await tick();
scrollToBottom();
}
}
function formatTime(ts) {
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
</script>
<div class="floating-event-log {open ? 'open' : ''}">
<button class="toggle-log-btn" on:click={toggleOpen}>
{open ? '⬇️ Hide Log' : '📜 Event Log'}
</button>
{#if open}
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
{#if events.length}
<div class="log-top">
{#if loadingOlder}
<span class="log-top-note">Hauling up older entries…</span>
{:else if haveMore}
<button class="load-older-btn" on:click={loadOlder}>⬆️ Load earlier events</button>
{:else}
<span class="log-top-note">⚓ The story begins here</span>
{/if}
</div>
{#each events as event (event.id)}
<div class="log-entry log-kind-{event.kind || 'info'}">
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
<span class="log-message">{event.message}</span>
<span class="log-time">{formatTime(event.timestamp)}</span>
</div>
{/each}
{:else}
<div class="log-empty">No events yet.</div>
{/if}
</div>
{#if !atBottom}
<button class="jump-to-bottom" on:click={scrollToBottom}>⬇️ Latest</button>
{/if}
{/if}
</div>
<style>
.floating-event-log {
position: fixed;
bottom: 20px;
right: 20px;
width: 380px;
background: rgba(10, 15, 25, 0.95);
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
flex-direction: column;
max-height: 60vh;
backdrop-filter: blur(10px);
}
.floating-event-log:not(.open) {
width: auto;
}
.toggle-log-btn {
background: var(--dark-bg, #1a1a2e);
color: white;
border: none;
padding: 10px;
text-align: center;
cursor: pointer;
font-weight: bold;
border-radius: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-family: var(--font-heading);
}
.toggle-log-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.log-content {
overflow-y: auto;
padding: 10px;
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
scrollbar-width: thin;
}
.log-top {
text-align: center;
padding: 2px 0 6px;
}
.log-top-note {
font-size: 0.75rem;
color: var(--text-muted, #94a3b8);
font-style: italic;
}
.load-older-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--text-muted, #94a3b8);
font-size: 0.75rem;
padding: 4px 12px;
border-radius: 12px;
cursor: pointer;
}
.load-older-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: var(--text-primary, #f1f5f9);
}
.log-entry {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 8px;
align-items: baseline;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid rgba(255, 255, 255, 0.15);
border-radius: 0 6px 6px 0;
font-size: 0.85rem;
line-height: 1.35;
color: #c3cede;
}
.log-icon {
font-size: 0.9rem;
}
.log-message {
overflow-wrap: anywhere;
}
.log-time {
font-size: 0.7rem;
color: var(--text-muted, #94a3b8);
white-space: nowrap;
opacity: 0.8;
}
.log-empty {
padding: 10px;
color: var(--text-muted, #94a3b8);
font-style: italic;
}
.jump-to-bottom {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
background: var(--gold, #d4af37);
color: var(--text-dark, #0f172a);
border: none;
border-radius: 16px;
padding: 5px 14px;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.jump-to-bottom:hover {
filter: brightness(1.1);
}
/* Accent colors by event kind */
.log-kind-challenge { border-left-color: var(--red-suit, #ff2a5f); }
.log-kind-card { border-left-color: var(--neon-cyan, #00f2fe); }
.log-kind-captain,
.log-kind-tax,
.log-kind-rank,
.log-kind-victory { border-left-color: var(--gold, #d4af37); }
.log-kind-scene,
.log-kind-phase { border-left-color: var(--neon-emerald, #00ff87); }
.log-kind-joker,
.log-kind-warning { border-left-color: #ff9f43; }
.log-kind-obstacle { border-left-color: #4aa3df; }
.log-kind-join,
.log-kind-vote,
.log-kind-objective { border-left-color: #9b8cff; }
.log-kind-victory {
background: rgba(212, 175, 55, 0.08);
color: var(--gold, #d4af37);
}
</style>

View File

@@ -1,231 +1,17 @@
<script>
import { apiRequest } from '../lib/api';
import Card from './Card.svelte';
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 EventLog from './EventLog.svelte';
export let state;
let playingCard = false;
let error = '';
// Helpers
$: 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;
$: challenges = state.challenges || [];
$: openChallenges = challenges.filter(c => c.status === 'open');
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
$: myOpenChallenge = openChallenges.find(c => c.acting_player_id === state.player.id) || null;
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
$: scenePirats = state.players.filter(p => p.role === 'pirat');
function playerName(id) {
return state.players.find(p => p.id === id)?.name || '???';
}
function isJoker(code) {
return !!code && code.startsWith('Joker');
}
async function playCard(obstacleId, cardCode) {
playingCard = true;
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;
} finally {
playingCard = false;
}
}
async function playJoker(obstacleId, cardCode) {
playingCard = true;
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;
} finally {
playingCard = false;
}
}
async function endScene() {
try {
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
} catch(e) {
console.error(e);
}
}
async function clearObstacle(obstacleId) {
try {
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
} catch(e) {
console.error(e);
error = e.message;
}
}
async function toggleObjective(targetPlayerId, type) {
try {
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', {
type: type
});
} catch(e) {
console.error(e);
}
}
// --- Challenge controls (Deep) ---
let challengeTargetId = '';
let challengeStakes = '';
let selectedObstacles = {};
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 resolveChallenge(challengeId) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/resolve`, 'POST');
} catch(e) {
error = e.message;
}
}
async function cancelChallenge(challengeId) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/cancel`, 'POST');
} catch(e) {
error = e.message;
}
}
// --- Captain controls (Deep / current Captain) ---
let captainSelectId = '';
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;
}
}
// --- Gat/Name Tax ---
let taxTargetId = '';
function taxType(player) {
if (!player.completed_personal_1) return 'Gat';
if (!player.completed_personal_2) return 'Name';
return null;
}
function eligibleTaxTargets(challenge) {
const me = state.player;
const type = taxType(me);
if (!type) return [];
return scenePirats.filter(p =>
p.id !== me.id && !p.is_dead &&
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
);
}
async function requestTax(challengeId) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/request`, 'POST', {
target_player_id: taxTargetId
});
taxTargetId = '';
} catch(e) {
error = e.message;
}
}
async function respondTax(challengeId, accept) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/respond`, 'POST', {
accept: accept ? 'true' : 'false'
});
} catch(e) {
error = e.message;
}
}
// --- Pi-Rat vs Pi-Rat duels ---
let pvpOpponentId = '';
let pvpCard = '';
async function createPvp() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
defender_id: pvpOpponentId,
card_code: pvpCard
});
pvpOpponentId = '';
pvpCard = '';
} catch(e) {
error = e.message;
}
}
async function pvpDefend(challengeId, cardCode) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/pvp/defend`, 'POST', {
card_code: cardCode
});
} catch(e) {
error = e.message;
}
}
let showEventLog = false;
let newNameInput = '';
async function submitNewName() {
if (!newNameInput.trim()) return;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
new_name: newNameInput.trim()
});
newNameInput = '';
} catch(e) {
console.error(e);
error = e.message;
}
}
// Parsing card helpers
function getCardDisplay(code) {
if (!code) return "";
if (isJoker(code)) return "🃏 JOKER";
let rank = code.slice(0, -1);
let suit = code.slice(-1);
let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit;
return `${rank}${suitEmoji}`;
}
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
</script>
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
@@ -274,280 +60,16 @@
</div>
</div>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
<!-- OPEN CHALLENGES -->
{#each openChallenges as ch}
{@const chPlays = JSON.parse(ch.plays || '[]')}
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
<div class="glass-panel" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red-suit, #ff2a5f); border-radius: 8px; background: rgba(255, 42, 95, 0.06);">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h4 style="margin: 0; font-family: var(--font-heading);">
{#if ch.challenge_type === 'pvp'}
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
{:else}
⚔️ The Deep challenges {playerName(ch.target_player_id)}!
{/if}
</h4>
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" on:click={() => resolveChallenge(ch.id)} disabled={ch.tax_state === 'requested'}>Resolve</button>
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
</div>
{/if}
</div>
{#if ch.stakes}
<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>{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).
</p>
{/if}
{#if chPlays.length > 0}
<p class="info-text" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;">
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
</p>
{/if}
<!-- Tax: pending request aimed at me -->
{#if ch.tax_state === 'requested'}
{#if myTaxRequest && myTaxRequest.id === ch.id}
<div style="margin-top: 0.75rem; padding: 0.75rem; border: 1px dashed var(--gold); border-radius: 6px;">
<p style="margin: 0 0 0.5rem 0;"><strong>{playerName(ch.target_player_id)}</strong> calls a <strong>{ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax</strong> on you: take this Challenge for them!</p>
<p class="info-text" style="font-size: 0.85rem;">Accept: you get one random card from their hand and attempt the Challenge. Refuse: you hand over your {ch.tax_type === 'gat' ? 'Gat' : 'Name'} — they keep it if they succeed!</p>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
</div>
</div>
{:else}
<p class="info-text gold-text" style="margin-top: 0.5rem;">⏳ Waiting for {playerName(ch.tax_target_id)} to answer the {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax...</p>
{/if}
{/if}
<!-- Tax: option for the challenged player -->
{#if ch.challenge_type === 'deep' && ch.acting_player_id === state.player.id && !ch.tax_state && !state.player.tax_banned && taxType(state.player) && eligibleTaxTargets(ch).length > 0}
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<span class="info-text" style="font-size: 0.9rem;">No {taxType(state.player)}? Make it someone else's problem:</span>
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
<option value="">Pick a crewmate...</option>
{#each eligibleTaxTargets(ch) as p}
<option value={p.id}>{p.name}</option>
{/each}
</select>
<button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button>
</div>
{/if}
<!-- PvP: defender picks a card -->
{#if myPvpDefense && myPvpDefense.id === ch.id}
<div style="margin-top: 0.75rem;">
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{#each hand.filter(c => !isJoker(c)) as card}
<button class="btn btn-secondary" on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
{/each}
</div>
</div>
{/if}
</div>
{/each}
{#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' : ''}"
style={in_challenge ? 'box-shadow: 0 0 0 2px var(--red-suit, #ff2a5f);' : ''}
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" style="display: flex; justify-content: center; align-items: center;">
<div class="card-medium suit-{active_card_code.slice(-1).toLowerCase()}" title="Active Card: {getCardDisplay(active_card_code)}">
<div class="card-corner top-left">
<span class="val">{active_card_code.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
</div>
<div class="card-center">
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
</div>
<div class="card-corner bottom-right">
<span class="val">{active_card_code.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
</div>
</div>
</div>
<div class="obstacle-details">
<h4>{obs.title} {#if in_challenge}<span style="color: var(--red-suit, #ff2a5f); font-size: 0.8rem;">⚔️ 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((column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card).slice(0, -1))}
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" style="font-size: 1.5rem;">
{success_count} / {state.players.length}
</span>
</div>
<!-- Played Cards Column -->
<div class="played-column">
<h5>Card History (Column)</h5>
<div class="column-cards-flex">
<div class="card-mini base-card">
<span class="val">{obs.original_card.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[obs.original_card.slice(-1)]}</span>
</div>
{#each column_cards as pc}
<div class="card-mini played-card rotated {pc.success ? 'success' : 'failure'}"
title="{pc.player_name} played {getCardDisplay(pc.card)}">
<span class="val">{pc.card.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
<span class="owner">{pc.player_name.slice(0,4)}</span>
</div>
{/each}
</div>
</div>
{#if is_completed && state.player.role === 'deep'}
<div class="text-center" style="margin-top: 1rem;">
<button class="btn btn-success" on:click={() => clearObstacle(obs.id)} style="width: 100%; border: 1px solid rgba(255,255,255,0.2);">Clear Obstacle</button>
</div>
{/if}
</div>
{:else}
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
{/each}
<ChallengePanel {state} />
<ObstacleBoard {state} />
</div>
</div>
</div>
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
<div class="private-column">
<!-- DEEP CONTROLS: Challenges, End Scene & Objectives -->
{#if state.player.role === "deep"}
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
<!-- Call a Challenge -->
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">⚔️ 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}>{p.name} (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="form-control" 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 style="color: var(--gold);">🏴‍☠️ 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}>{p.name}</option>
{/each}
</select>
<button class="btn btn-secondary" on:click={setCaptain}>Set</button>
</div>
</div>
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">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 style="color: var(--gold);">Pi-Rat Personal Objectives</h4>
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
{#each state.players.filter(p => p.role === 'pirat') as p}
<div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
<strong>{p.name}</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" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
<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>
<DeepControlPanel {state} />
{/if}
<!-- Player Hand Card -->
@@ -557,219 +79,25 @@
<div class="hand-flex">
{#each hand as card}
<div class="card-large suit-{isJoker(card) ? 'joker' : card.slice(-1).toLowerCase()} {isJoker(card) ? 'joker-card' : ''}"
draggable={state.player.role !== "deep"}
on:dragstart={(e) => {
e.dataTransfer.setData('text/plain', card);
e.currentTarget.classList.add("dragging");
}}
on:dragend={(e) => {
e.currentTarget.classList.remove("dragging");
}}>
<div class="card-corner top-left">
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
<div class="card-center">
{#if isJoker(card)}
🃏
{:else}
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
{/if}
</div>
<div class="card-tech-overlay">
{#if !isJoker(card) && card.slice(0, -1) === "J"}
<div class="tech-tag" title="Automatic success when played">J: "{state.player.tech_jack}"</div>
{:else if !isJoker(card) && card.slice(0, -1) === "Q"}
<div class="tech-tag" title="Automatic success when played">Q: "{state.player.tech_queen}"</div>
{:else if !isJoker(card) && card.slice(0, -1) === "K"}
<div class="tech-tag" title="Automatic success when played">K: "{state.player.tech_king}"</div>
{/if}
</div>
<div class="card-corner bottom-right">
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
</div>
<Card {card} techs={playerTechs}
draggable={state.player.role !== "deep"}
on:dragstart={(e) => {
e.dataTransfer.setData('text/plain', card);
e.currentTarget.classList.add("dragging");
}}
on:dragend={(e) => {
e.currentTarget.classList.remove("dragging");
}} />
{:else}
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
{/each}
</div>
</div>
<!-- Player Character Sheet Card -->
{#if state.player.role !== "deep"}
<div class="card glass-panel sheet-card">
<h3>🐀 {state.player.name}'s Character Sheet</h3>
<div class="character-sheet-details">
{#if state.player.is_dead}
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.1); border: 1px dashed var(--red-suit); padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
<h4 style="color: var(--red-suit); margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">💀 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}
<div class="sheet-group prompt-box" style="background: rgba(120, 150, 180, 0.1); border: 1px dashed #7890a8; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
<h4 style="color: #a0b0c0; margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">👻 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="sheet-group prompt-box" style="background: rgba(212, 175, 55, 0.1); border: 1px dashed var(--gold); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
<h4 style="color: var(--gold); margin-top: 0;">🏴‍☠️ 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 style="display: flex; gap: 0.5rem;">
<input type="text" bind:value={newNameInput} class="form-control" 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}
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.08); border: 1px dashed var(--red-suit); padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; text-align: 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>
{/if}
<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>
</div>
<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>
</ul>
</div>
<!-- Duel another Pi-Rat -->
{#if !state.player.is_dead && scenePirats.filter(p => p.id !== state.player.id && !p.is_dead).length > 0}
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">⚔️ 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 scenePirats.filter(p => p.id !== state.player.id && !p.is_dead) as p}
<option value={p.id}>{p.name} (Rank {p.rank})</option>
{/each}
</select>
<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}
<option value={card}>{getCardDisplay(card)}</option>
{/each}
</select>
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
</div>
{/if}
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">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 style="color: var(--gold);">Personal Objectives</h4>
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_1} disabled>
<span>1. Gat</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_2} disabled>
<span>2. Name</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_3} disabled>
<span>3. Die/Retire</span>
</label>
</div>
</div>
</div>
</div>
<CharacterSheet {state} />
{/if}
</div>
</div>
<!-- Floating Event Log -->
<div class="floating-event-log {showEventLog ? 'open' : ''}">
<button class="toggle-log-btn" on:click={() => showEventLog = !showEventLog}>
{showEventLog ? '⬇️ Hide Log' : '📜 Event Log'}
</button>
{#if showEventLog}
<div class="log-content font-mono text-sm space-y-2 p-2">
{#each state.events as event}
<div class="p-2 border-l-2 border-gray-600 bg-black text-gray-400" style="border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 0.25rem;">
{event.message}
</div>
{:else}
<div class="p-2 text-gray-500">No events yet.</div>
{/each}
</div>
{/if}
</div>
<style>
.floating-event-log {
position: fixed;
bottom: 20px;
right: 20px;
width: 350px;
background: rgba(10, 15, 25, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
z-index: 1000;
display: flex;
flex-direction: column;
max-height: 60vh;
backdrop-filter: blur(10px);
}
.toggle-log-btn {
background: var(--dark-bg, #1a1a2e);
color: white;
border: none;
padding: 10px;
text-align: center;
cursor: pointer;
font-weight: bold;
border-radius: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.toggle-log-btn:hover {
background: rgba(255,255,255,0.1);
}
.log-content {
overflow-y: auto;
padding: 10px;
flex: 1;
}
.floating-event-log:not(.open) {
width: auto;
}
</style>
<EventLog {state} />

View File

@@ -1,6 +1,7 @@
<script>
import { apiRequest } from '../lib/api';
import { getSuggestion } from '../lib/suggestions';
import Card from './Card.svelte';
export let state;
@@ -319,28 +320,10 @@
<h4 class="gold-text">Hand (Keep)</h4>
<div class="upkeep-flex">
{#each keepCards as card}
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}"
draggable="true"
on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToDiscard(card)}
style="cursor: pointer;"
title="Click or drag to discard">
<div class="card-corner top-left">
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
<div class="card-center">
{#if card.startsWith('Joker')}
🃏
{:else}
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
{/if}
</div>
<div class="card-corner bottom-right">
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
</div>
<Card {card} draggable={true} style="cursor: pointer;"
title="Click or drag to discard"
on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToDiscard(card)} />
{:else}
<p class="empty-text text-center w-full">No cards in hand.</p>
{/each}
@@ -354,28 +337,10 @@
<h4 class="gold-text">Discard Pile</h4>
<div class="upkeep-flex">
{#each discardCards as card}
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}"
draggable="true"
on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToKeep(card)}
style="cursor: pointer;"
title="Click or drag to keep">
<div class="card-corner top-left">
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
<div class="card-center">
{#if card.startsWith('Joker')}
🃏
{:else}
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
{/if}
</div>
<div class="card-corner bottom-right">
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
</div>
<Card {card} draggable={true} style="cursor: pointer;"
title="Click or drag to keep"
on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToKeep(card)} />
{:else}
<p class="empty-text text-center w-full">Drag cards here to discard them.</p>
{/each}

View File

@@ -0,0 +1,143 @@
<script>
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, playerName as lookupName } from '../../lib/cards';
export let state;
let error = '';
let taxTargetId = '';
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: 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');
function playerName(id) {
return lookupName(state.players, id);
}
function taxType(player) {
if (!player.completed_personal_1) return 'Gat';
if (!player.completed_personal_2) return 'Name';
return null;
}
function eligibleTaxTargets() {
const type = taxType(state.player);
if (!type) return [];
return scenePirats.filter(p =>
p.id !== state.player.id && !p.is_dead &&
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
);
}
async function post(path, data = null) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/${path}`, 'POST', data);
return true;
} catch(e) {
error = e.message;
return false;
}
}
const resolveChallenge = (id) => post(`challenge/${id}/resolve`);
const cancelChallenge = (id) => post(`challenge/${id}/cancel`);
const respondTax = (id, accept) => post(`challenge/${id}/tax/respond`, { accept: accept ? 'true' : 'false' });
const pvpDefend = (id, cardCode) => post(`challenge/${id}/pvp/defend`, { card_code: cardCode });
async function requestTax(challengeId) {
if (await post(`challenge/${challengeId}/tax/request`, { target_player_id: taxTargetId })) {
taxTargetId = '';
}
}
</script>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
{#each openChallenges as ch}
{@const chPlays = JSON.parse(ch.plays || '[]')}
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
<div class="glass-panel" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red-suit, #ff2a5f); border-radius: 8px; background: rgba(255, 42, 95, 0.06);">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h4 style="margin: 0; font-family: var(--font-heading);">
{#if ch.challenge_type === 'pvp'}
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
{:else}
⚔️ The Deep challenges {playerName(ch.target_player_id)}!
{/if}
</h4>
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" on:click={() => resolveChallenge(ch.id)} disabled={ch.tax_state === 'requested'}>Resolve</button>
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
</div>
{/if}
</div>
{#if ch.stakes}
<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>{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).
</p>
{/if}
{#if chPlays.length > 0}
<p class="info-text" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;">
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
</p>
{/if}
<!-- Tax: pending request aimed at me -->
{#if ch.tax_state === 'requested'}
{#if myTaxRequest && myTaxRequest.id === ch.id}
<div style="margin-top: 0.75rem; padding: 0.75rem; border: 1px dashed var(--gold); border-radius: 6px;">
<p style="margin: 0 0 0.5rem 0;"><strong>{playerName(ch.target_player_id)}</strong> calls a <strong>{ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax</strong> on you: take this Challenge for them!</p>
<p class="info-text" style="font-size: 0.85rem;">Accept: you get one random card from their hand and attempt the Challenge. Refuse: you hand over your {ch.tax_type === 'gat' ? 'Gat' : 'Name'} — they keep it if they succeed!</p>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
</div>
</div>
{:else}
<p class="info-text gold-text" style="margin-top: 0.5rem;">⏳ Waiting for {playerName(ch.tax_target_id)} to answer the {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax...</p>
{/if}
{/if}
<!-- Tax: option for the challenged player -->
{#if ch.challenge_type === 'deep' && ch.acting_player_id === state.player.id && !ch.tax_state && !state.player.tax_banned && taxType(state.player) && eligibleTaxTargets().length > 0}
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<span class="info-text" style="font-size: 0.9rem;">No {taxType(state.player)}? Make it someone else's problem:</span>
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
<option value="">Pick a crewmate...</option>
{#each eligibleTaxTargets() as p}
<option value={p.id}>{p.name}</option>
{/each}
</select>
<button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button>
</div>
{/if}
<!-- PvP: defender picks a card -->
{#if myPvpDefense && myPvpDefense.id === ch.id}
<div style="margin-top: 0.75rem;">
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{#each hand.filter(c => !isJoker(c)) as card}
<button class="btn btn-secondary" on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
{/each}
</div>
</div>
{/if}
</div>
{/each}

View File

@@ -0,0 +1,156 @@
<script>
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker } from '../../lib/cards';
export let state;
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');
$: duelTargets = scenePirats.filter(p => p.id !== state.player.id && !p.is_dead);
async function submitNewName() {
if (!newNameInput.trim()) return;
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
new_name: newNameInput.trim()
});
newNameInput = '';
} catch(e) {
error = e.message;
}
}
async function createPvp() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
defender_id: pvpOpponentId,
card_code: pvpCard
});
pvpOpponentId = '';
pvpCard = '';
} catch(e) {
error = e.message;
}
}
</script>
<div class="card glass-panel sheet-card">
<h3>🐀 {state.player.name}'s Character Sheet</h3>
<div class="character-sheet-details">
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
{#if state.player.is_dead}
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.1); border: 1px dashed var(--red-suit); padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
<h4 style="color: var(--red-suit); margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">💀 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}
<div class="sheet-group prompt-box" style="background: rgba(120, 150, 180, 0.1); border: 1px dashed #7890a8; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
<h4 style="color: #a0b0c0; margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">👻 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="sheet-group prompt-box" style="background: rgba(212, 175, 55, 0.1); border: 1px dashed var(--gold); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
<h4 style="color: var(--gold); margin-top: 0;">🏴‍☠️ 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 style="display: flex; gap: 0.5rem;">
<input type="text" bind:value={newNameInput} class="form-control" 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}
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.08); border: 1px dashed var(--red-suit); padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; text-align: 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>
{/if}
<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>
</div>
<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>
</ul>
</div>
<!-- Duel another Pi-Rat -->
{#if !state.player.is_dead && duelTargets.length > 0}
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">⚔️ 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}>{p.name} (Rank {p.rank})</option>
{/each}
</select>
<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}
<option value={card}>{getCardDisplay(card)}</option>
{/each}
</select>
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
</div>
{/if}
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">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 style="color: var(--gold);">Personal Objectives</h4>
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_1} disabled>
<span>1. Gat</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_2} disabled>
<span>2. Name</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_3} disabled>
<span>3. Die/Retire</span>
</label>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,152 @@
<script>
import { apiRequest } from '../../lib/api';
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" style="max-height: 80vh; overflow-y: auto;">
<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 style="color: var(--gold);">⚔️ 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}>{p.name} (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="form-control" 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 style="color: var(--gold);">🏴‍☠️ 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}>{p.name}</option>
{/each}
</select>
<button class="btn btn-secondary" on:click={setCaptain}>Set</button>
</div>
</div>
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">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 style="color: var(--gold);">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 style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
<strong>{p.name}</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" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
<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

@@ -0,0 +1,131 @@
<script>
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker } from '../../lib/cards';
import Card from '../Card.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;
}
}
</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' : ''}"
style={in_challenge ? 'box-shadow: 0 0 0 2px var(--red-suit, #ff2a5f);' : ''}
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" style="display: flex; justify-content: center; align-items: center;">
<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 style="color: var(--red-suit, #ff2a5f); font-size: 0.8rem;">⚔️ 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" style="font-size: 1.5rem;">
{success_count} / {state.players.length}
</span>
</div>
<!-- Played Cards Column -->
<div class="played-column">
<h5>Card History (Column)</h5>
<div class="column-cards-flex">
<Card card={obs.original_card} size="mini" />
{#each column_cards as pc}
<Card card={pc.card} size="mini" rotated success={pc.success}
owner={pc.player_name.slice(0, 4)}
title="{pc.player_name} played {getCardDisplay(pc.card)}" />
{/each}
</div>
</div>
{#if is_completed && state.player.role === 'deep'}
<div class="text-center" style="margin-top: 1rem;">
<button class="btn btn-success" on:click={() => clearObstacle(obs.id)} style="width: 100%; border: 1px solid rgba(255,255,255,0.2);">Clear Obstacle</button>
</div>
{/if}
</div>
{:else}
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
{/each}

View File

@@ -1,5 +0,0 @@
<script>
let count = $state(0)
</script>
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>

27
frontend/src/lib/cards.js Normal file
View File

@@ -0,0 +1,27 @@
// Card-code helpers shared across components.
// Codes look like "10D", "KH", "Joker1" (black) / "Joker2" (red).
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
export function isJoker(code) {
return !!code && code.startsWith('Joker');
}
// "10D" -> "10"
export function cardValue(code) {
return code.slice(0, -1);
}
// "10D" -> "D"
export function cardSuit(code) {
return code.slice(-1);
}
export function getCardDisplay(code) {
if (!code) return '';
if (isJoker(code)) return '🃏 JOKER';
return `${cardValue(code)}${SUIT_EMOJI[cardSuit(code)] || cardSuit(code)}`;
}
export function playerName(players, id) {
return players.find(p => p.id === id)?.name || '???';
}

View File

@@ -1,3 +1,5 @@
import { apiRequest } from './api';
// Auto-generated math-pirate suggestions pool for character creation
const SUGGESTIONS = {
"look": [
@@ -1211,227 +1213,42 @@ const SUGGESTIONS = {
"They stubbornly argues about the slide rule and has the abacus tattoos to prove it.",
"They accidently misplaces the dry gunpowder bags and has a certificate from the Stowaway Academy.",
"They frequently drops the slide rule but gets confused by negative numbers."
],
"techniques": [
"I meant to do that",
"The floor was always a trap",
"Carlos owes me one",
"Barry already disarmed it",
"Jaspar took the blame",
"Greg always comes back",
"We left Greg on purpose",
"The seagulls work for me",
"My whiskers told me so",
"I was behind you the whole time",
"I am wearing two eyepatches",
"Nobody suspects the small one",
"I licked it, so it's mine",
"The grog made me do it",
"I know a guy",
"The guy knows another guy",
"I greased the entire ship",
"I hid the cannonball in my cheeks",
"I taught the parrot everything it knows",
"Hold my grog",
"Watch this",
"I counted to Pi",
"Pi is exactly three when I'm in a hurry",
"I cast Pie, again",
"I memorized the wrong map on purpose",
"I sneezed at exactly the right moment",
"I read about this in a book I ate",
"I learned this from a ghost",
"The barrel was empty on purpose",
"This is my emotional support cannon",
"That wasn't even my final form",
"I've been holding my breath since Tuesday",
"It's a rental",
"The ship likes me better",
"My cousin is a cannonball",
"My scars spell out a map",
"My other gat is a crossbow",
"I prepared this speech in advance",
"I read the rulebook upside down",
"Technically, that's legal",
"Objection!",
"It's called a heist now",
"You wouldn't hit a rat with glasses",
"These aren't even my real glasses",
"I have the high ground",
"You activated my trap card",
"It was me all along",
"Behold, a distraction",
"We meet again, stairs",
"I've seen this in a dream",
"The prophecy said nothing about Tuesdays",
"Catch!",
"Think fast",
"Look behind you",
"No, seriously, look behind you",
"This one goes to eleven",
"Critical squeak",
"Nothing up my sleeves but more sleeves",
"The anchor was a decoy",
"Six rats in a coat",
"The Deep blinked first",
"The Squid owed me a favor",
"The Albatross vouches for me",
"The Mermaid pinky-swore",
"It worked in the bathtub",
"We rehearsed this exactly once",
"The smell did the negotiating",
"Carry me, I'm dramatic",
"It's only stealing if you get caught",
"Borrowed permanently",
"Never trust a dry deck",
"Never trust a barefoot vampire",
"Never duel before lunch",
"Never sing the second verse",
"Never play cards with a Goblin",
"Never wake a sleeping Bean Whale",
"Never bite the hand that feeds you cheese",
"Never bring a sword to a math fight",
"A rat always has an exit",
"Two exits, actually",
"A wet rat fears no rain",
"A captain never looks up",
"A good plan smells like cheese",
"The loudest rat is rarely the problem",
"Dead men file no complaints",
"The best sword is a longer sword",
"If you can't tie a knot, tie a lot",
"When in doubt, scream and leap",
"Every storm is someone's fault",
"All maps lead to treasure if you're stubborn enough",
"The sea forgives nothing but forgets everything",
"Steal the boat first, the hearts will follow",
"You miss every shot you don't blame on the wind",
"There's no such thing as too much rope",
"It is always squid o'clock somewhere",
"Fortune favors the furry",
"Don't count your doubloons until they're stolen",
"Cheese before glory",
"Glory before breakfast",
"Gravity is a suggestion",
"Rule one: there are no rules",
"Rule two: see rule one",
"The knot unties itself if you believe",
"Smile until they get nervous",
"Just keep nodding",
"Pretend it's Tuesday",
"Speak softly and carry a loud gat",
"Improvise, adapt, overboard",
"Cry first, ask questions later",
"The tide pays its debts",
"Apologize mid-swing",
"Bite first, monologue later",
"Lose dramatically, win quietly",
"Fight like the ghosts are watching",
"Trip over the right thing",
"Be the cannonball",
"Always fall on the soft pirate",
"Flee now, gloat later",
"Dramatic timing is a weapon",
"Cheese knows the way home",
"Trust the mustache",
"The plank is a state of mind",
"Every plank is a trapdoor if you stomp hard enough",
"The pointy end goes in the other guy",
"An apology and a gat beats an apology",
"It's not a bribe, it's a gift",
"The fourth wall is load-bearing",
"Vampires can't resist counting spilled rice",
"Distract them with interpretive dance",
"Sing the shanty of unreasonable confidence",
"The Cheddar Gambit",
"The Gouda Guillotine",
"The Brie Breach",
"The Camembert Cannonade",
"The Reverse Plank Walk",
"The Drunken Compass Defense",
"The Whisker Feint",
"The Triple Barrel Roll",
"The Stowaway Shuffle",
"The Bilge Rat Backflip",
"The Captain's Distraction Waltz",
"The Forbidden Knot",
"The Unforgivable Cannonball",
"The Polite Mutiny",
"The Reverse Mutiny",
"The Squeak of a Thousand Regrets",
"The Tail-First Retreat",
"The Upside-Down Boarding Party",
"The Invisible Rope Trick",
"The Two-Hat Bamboozle",
"The Ghost Pepper Defense",
"The Barnacle Embrace",
"The Crow's Nest Cannonball",
"The Rigging Tango",
"The Anchor Yo-Yo",
"The Powder Keg Lullaby",
"The Mermaid's IOU",
"The Goblin Refund Policy",
"The Fifth Ace",
"The Sixth Ace",
"The Emergency Wedding",
"The Slow-Motion Walk Away",
"The Borrowed Thunder",
"The Cheese Wheel of Fortune",
"The Last Rat Standing",
"The First Rat Running",
"The Old Switcheroo",
"The Old Double Switcheroo",
"The Long Con (Short Version)",
"The Accidental Masterpiece",
"The Sympathy Limp",
"The Decoy Funeral",
"The Surprise Encore",
"The Overly Specific Alibi",
"The Group Hug Ambush",
"The Cannonball Curveball",
"The Soup of Truth",
"The Hat Trick (With Actual Hats)",
"The Thousand-Yard Squint",
"The Completely Legal Salvage",
"Carry the one",
"Round down, shoot up",
"Divide and run away",
"Subtract yourself from the situation",
"Multiply by chaos",
"Add insult to injury",
"The remainder goes in my pocket",
"Long division, short fuse",
"Triangulate, then detonate",
"Roll for cheese",
"Yo ho ho and a barrel of math",
"Ask the cook, he's seen worse",
"Barry has a boat for this",
"Tell Jaspar I said hello",
"The first mate signs my alibis",
"Piss Whiskers takes the watch",
"We voted, you lost",
"The cheese was a lie",
"One more verse won't hurt",
"Abandon ship, but stylishly"
]
};
export function getSuggestion(type, exclude = []) {
let arr = SUGGESTIONS[type];
if (!arr) return "";
function pickFrom(arr, exclude = []) {
const taken = new Set(exclude.map(s => s.trim().toLowerCase()));
const available = arr.filter(s => !taken.has(s.trim().toLowerCase()));
if (available.length > 0) arr = available;
return arr[Math.floor(Math.random() * arr.length)];
const pool = available.length > 0 ? available : arr;
return pool[Math.floor(Math.random() * pool.length)];
}
// Draws `count` distinct suggestions from a pool.
export function getSuggestions(type, count) {
export function getSuggestion(type, exclude = []) {
const arr = SUGGESTIONS[type];
if (!arr) return "";
return pickFrom(arr, exclude);
}
// Secret Pirate Technique names live in the backend (it also uses them to equip
// re-rolled recruits); fetch the pool once and pick from it client-side.
let techniquePoolPromise = null;
function loadTechniquePool() {
if (!techniquePoolPromise) {
techniquePoolPromise = apiRequest('/suggestions/techniques').then(d => d.techniques);
}
return techniquePoolPromise;
}
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++) {
const s = getSuggestion(type, picked);
if (!s) break;
picked.push(s);
picked.push(pickFrom(pool, [...exclude, ...picked]));
}
return picked;
}