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

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
.venv .venv
rats_with_gats.db rats_with_gats.db
smoke_test.db
result result
*.pdf *.pdf
# Built Svelte frontend (copied in from frontend/dist by the Nix build)
src/pirats/static/

View File

@@ -1,8 +1,8 @@
# Rats with Gats Remote Play (`pirats`) # Rats with Gats Remote Play (`pirats`)
A web-based remote play companion app for the tabletop roleplaying game **Rats with Gats** (where players roleplay as space-faring pirate rats, or "Pi-Rats"). The application manages lobbies, character creation, card mechanics, scene phases, obstacles, challenges, and player voting. A web-based remote play companion app for the tabletop roleplaying game **Rats with Gats** (where players roleplay as gunslinging pirate rats, or "Pi-Rats", in the magical land of Yeld). The application manages lobbies, character creation, card mechanics, scene phases, obstacles, challenges, and player voting. See [RULEBOOK.md](RULEBOOK.md) for the game rules.
It is built with **FastAPI**, **SQLModel** (SQLite database), **Jinja2 templates**, and **HTMX** for dynamic, real-time page updates. It is built with a **FastAPI** + **SQLModel** (SQLite) backend and a **Svelte 5** single-page frontend (in `frontend/`), which polls the backend's JSON state endpoint for near-real-time updates.
--- ---
@@ -125,16 +125,30 @@ nix develop --command pytest
``` ```
pirats/ pirats/
├── pyproject.toml # Python package metadata and dependencies ├── pyproject.toml # Python package metadata and dependencies
├── flake.nix # Nix package, dev shell, and NixOS service module ├── flake.nix # Nix package (incl. frontend build), dev shell, NixOS service module
├── tests/ # Unit and integration tests ├── tests/
│ └── test_game.py # Game logic and CRUD database test suites │ └── test_game.py # Game logic and CRUD test suite (pure-crud, no HTTP for most)
├── frontend/ # Svelte 5 + Vite SPA
│ └── src/
│ ├── pages/ # Routes: Home, Join, Dashboard (game UI), Admin
│ ├── components/ # One component per game phase, Card.svelte, and scene/ (ScenePhase sub-panels)
│ └── lib/ # api.js (fetch wrapper), cards.js (card display helpers), suggestions.js (Suggest-button pools)
└── src/ └── src/
└── pirats/ # Core Python package └── pirats/ # FastAPI backend package
├── main.py # FastAPI routes, HTTP endpoints, CLI entrypoint ├── main.py # App setup, /api/game create/join/state endpoints, CLI entrypoint
├── crud.py # Core database CRUD, game mechanics, and transitions ├── models.py # SQLModel tables (Game, Player, Obstacle, Challenge, Vote, GameEvent)
├── models.py # SQLModel database tables (Game, Player, Obstacle, Challenge, Vote) ├── cards.py # Card parsing, deck building, obstacle table from the rulebook
├── cards.py # Playing card parser, deck setups, and obstacle definitions ├── crud.py # Facade re-exporting all crud_* modules
├── templates/ # Jinja2 HTML pages and HTMX snippets ├── crud_*.py # Game logic by phase: base (deck/hands/rank), character, scene, challenge, upkeep
── static/ # CSS styles and frontend assets ── routes_*.py # Thin API routers by phase, mounted under /api
└── static/ # Built frontend lands here (gitignored; populated by the Nix build)
```
### Frontend Development
Run the backend (`pirats --reload`) and the Vite dev server side by side; Vite proxies `/api` to port 8000:
```bash
cd frontend && npm install && npm run dev
``` ```

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> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { getSuggestion, getSuggestions } from '../lib/suggestions'; import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions';
export let state; export let state;
@@ -142,7 +142,7 @@
// grabbed the same name first (the backend rejects collisions) // grabbed the same name first (the backend rejects collisions)
if (createdTechniques.length === 0) { if (createdTechniques.length === 0) {
for (let attempt = 0; attempt < 5; attempt++) { for (let attempt = 0; attempt < 5; attempt++) {
[tech1, tech2, tech3] = getSuggestions('techniques', 3); [tech1, tech2, tech3] = await getTechniqueSuggestions(3);
await submitTechniques(); await submitTechniques();
if (!techError) break; if (!techError) break;
} }
@@ -313,21 +313,21 @@
<label for="tech1">Technique #1:</label> <label for="tech1">Technique #1:</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field"> <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> </div>
<div class="form-group"> <div class="form-group">
<label for="tech2">Technique #2:</label> <label for="tech2">Technique #2:</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field"> <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> </div>
<div class="form-group"> <div class="form-group">
<label for="tech3">Technique #3:</label> <label for="tech3">Technique #3:</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field"> <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>
</div> </div>
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button> <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> <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; export let state;
let playingCard = false;
let error = '';
// Helpers
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; $: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null; $: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
$: isCaptain = state.game.captain_player_id === state.player.id; $: isCaptain = state.game.captain_player_id === state.player.id;
$: challenges = state.challenges || []; $: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
$: 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}`;
}
</script> </script>
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}> <div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
@@ -274,280 +60,16 @@
</div> </div>
</div> </div>
{#if error} <ChallengePanel {state} />
<div class="alert alert-danger">{error}</div> <ObstacleBoard {state} />
{/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}
</div> </div>
</div> </div>
</div> </div>
<!-- RIGHT COLUMN: Private Hand & Character Sheet --> <!-- RIGHT COLUMN: Private Hand & Character Sheet -->
<div class="private-column"> <div class="private-column">
<!-- DEEP CONTROLS: Challenges, End Scene & Objectives -->
{#if state.player.role === "deep"} {#if state.player.role === "deep"}
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;"> <DeepControlPanel {state} />
<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>
{/if} {/if}
<!-- Player Hand Card --> <!-- Player Hand Card -->
@@ -557,219 +79,25 @@
<div class="hand-flex"> <div class="hand-flex">
{#each hand as card} {#each hand as card}
<div class="card-large suit-{isJoker(card) ? 'joker' : card.slice(-1).toLowerCase()} {isJoker(card) ? 'joker-card' : ''}" <Card {card} techs={playerTechs}
draggable={state.player.role !== "deep"} draggable={state.player.role !== "deep"}
on:dragstart={(e) => { on:dragstart={(e) => {
e.dataTransfer.setData('text/plain', card); e.dataTransfer.setData('text/plain', card);
e.currentTarget.classList.add("dragging"); e.currentTarget.classList.add("dragging");
}} }}
on:dragend={(e) => { on:dragend={(e) => {
e.currentTarget.classList.remove("dragging"); 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>
{:else} {: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> <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} {/each}
</div> </div>
</div> </div>
<!-- Player Character Sheet Card -->
{#if state.player.role !== "deep"} {#if state.player.role !== "deep"}
<div class="card glass-panel sheet-card"> <CharacterSheet {state} />
<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>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Floating Event Log --> <EventLog {state} />
<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>

View File

@@ -1,6 +1,7 @@
<script> <script>
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { getSuggestion } from '../lib/suggestions'; import { getSuggestion } from '../lib/suggestions';
import Card from './Card.svelte';
export let state; export let state;
@@ -319,28 +320,10 @@
<h4 class="gold-text">Hand (Keep)</h4> <h4 class="gold-text">Hand (Keep)</h4>
<div class="upkeep-flex"> <div class="upkeep-flex">
{#each keepCards as card} {#each keepCards as card}
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}" <Card {card} draggable={true} style="cursor: pointer;"
draggable="true" title="Click or drag to discard"
on:dragstart={(e) => handleDragStart(e, card)} on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToDiscard(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>
{:else} {:else}
<p class="empty-text text-center w-full">No cards in hand.</p> <p class="empty-text text-center w-full">No cards in hand.</p>
{/each} {/each}
@@ -354,28 +337,10 @@
<h4 class="gold-text">Discard Pile</h4> <h4 class="gold-text">Discard Pile</h4>
<div class="upkeep-flex"> <div class="upkeep-flex">
{#each discardCards as card} {#each discardCards as card}
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}" <Card {card} draggable={true} style="cursor: pointer;"
draggable="true" title="Click or drag to keep"
on:dragstart={(e) => handleDragStart(e, card)} on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToKeep(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>
{:else} {:else}
<p class="empty-text text-center w-full">Drag cards here to discard them.</p> <p class="empty-text text-center w-full">Drag cards here to discard them.</p>
{/each} {/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 // Auto-generated math-pirate suggestions pool for character creation
const SUGGESTIONS = { const SUGGESTIONS = {
"look": [ "look": [
@@ -1211,227 +1213,42 @@ const SUGGESTIONS = {
"They stubbornly argues about the slide rule and has the abacus tattoos to prove it.", "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 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." "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 = []) { function pickFrom(arr, exclude = []) {
let arr = SUGGESTIONS[type];
if (!arr) return "";
const taken = new Set(exclude.map(s => s.trim().toLowerCase())); const taken = new Set(exclude.map(s => s.trim().toLowerCase()));
const available = arr.filter(s => !taken.has(s.trim().toLowerCase())); const available = arr.filter(s => !taken.has(s.trim().toLowerCase()));
if (available.length > 0) arr = available; const pool = available.length > 0 ? available : arr;
return arr[Math.floor(Math.random() * arr.length)]; return pool[Math.floor(Math.random() * pool.length)];
} }
// Draws `count` distinct suggestions from a pool. export function getSuggestion(type, exclude = []) {
export function getSuggestions(type, count) { 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 = []; const picked = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const s = getSuggestion(type, picked); picked.push(pickFrom(pool, [...exclude, ...picked]));
if (!s) break;
picked.push(s);
} }
return picked; return picked;
} }

View File

@@ -1,5 +1,5 @@
import random import random
from typing import Dict, Any, List, Tuple from typing import Dict, Any, List
SUITS = { SUITS = {
"C": {"symbol": "", "name": "Clubs", "color": "black", "narration": "Shootin' and Stabbin' (violence/combat)"}, "C": {"symbol": "", "name": "Clubs", "color": "black", "narration": "Shootin' and Stabbin' (violence/combat)"},
@@ -74,50 +74,209 @@ OBSTACLES_DATA = {
} }
} }
# Secret Pirate Technique suggestions for when players need ideas: 20 handcrafted # Secret Pirate Technique suggestions: served to the frontend's Suggest buttons
# names plus the same "{math word} {move} of {target}" combinations the frontend # via GET /api/suggestions/techniques and used to equip re-rolled recruits.
# suggestion pool (frontend/src/lib/suggestions.js) is built from. TECHNIQUE_SUGGESTIONS = [
_HANDCRAFTED_TECHNIQUES = [ "I meant to do that",
"I missed on purpose", "The floor was always a trap",
"Carlos will figure it out", "Carlos owes me one",
"Never trust a hatless captain", "Barry already disarmed it",
"I'm not left handed", "Jaspar took the blame",
"The Albatross is always watching", "Greg always comes back",
"I've got a bomb", "We left Greg on purpose",
"Divide by Pi", "The seagulls work for me",
"Cheese-scented smoke screen", "My whiskers told me so",
"Look behind you, a three-headed parrot!", "I was behind you the whole time",
"Parabolic boarding leap", "I am wearing two eyepatches",
"The Pocket Cannon", "Nobody suspects the small one",
"Aggressive Math-whining", "I licked it, so it's mine",
"Unspeakable Tail Swish", "The grog made me do it",
"Bandana Whip", "I know a guy",
"Double-barrelled cheese blaster", "The guy knows another guy",
"Mathematical Scurvy cure", "I greased the entire ship",
"I was hiding under the carpet", "I hid the cannonball in my cheeks",
"Batten down my feelings", "I taught the parrot everything it knows",
"Wait, is that cheese?", "Hold my grog",
"Sudden pirate flip" "Watch this",
] "I counted to Pi",
"Pi is exactly three when I'm in a hurry",
_TECHNIQUE_MATH_WORDS = [ "I cast Pie, again",
"Geometric", "Parabolic", "Fibonacci", "Trigonometric", "Prime Number", "I memorized the wrong map on purpose",
"Logarithmic", "Hypotenuse", "Algebraic", "Binary", "Vector" "I sneezed at exactly the right moment",
] "I read about this in a book I ate",
_TECHNIQUE_MOVES = [ "I learned this from a ghost",
"Drift", "Leap", "Flurry", "Slash", "Parry", "The barrel was empty on purpose",
"Slam", "Dodge", "Vortex", "Shield", "Barrage" "This is my emotional support cannon",
] "That wasn't even my final form",
_TECHNIQUE_TARGETS = [ "I've been holding my breath since Tuesday",
"the Cheese Reef", "the Stormy Sea", "Euler", "the Abacus", "the Gat", "It's a rental",
"Carlos", "the Deep", "Gauss", "the Coordinate Plane", "Shrapnel" "The ship likes me better",
] "My cousin is a cannonball",
"My scars spell out a map",
TECHNIQUE_SUGGESTIONS = _HANDCRAFTED_TECHNIQUES + [ "My other gat is a crossbow",
f"{math_word} {move} of {target}" "I prepared this speech in advance",
for math_word in _TECHNIQUE_MATH_WORDS "I read the rulebook upside down",
for move in _TECHNIQUE_MOVES "Technically, that's legal",
for target in _TECHNIQUE_TARGETS "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"
] ]
def get_fresh_deck() -> List[str]: def get_fresh_deck() -> List[str]:

View File

@@ -1,10 +1,12 @@
import json import json
import random import random
from typing import List, Optional from typing import Any, Dict, List, Optional
from sqlmodel import Session from sqlmodel import Session, select
from .models import Game, Player, Obstacle, GameEvent from .models import Game, Player, GameEvent
from . import cards from . import cards
FACE_VALUES = ("J", "Q", "K")
# --- Card and Hand Utilities --- # --- Card and Hand Utilities ---
def get_player_hand(player: Player) -> List[str]: def get_player_hand(player: Player) -> List[str]:
@@ -19,6 +21,64 @@ def get_game_deck(game: Game) -> List[str]:
def set_game_deck(game: Game, deck: List[str]): def set_game_deck(game: Game, deck: List[str]):
game.deck_cards = json.dumps(deck) game.deck_cards = json.dumps(deck)
def technique_for_face_card(player: Player, value: str) -> str:
"""The Secret Pirate Technique a player has assigned to a face card value (J/Q/K)."""
return {
"J": player.tech_jack or "Jack Secret Technique",
"Q": player.tech_queen or "Queen Secret Technique",
"K": player.tech_king or "King Secret Technique",
}[value]
def evaluate_card_play(
player: Player,
card_code: str,
obstacle_card_code: str,
obstacle_value: int,
acting_rank: int,
) -> Dict[str, Any]:
"""
Shared Challenge resolution rules for one (non-Joker) card played by a Pi-Rat
(callers must have already rejected Jokers and Deep players):
- J/Q/K triggers the player's assigned Secret Pirate Technique: automatic success.
- A face card acting as the Obstacle is worth the challenged Pi-Rat's Rank
(acting_rank); any other Obstacle card is worth obstacle_value.
- Gat privilege: Black cards +1. Name privilege: Red cards +1.
Returns {"success", "details", "is_technique", "tech_name"}.
"""
played = cards.parse_card(card_code)
if played["value"] in FACE_VALUES:
tech_name = technique_for_face_card(player, played["value"])
return {
"success": True,
"details": f"Used Secret Pirate Technique: '{tech_name}'!",
"is_technique": True,
"tech_name": tech_name,
}
if cards.parse_card(obstacle_card_code)["value"] in FACE_VALUES:
target_value = acting_rank
obs_detail = f"Rank {acting_rank} (Face Card Obstacle)"
else:
target_value = obstacle_value
obs_detail = str(target_value)
privilege_bonus = 0
if player.completed_personal_1 and played["suit"] in ("C", "S"):
privilege_bonus += 1
if player.completed_personal_2 and played["suit"] in ("H", "D"):
privilege_bonus += 1
final_value = played["numeric_value"] + privilege_bonus
success = final_value > target_value
outcome = "Success" if success else "Failure"
return {
"success": success,
"details": f"{outcome}! Played {played['display']} ({final_value}) vs Obstacle value {obs_detail}.",
"is_technique": False,
"tech_name": "",
}
def calculate_max_hand_size(player: Player, players_in_scene: List[Player], captain_id: Optional[str] = None) -> int: def calculate_max_hand_size(player: Player, players_in_scene: List[Player], captain_id: Optional[str] = None) -> int:
""" """
Calculates a player's maximum hand size based on their Rank relative to others, Calculates a player's maximum hand size based on their Rank relative to others,
@@ -93,9 +153,9 @@ def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str =
msg = f"{new_captain.name} is now the Captain!" msg = f"{new_captain.name} is now the Captain!"
if reason: if reason:
msg += f" ({reason})" msg += f" ({reason})"
add_game_event(db, game.id, msg) add_game_event(db, game.id, msg, kind="captain")
elif reason: elif reason:
add_game_event(db, game.id, f"The Captain's position is vacant. ({reason})") add_game_event(db, game.id, f"The Captain's position is vacant. ({reason})", kind="captain")
def reshuffle_discard_pile(db: Session, game: Game): def reshuffle_discard_pile(db: Session, game: Game):
""" """
@@ -115,6 +175,10 @@ def reshuffle_discard_pile(db: Session, game: Game):
played = json.loads(obs.played_cards) played = json.loads(obs.played_cards)
for entry in played: for entry in played:
active_table_cards.add(entry["card"]) active_table_cards.add(entry["card"])
# A PvP challenger's temporary Obstacle is on the table until the duel resolves
for challenge in game.challenges:
if challenge.status == "open" and challenge.temp_card:
active_table_cards.add(challenge.temp_card)
# 3. The full deck of 54 cards # 3. The full deck of 54 cards
full_deck = [] full_deck = []
@@ -197,11 +261,29 @@ def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -
db.commit() db.commit()
db.refresh(player) db.refresh(player)
add_game_event(db, game_id, f"{name} joined the crew!") # Players joining mid-scene would otherwise never receive cards (starting
# hands are only dealt when scene 1 starts), so deal them in immediately.
if game and game.phase == "scene":
db.refresh(game)
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
draw_cards_for_player(db, game, player, max_size)
add_game_event(db, game_id, f"{name} joined the crew!", kind="join")
return player return player
def add_game_event(db: Session, game_id: str, message: str): def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
event = GameEvent(game_id=game_id, message=message) event = GameEvent(game_id=game_id, message=message, kind=kind)
db.add(event) db.add(event)
db.commit() db.commit()
def get_events_page(db: Session, game_id: str, before: Optional[float] = None, limit: int = 50):
"""Returns the newest `limit` events older than `before` (newest overall if None),
in ascending timestamp order, plus a flag for whether older events remain."""
query = select(GameEvent).where(GameEvent.game_id == game_id)
if before is not None:
query = query.where(GameEvent.timestamp < before)
query = query.order_by(GameEvent.timestamp.desc()).limit(limit + 1)
events = db.exec(query).all()
has_more = len(events) > limit
return list(reversed(events[:limit])), has_more

View File

@@ -6,7 +6,7 @@ from .models import Game, Player, Obstacle, Challenge
from . import cards from . import cards
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, get_player, get_game, get_player_hand, set_player_hand,
draw_cards_for_player, add_game_event, change_player_rank draw_cards_for_player, add_game_event, change_player_rank, evaluate_card_play
) )
from .crud_scene import resolve_card_against_obstacle from .crud_scene import resolve_card_against_obstacle
@@ -25,7 +25,7 @@ def _apply_captain_tax(db: Session, game: Game, acting_player: Player):
"""Captains lose 1 Rank each time they personally fail a Challenge.""" """Captains lose 1 Rank each time they personally fail a Challenge."""
if game.captain_player_id == acting_player.id and acting_player.rank > 1: if game.captain_player_id == acting_player.id and acting_player.rank > 1:
change_player_rank(db, game, acting_player, -1) change_player_rank(db, game, acting_player, -1)
add_game_event(db, game.id, f"Captain Tax! {acting_player.name} failed a Challenge and drops to Rank {acting_player.rank}.") add_game_event(db, game.id, f"Captain Tax! {acting_player.name} failed a Challenge and drops to Rank {acting_player.rank}.", kind="rank")
# --- Deep Challenges --- # --- Deep Challenges ---
@@ -80,7 +80,7 @@ def create_challenge(
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}." msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
if stakes.strip(): if stakes.strip():
msg += f" Stakes: {stakes.strip()}" msg += f" Stakes: {stakes.strip()}"
add_game_event(db, game.id, msg) add_game_event(db, game.id, msg, kind="challenge")
return True, "Challenge called!" return True, "Challenge called!"
def play_challenge_card( def play_challenge_card(
@@ -156,7 +156,7 @@ def play_challenge_card(
result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)" result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)"
role_note = "" if player.id == challenge.acting_player_id else " (assist)" role_note = "" if player.id == challenge.acting_player_id else " (assist)"
add_game_event(db, game.id, f"{player.name} played {cards.parse_card(card_code)['display']} on '{obstacle.title}'{role_note}. {result['details']}") add_game_event(db, game.id, f"{player.name} played {cards.parse_card(card_code)['display']} on '{obstacle.title}'{role_note}. {result['details']}", kind="card")
result["drew_card"] = drew_card result["drew_card"] = drew_card
result["is_joker"] = False result["is_joker"] = False
@@ -191,7 +191,7 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
db.commit() db.commit()
outcome = "succeeded" if success else "failed" outcome = "succeeded" if success else "failed"
add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!") add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!", kind="challenge")
# Settle a refused Gat/Name Tax: keep the prize on success, return it on failure. # Settle a refused Gat/Name Tax: keep the prize on success, return it on failure.
if challenge.tax_state == "refused" and challenge.tax_target_id: if challenge.tax_state == "refused" and challenge.tax_target_id:
@@ -199,7 +199,7 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
thing = "Gat" if challenge.tax_type == "gat" else "Name" thing = "Gat" if challenge.tax_type == "gat" else "Name"
if refuser: if refuser:
if success: if success:
add_game_event(db, game.id, f"{acting.name} succeeded and keeps {refuser.name}'s {thing}! {refuser.name} must find a new one.") add_game_event(db, game.id, f"{acting.name} succeeded and keeps {refuser.name}'s {thing}! {refuser.name} must find a new one.", kind="tax")
else: else:
if challenge.tax_type == "gat": if challenge.tax_type == "gat":
acting.completed_personal_1 = False acting.completed_personal_1 = False
@@ -213,7 +213,7 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
db.add(acting) db.add(acting)
db.commit() db.commit()
change_player_rank(db, game, acting, -1) change_player_rank(db, game, acting, -1)
add_game_event(db, game.id, f"{acting.name} failed and returns the {thing} to {refuser.name}. No more Gat/Name Taxes for {acting.name} this scene!") add_game_event(db, game.id, f"{acting.name} failed and returns the {thing} to {refuser.name}. No more Gat/Name Taxes for {acting.name} this scene!", kind="tax")
if not success: if not success:
_apply_captain_tax(db, game, acting) _apply_captain_tax(db, game, acting)
@@ -235,7 +235,7 @@ def cancel_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple[
db.delete(challenge) db.delete(challenge)
db.commit() db.commit()
if game and target: if game and target:
add_game_event(db, game.id, f"The Deep withdrew the Challenge against {target.name}.") add_game_event(db, game.id, f"The Deep withdrew the Challenge against {target.name}.", kind="challenge")
return True, "Challenge withdrawn." return True, "Challenge withdrawn."
# --- Gat Tax & Name Tax --- # --- Gat Tax & Name Tax ---
@@ -280,7 +280,7 @@ def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id
game = get_game(db, challenge.game_id) game = get_game(db, challenge.game_id)
thing = "Gat" if tax_type == "gat" else "Name" thing = "Gat" if tax_type == "gat" else "Name"
add_game_event(db, game.id, f"{requester.name} calls a {thing} Tax on {target.name}: 'Take this Challenge for me!'") add_game_event(db, game.id, f"{requester.name} calls a {thing} Tax on {target.name}: 'Take this Challenge for me!'", kind="tax")
return True, f"{thing} Tax called on {target.name}." return True, f"{thing} Tax called on {target.name}."
def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool) -> Tuple[bool, str]: def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool) -> Tuple[bool, str]:
@@ -324,7 +324,7 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
challenge.tax_state = "accepted" challenge.tax_state = "accepted"
db.add(challenge) db.add(challenge)
db.commit() db.commit()
add_game_event(db, game.id, f"{responder.name} accepted the {thing} Tax and takes over the Challenge!{card_note}") add_game_event(db, game.id, f"{responder.name} accepted the {thing} Tax and takes over the Challenge!{card_note}", kind="tax")
return True, "Tax accepted." return True, "Tax accepted."
# Refused: hand over the Gat/Name. The requester attempts the Challenge themselves; # Refused: hand over the Gat/Name. The requester attempts the Challenge themselves;
@@ -342,7 +342,7 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
db.add(challenge) db.add(challenge)
db.commit() db.commit()
change_player_rank(db, game, requester, 1) change_player_rank(db, game, requester, 1)
add_game_event(db, game.id, f"{responder.name} refused the {thing} Tax and must hand over their {thing}! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!") add_game_event(db, game.id, f"{responder.name} refused the {thing} Tax and must hand over their {thing}! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!", kind="tax")
return True, "Tax refused. The prize is on the line!" return True, "Tax refused. The prize is on the line!"
# --- Pi-Rat vs Pi-Rat Challenges --- # --- Pi-Rat vs Pi-Rat Challenges ---
@@ -397,7 +397,7 @@ def create_pvp_challenge(
parsed = cards.parse_card(card_code) parsed = cards.parse_card(card_code)
narration = cards.SUITS[parsed["suit"]]["narration"] narration = cards.SUITS[parsed["suit"]]["narration"]
add_game_event(db, game.id, f"{challenger.name} challenges {defender.name}, playing {parsed['display']} as a temporary Obstacle — {narration}!") add_game_event(db, game.id, f"{challenger.name} challenges {defender.name}, playing {parsed['display']} as a temporary Obstacle — {narration}!", kind="challenge")
return True, "Duel called!" return True, "Duel called!"
def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code: str) -> Tuple[bool, str, Dict[str, Any]]: def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code: str) -> Tuple[bool, str, Dict[str, Any]]:
@@ -430,43 +430,10 @@ def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code
set_player_hand(defender, hand) set_player_hand(defender, hand)
db.add(defender) db.add(defender)
temp_parsed = cards.parse_card(challenge.temp_card) # The temp card is the Obstacle: both its value source and its face-card check.
temp_value = cards.parse_card(challenge.temp_card)["numeric_value"]
success = False result = evaluate_card_play(defender, card_code, challenge.temp_card, temp_value, defender.rank)
details = "" success = result["success"]
is_technique = False
tech_name = ""
if played_parsed["value"] in ["J", "Q", "K"]:
success = True
is_technique = True
tech_name = {
"J": defender.tech_jack or "Jack Secret Technique",
"Q": defender.tech_queen or "Queen Secret Technique",
"K": defender.tech_king or "King Secret Technique",
}[played_parsed["value"]]
details = f"Used Secret Pirate Technique: '{tech_name}'!"
else:
# A face card acting as an Obstacle is worth the challenged Pi-Rat's Rank
if temp_parsed["value"] in ["J", "Q", "K"]:
target_value = defender.rank
obs_detail = f"Rank {defender.rank} (Face Card Obstacle)"
else:
target_value = temp_parsed["numeric_value"]
obs_detail = str(target_value)
privilege_bonus = 0
if defender.completed_personal_1 and played_parsed["suit"] in ["C", "S"]:
privilege_bonus = 1
if defender.completed_personal_2 and played_parsed["suit"] in ["H", "D"]:
privilege_bonus = 1
final_value = played_parsed["numeric_value"] + privilege_bonus
if final_value > target_value:
success = True
details = f"Success! Played {played_parsed['display']} ({final_value}) vs {temp_parsed['display']} ({obs_detail})."
else:
details = f"Failure! Played {played_parsed['display']} ({final_value}) vs {temp_parsed['display']} ({obs_detail})."
plays = json.loads(challenge.plays) plays = json.loads(challenge.plays)
plays.append({ plays.append({
@@ -475,7 +442,7 @@ def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code
"player_id": defender.id, "player_id": defender.id,
"player_name": defender.name, "player_name": defender.name,
"success": success, "success": success,
"details": details "details": result["details"]
}) })
challenge.plays = json.dumps(plays) challenge.plays = json.dumps(plays)
challenge.status = "succeeded" if success else "failed" challenge.status = "succeeded" if success else "failed"
@@ -487,19 +454,14 @@ def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code
drawn = draw_cards_for_player(db, game, defender, 1) drawn = draw_cards_for_player(db, game, defender, 1)
if drawn: if drawn:
drew_card = drawn[0] drew_card = drawn[0]
details += " (Drew a card back due to matching suit colors!)" result["details"] += " (Drew a card back due to matching suit colors!)"
outcome = "wins" if success else "loses" outcome = "wins" if success else "loses"
add_game_event(db, game.id, f"{defender.name} defends against {challenger.name} and {outcome}! {details}") add_game_event(db, game.id, f"{defender.name} defends against {challenger.name} and {outcome}! {result['details']}", kind="challenge")
if not success: if not success:
_apply_captain_tax(db, game, defender) _apply_captain_tax(db, game, defender)
return True, "Duel resolved.", { result["drew_card"] = drew_card
"success": success, result["is_joker"] = False
"details": details, return True, "Duel resolved.", result
"is_technique": is_technique,
"tech_name": tech_name,
"drew_card": drew_card,
"is_joker": False
}

View File

@@ -1,7 +1,7 @@
import json import json
import random import random
from sqlmodel import Session from sqlmodel import Session
from .models import Game, Player from .models import Game
from .crud_base import get_player, get_game, add_game_event from .crud_base import get_player, get_game, add_game_event
# --- Character Creation Operations --- # --- Character Creation Operations ---
@@ -126,7 +126,7 @@ def trigger_technique_swap(db: Session, game: Game):
p.created_techniques = "[]" p.created_techniques = "[]"
db.add(p) db.add(p)
db.commit() db.commit()
add_game_event(db, game.id, "Duplicate techniques submitted! The pool has been cleared. All players must try again with unique technique names.") add_game_event(db, game.id, "Duplicate techniques submitted! The pool has been cleared. All players must try again with unique technique names.", kind="warning")
return return
# Swap algorithm with simple retry backtracking # Swap algorithm with simple retry backtracking
@@ -166,7 +166,7 @@ def trigger_technique_swap(db: Session, game: Game):
game.phase = "swap_techniques" game.phase = "swap_techniques"
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Swap Techniques") add_game_event(db, game.id, "Phase changed: Swap Techniques", kind="phase")
success = True success = True
break break
@@ -180,7 +180,7 @@ def trigger_technique_swap(db: Session, game: Game):
game.phase = "swap_techniques" game.phase = "swap_techniques"
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Swap Techniques") add_game_event(db, game.id, "Phase changed: Swap Techniques", kind="phase")
def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str): def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
player = get_player(db, player_id) player = get_player(db, player_id)
@@ -234,7 +234,7 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s
game.phase = "scene_setup" game.phase = "scene_setup"
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup") add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
def roll_new_character( def roll_new_character(
db: Session, db: Session,
@@ -347,4 +347,4 @@ def roll_new_character(
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id) max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
draw_cards_for_player(db, game, player, max_size) draw_cards_for_player(db, game, player, max_size)
add_game_event(db, game.id, f"{player.name} has joined the crew as a new Rank {player.rank} recruit!") add_game_event(db, game.id, f"{player.name} has joined the crew as a new Rank {player.rank} recruit!", kind="join")

View File

@@ -1,14 +1,13 @@
import json import json
import random import random
from typing import Tuple, Dict, Any, Optional from typing import Tuple, Dict, Any
from sqlmodel import Session from sqlmodel import Session
from .models import Game, Player, Obstacle, Challenge from .models import Game, Player, Obstacle
from . import cards from . import cards
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, set_game_deck, calculate_max_hand_size, draw_cards_for_player, get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
add_game_event, reshuffle_discard_pile, capture_hand_maxes, apply_hand_increases, change_player_rank, set_captain, evaluate_card_play
change_player_rank, set_captain
) )
# --- Scene Setup Operations --- # --- Scene Setup Operations ---
@@ -115,7 +114,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
# Joker drawn as an Obstacle: discard it and permanently increase the # Joker drawn as an Obstacle: discard it and permanently increase the
# number of Obstacles required for following scenes by 1. # number of Obstacles required for following scenes by 1.
game.extra_obstacles += 1 game.extra_obstacles += 1
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.") add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.", kind="joker")
continue continue
info = cards.get_obstacle_info(card) info = cards.get_obstacle_info(card)
@@ -148,7 +147,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
db.commit() db.commit()
total_obstacles = active_count + drawn_count total_obstacles = active_count + drawn_count
add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {drawn_count} new obstacle(s) drawn ({total_obstacles} active).") add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {drawn_count} new obstacle(s) drawn ({total_obstacles} active).", kind="scene")
return True, "Scene started successfully!" return True, "Scene started successfully!"
@@ -193,83 +192,30 @@ def resolve_card_against_obstacle(
acting_rank: int acting_rank: int
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Resolves one (non-Joker) card from a player against an Obstacle: Resolves one (non-Joker) card from a player against an Obstacle via the shared
determines success (Secret Pirate Techniques, Gat/Name privileges, face-card rules in evaluate_card_play, appends the card to the Obstacle's column, and
Obstacle values), appends the card to the Obstacle's column, and updates updates the Obstacle's current value. Does not touch hands or draws.
the Obstacle's current value. Does not touch hands or draws.
""" """
played_parsed = cards.parse_card(card_code) # The last card in the column (or the original Obstacle card) determines the value.
played_list = json.loads(obstacle.played_cards)
obstacle_card = played_list[-1]["card"] if played_list else obstacle.original_card
success = False result = evaluate_card_play(player, card_code, obstacle_card, obstacle.current_value, acting_rank)
details = ""
is_technique = False
tech_name = ""
# Face Cards (Jack, Queen, King) played by a Pi-Rat trigger a Secret Pirate Technique: automatic success!
if played_parsed["value"] in ["J", "Q", "K"] and player.role != "deep":
success = True
is_technique = True
if played_parsed["value"] == "J":
tech_name = player.tech_jack or "Jack Secret Technique"
elif played_parsed["value"] == "Q":
tech_name = player.tech_queen or "Queen Secret Technique"
elif played_parsed["value"] == "K":
tech_name = player.tech_king or "King Secret Technique"
details = f"Used Secret Pirate Technique: '{tech_name}'!"
else:
# Regular card play vs Obstacle value. The last card in the column (or the
# original Obstacle card) determines the value; face cards count as the
# Rank of the challenged Pi-Rat.
obs_val_card = cards.parse_card(obstacle.original_card)
played_on_obs = json.loads(obstacle.played_cards)
if played_on_obs:
obs_val_card = cards.parse_card(played_on_obs[-1]["card"])
if obs_val_card["value"] in ["J", "Q", "K"]:
target_value = acting_rank
obs_detail = f"Rank {acting_rank} (Face Card Obstacle)"
else:
target_value = obstacle.current_value
obs_detail = str(target_value)
played_val = played_parsed["numeric_value"]
# Gat privilege: Black cards +1. Name privilege: Red cards +1.
privilege_bonus = 0
if player.completed_personal_1 and played_parsed["suit"] in ["C", "S"]:
privilege_bonus = 1
if player.completed_personal_2 and played_parsed["suit"] in ["H", "D"]:
privilege_bonus = 1
final_played_value = played_val + privilege_bonus
if final_played_value > target_value:
success = True
details = f"Success! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
else:
success = False
details = f"Failure! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
# Place the card in the Obstacle's column; it becomes the Obstacle's new value. # Place the card in the Obstacle's column; it becomes the Obstacle's new value.
played_list = json.loads(obstacle.played_cards)
played_list.append({ played_list.append({
"card": card_code, "card": card_code,
"player_id": player.id, "player_id": player.id,
"player_name": player.name, "player_name": player.name,
"success": success, "success": result["success"],
"details": details "details": result["details"]
}) })
obstacle.played_cards = json.dumps(played_list) obstacle.played_cards = json.dumps(played_list)
obstacle.current_value = played_parsed["numeric_value"] obstacle.current_value = cards.parse_card(card_code)["numeric_value"]
db.add(obstacle) db.add(obstacle)
db.commit() db.commit()
return { return result
"success": success,
"details": details,
"is_technique": is_technique,
"tech_name": tech_name
}
def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]: def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]:
""" """
@@ -318,7 +264,7 @@ def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) ->
new_parsed = cards.parse_card(new_card) new_parsed = cards.parse_card(new_card)
if new_parsed["is_joker"]: if new_parsed["is_joker"]:
game.extra_obstacles += 1 game.extra_obstacles += 1
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.") add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.", kind="joker")
continue continue
info = cards.get_obstacle_info(new_card) info = cards.get_obstacle_info(new_card)
@@ -340,7 +286,7 @@ def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) ->
msg = f"{player.name} played a Joker! '{obstacle_title}' is discarded" msg = f"{player.name} played a Joker! '{obstacle_title}' is discarded"
msg += f" and replaced by '{new_title}'." if new_title else " (no replacement left in the deck)." msg += f" and replaced by '{new_title}'." if new_title else " (no replacement left in the deck)."
add_game_event(db, game.id, msg) add_game_event(db, game.id, msg, kind="joker")
return True, msg return True, msg
@@ -418,7 +364,7 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
obj_name = obj_type.replace('_', ' ').title() obj_name = obj_type.replace('_', ' ').title()
status_text = "completed" if status else "unmarked" status_text = "completed" if status else "unmarked"
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.") add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]: def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]:
obstacle = db.get(Obstacle, obstacle_id) obstacle = db.get(Obstacle, obstacle_id)
@@ -471,7 +417,7 @@ def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]:
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str): def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
obstacle = db.get(Obstacle, obstacle_id) obstacle = db.get(Obstacle, obstacle_id)
if obstacle: if obstacle:
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.") add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.", kind="obstacle")
db.delete(obstacle) db.delete(obstacle)
db.commit() db.commit()
@@ -483,4 +429,4 @@ def finish_game(db: Session, game_id: str):
game.phase = "ended" game.phase = "ended"
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉") add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉", kind="victory")

View File

@@ -1,8 +1,7 @@
import json
import random import random
from typing import List, Tuple from typing import List, Tuple
from sqlmodel import Session, select from sqlmodel import Session, select
from .models import Game, Player, Vote from .models import Game, Vote
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event, get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
@@ -56,7 +55,7 @@ def end_scene_and_transition(db: Session, game_id: str):
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.") add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
def process_between_scenes_votes(db: Session, game: Game): def process_between_scenes_votes(db: Session, game: Game):
""" """
@@ -82,7 +81,7 @@ def process_between_scenes_votes(db: Session, game: Game):
winner = get_player(db, winner_id) winner = get_player(db, winner_id)
if winner: if winner:
change_player_rank(db, game, winner, 1) change_player_rank(db, game, winner, 1)
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.") add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank")
# Clear all votes # Clear all votes
for v in votes: for v in votes:
@@ -107,13 +106,13 @@ def transition_to_deep_upkeep(db: Session, game_id: str):
if has_resting_deep: if has_resting_deep:
game.phase = "deep_upkeep" game.phase = "deep_upkeep"
add_game_event(db, game.id, "Phase changed: Deep Upkeep") add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
else: else:
# Fallback in case there were no Deep players # Fallback in case there were no Deep players
# Just advance directly to scene_setup # Just advance directly to scene_setup
game.current_scene_number += 1 game.current_scene_number += 1
game.phase = "scene_setup" game.phase = "scene_setup"
add_game_event(db, game.id, "Phase changed: Scene Setup") add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
for p in game.players: for p in game.players:
p.role = None p.role = None
p.is_ready = False p.is_ready = False
@@ -164,7 +163,7 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
# Transition to scene_setup! # Transition to scene_setup!
game.current_scene_number += 1 game.current_scene_number += 1
game.phase = "scene_setup" game.phase = "scene_setup"
add_game_event(db, game.id, "Phase changed: Scene Setup") add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
for p in game.players: for p in game.players:
p.is_ready = False p.is_ready = False
p.role = None p.role = None

View File

@@ -31,6 +31,7 @@ def create_db_and_tables():
("player", "is_dead", "BOOLEAN DEFAULT FALSE"), ("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"), ("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"), ("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
] ]
for table, col, def_type in columns: for table, col, def_type in columns:

View File

@@ -1,25 +1,26 @@
from contextlib import asynccontextmanager
from typing import Optional from typing import Optional
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status, APIRouter from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlmodel import Session, select from sqlmodel import Session
import uvicorn import uvicorn
import os import os
from pathlib import Path from pathlib import Path
from . import crud from . import crud
from .database import create_db_and_tables, get_session from .database import create_db_and_tables, get_session
from .models import GameEvent
EVENT_PAGE_SIZE = 50
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
app = FastAPI(title="Rats with Gats Remote Play API") @asynccontextmanager
api = APIRouter() async def lifespan(app: FastAPI):
# Register startup event to create tables
@app.on_event("startup")
def on_startup():
create_db_and_tables() create_db_and_tables()
yield
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)
api = APIRouter()
# --- API Core Route Handlers --- # --- API Core Route Handlers ---
@@ -29,7 +30,8 @@ def create_game_route(
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
game = crud.create_game(db, crew_name=crew_name.strip()) game = crud.create_game(db, crew_name=crew_name.strip())
return {"id": game.id} # admin_key is only revealed here, to the creator; the state endpoint hides it
return {"id": game.id, "admin_key": game.admin_key}
@api.post("/game/{game_id}/join") @api.post("/game/{game_id}/join")
def join_game_submit( def join_game_submit(
@@ -53,16 +55,36 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi
if not game or not player: if not game or not player:
raise HTTPException(status_code=404, detail="Game or Player not found") raise HTTPException(status_code=404, detail="Game or Player not found")
events = db.exec(select(GameEvent).where(GameEvent.game_id == game_id).order_by(GameEvent.timestamp.asc())).all() events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
return { return {
"game": game.model_dump(), "game": game.model_dump(exclude={"admin_key"}),
"player": player.model_dump(), "player": player.model_dump(),
"players": [p.model_dump() for p in game.players], "players": [p.model_dump() for p in game.players],
"obstacles": [o.model_dump() for o in game.obstacles], "obstacles": [o.model_dump() for o in game.obstacles],
"challenges": [c.model_dump() for c in game.challenges], "challenges": [c.model_dump() for c in game.challenges],
"votes": [v.model_dump() for v in game.votes], "votes": [v.model_dump() for v in game.votes],
"events": [e.model_dump() for e in events], "events": [e.model_dump() for e in events],
"events_have_more": events_have_more,
}
@api.get("/game/{game_id}/events")
def get_game_events(
game_id: str,
before: Optional[float] = None,
limit: int = EVENT_PAGE_SIZE,
db: Session = Depends(get_session),
):
"""Pages back through the event log: returns the newest `limit` events older than `before`."""
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
limit = max(1, min(limit, 200))
events, have_more = crud.get_events_page(db, game_id, before=before, limit=limit)
return {
"events": [e.model_dump() for e in events],
"have_more": have_more,
} }
# --- Register Modular Phase Routers --- # --- Register Modular Phase Routers ---

View File

@@ -121,5 +121,6 @@ class GameEvent(SQLModel, table=True):
game_id: str = Field(foreign_key="game.id", index=True) game_id: str = Field(foreign_key="game.id", index=True)
timestamp: float = Field(default_factory=time.time) timestamp: float = Field(default_factory=time.time)
message: str = Field(...) message: str = Field(...)
kind: str = Field(default="info") # category for frontend icons/styling: "join", "phase", "scene", "captain", "challenge", "card", "tax", "objective", "vote", "obstacle", "joker", "victory", "warning", "rank", "info"
game: Optional[Game] = Relationship(back_populates="events") game: Optional[Game] = Relationship(back_populates="events")

View File

@@ -1,5 +1,4 @@
from fastapi import APIRouter, Request, Depends, HTTPException from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlmodel import Session from sqlmodel import Session
from .database import get_session from .database import get_session
from . import crud from . import crud

View File

@@ -1,12 +1,17 @@
from typing import Optional from fastapi import APIRouter, Form, Depends, HTTPException
from fastapi import APIRouter, Form, Depends, HTTPException, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlmodel import Session from sqlmodel import Session
from .database import get_session from .database import get_session
from . import crud from . import crud
from .cards import TECHNIQUE_SUGGESTIONS
router = APIRouter() router = APIRouter()
# Secret Pirate Technique name pool (for the frontend's Suggest buttons)
@router.get("/suggestions/techniques")
def technique_suggestions():
return {"techniques": TECHNIQUE_SUGGESTIONS}
# Save basic character details # Save basic character details
@router.post("/game/{game_id}/player/{player_id}/save-basic") @router.post("/game/{game_id}/player/{player_id}/save-basic")
def player_save_basic_details( def player_save_basic_details(

View File

@@ -1,4 +1,3 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session from sqlmodel import Session
from .database import get_session from .database import get_session

View File

@@ -1,4 +1,3 @@
from typing import Optional
from fastapi import APIRouter, Form, Depends, HTTPException from fastapi import APIRouter, Form, Depends, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlmodel import Session from sqlmodel import Session

View File

@@ -1,5 +1,5 @@
import json import json
from fastapi import APIRouter, Form, Depends, HTTPException, status from fastapi import APIRouter, Form, Depends, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlmodel import Session from sqlmodel import Session
from .database import get_session from .database import get_session

View File

@@ -1,28 +0,0 @@
/* --- Admin Panel --- */
.admin-players-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.admin-player-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.player-info-basic {
font-size: 1.05rem;
}
.link-copy-action {
display: flex;
gap: 0.5rem;
align-items: center;
flex: 1;
max-width: 500px;
}

View File

@@ -1,244 +0,0 @@
/* Card Widget (Mini) */
.card-mini {
width: 32px;
height: 48px;
border-radius: 4px;
border: 1px solid var(--text-muted);
background: var(--bg-dark);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 2px 4px;
font-size: 0.7rem;
font-weight: 900;
}
.card-mini.base-card {
border-color: var(--gold);
color: var(--gold);
background: rgba(212,175,55,0.1);
}
.card-mini.rotated {
transform: rotate(90deg);
margin: 0 4px;
}
.card-mini.success {
border-color: var(--neon-emerald);
color: var(--neon-emerald);
}
.card-mini.failure {
border-color: var(--red-suit);
color: var(--red-suit);
}
.card-mini .val {
align-self: flex-start;
}
.card-mini .suit {
align-self: flex-end;
}
.card-mini .owner {
font-size: 0.5rem;
text-transform: uppercase;
position: absolute;
bottom: -1px;
left: 1px;
color: var(--text-muted);
}
/* Card Widget (Medium) */
.card-medium {
width: 75px;
height: 112px;
border-radius: 6px;
border: 1.5px solid var(--glass-border);
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
box-shadow: 0 3px 10px rgba(0,0,0,0.4);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0.3rem;
position: relative;
font-size: 0.85rem;
}
.card-medium .card-corner {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
}
.card-medium .card-corner .val {
font-weight: 900;
font-size: 0.9rem;
}
.card-medium .card-corner .suit {
font-size: 0.75rem;
}
.card-medium.suit-c, .card-medium.suit-s {
border-color: rgba(226, 232, 240, 0.15);
}
.card-medium.suit-h, .card-medium.suit-d {
border-color: rgba(255, 42, 95, 0.15);
}
.card-medium.joker-card {
border-color: var(--gold);
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
}
.card-medium.suit-c .val, .card-medium.suit-c .suit,
.card-medium.suit-s .val, .card-medium.suit-s .suit {
color: var(--black-suit);
}
.card-medium.suit-h .val, .card-medium.suit-h .suit,
.card-medium.suit-d .val, .card-medium.suit-d .suit {
color: var(--red-suit);
}
.card-medium .card-center {
font-size: 1.8rem;
text-align: center;
align-self: center;
margin: auto 0;
}
.card-medium.suit-c .card-center, .card-medium.suit-s .card-center { color: var(--black-suit); }
.card-medium.suit-h .card-center, .card-medium.suit-d .card-center { color: var(--red-suit); }
/* Card Widget (Large) */
.card-large {
width: 110px;
height: 165px;
border-radius: 8px;
border: 2px solid var(--glass-border);
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0.5rem;
position: relative;
transition: var(--transition-smooth);
}
.card-large:hover {
transform: translateY(-8px) scale(1.05);
border-color: var(--gold);
box-shadow: 0 10px 25px rgba(212, 175, 55, 0.2);
}
.card-large.suit-c, .card-large.suit-s {
border-color: rgba(226, 232, 240, 0.15);
}
.card-large.suit-c:hover, .card-large.suit-s:hover {
border-color: var(--black-suit);
box-shadow: 0 10px 25px var(--black-glow);
}
.card-large.suit-h, .card-large.suit-d {
border-color: rgba(255, 42, 95, 0.15);
}
.card-large.suit-h:hover, .card-large.suit-d:hover {
border-color: var(--red-suit);
box-shadow: 0 10px 25px var(--red-glow);
}
.card-large.joker-card {
border-color: var(--gold);
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
}
.card-large .card-corner {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.1;
}
.card-large .card-corner .val {
font-size: 1.2rem;
font-weight: 900;
}
.card-large .card-corner .suit {
font-size: 1rem;
}
.card-large.suit-c .val, .card-large.suit-c .suit,
.card-large.suit-s .val, .card-large.suit-s .suit {
color: var(--black-suit);
}
.card-large.suit-h .val, .card-large.suit-h .suit,
.card-large.suit-d .val, .card-large.suit-d .suit {
color: var(--red-suit);
}
.card-large .card-center {
font-size: 2.8rem;
text-align: center;
align-self: center;
margin: auto 0;
}
.card-large.suit-c .card-center, .card-large.suit-s .card-center { color: var(--black-suit); }
.card-large.suit-h .card-center, .card-large.suit-d .card-center { color: var(--red-suit); }
.card-large .card-tech-overlay {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
right: 0.5rem;
text-align: center;
}
.tech-tag {
background: rgba(0, 0, 0, 0.7);
border: 1px solid var(--gold);
color: var(--gold);
font-size: 0.65rem;
padding: 0.15rem 0.3rem;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.joker-action {
display: flex;
gap: 2px;
}
/* Drag and Drop Interface Styles */
.card-large[draggable="true"] {
cursor: grab;
}
.card-large[draggable="true"]:active {
cursor: grabbing;
}
.card-large.dragging {
opacity: 0.4;
border-style: dashed;
transform: scale(0.95);
}
.obstacle-item.drag-over {
border-color: var(--gold) !important;
background: rgba(212, 175, 55, 0.1) !important;
box-shadow: 0 0 20px rgba(212, 175, 55, 0.2) !important;
transform: translateY(-2px) scale(1.01);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

View File

@@ -1,442 +0,0 @@
/* --- Character Creation & Sheet Grid --- */
.creation-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-top: 2rem;
}
@media (max-width: 900px) {
.creation-grid {
grid-template-columns: 1fr;
}
}
.section-desc {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.record-line {
background: rgba(0,0,0,0.2);
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 0.75rem;
border-left: 3px solid var(--gold);
}
.delegation-box {
margin-bottom: 1.25rem;
padding: 1.25rem;
text-align: left;
}
.delegation-box h4 {
font-size: 0.95rem;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.answer-box {
font-style: italic;
color: var(--neon-cyan);
font-size: 1.05rem;
}
.author-label {
font-size: 0.8rem;
color: var(--text-muted);
font-style: normal;
}
.waiting-box {
font-size: 0.9rem;
color: var(--gold);
display: flex;
align-items: center;
gap: 0.5rem;
}
.inbox-list:has(.inbox-item) .inbox-empty-message {
display: none;
}
.inbox-item {
padding: 1rem;
margin-bottom: 0.75rem;
border-left: 3px solid var(--neon-cyan);
background: rgba(0, 242, 254, 0.02);
}
.inbox-item label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.submitted-techniques .tech-chip {
background: rgba(212,175,55,0.05);
border: 1px solid var(--gold);
color: var(--gold);
padding: 0.5rem;
border-radius: 6px;
margin-bottom: 0.5rem;
font-family: var(--font-heading);
}
.tech-chip {
padding: 0.6rem 1rem;
font-size: 0.95rem;
}
.tech-assignment-pill {
background: rgba(0, 242, 254, 0.05);
border: 1px solid var(--neon-cyan);
color: var(--text-primary);
padding: 0.6rem 1rem;
border-radius: 6px;
margin-bottom: 0.5rem;
text-align: left;
display: flex;
align-items: center;
gap: 1rem;
}
.card-rank {
background: var(--neon-cyan);
color: var(--bg-dark);
font-weight: 900;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 0.85rem;
}
/* --- Character Sheet layout --- */
.character-sheet-details {
text-align: left;
}
.sheet-group {
background: rgba(0,0,0,0.15);
padding: 1rem;
border-radius: 8px;
border-left: 2px solid var(--gold-dark);
}
.sheet-group h4 {
color: var(--gold);
font-size: 0.95rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.footnote {
font-size: 0.8rem;
color: var(--text-muted);
}
.techniques-list-sheet {
list-style: none;
}
.techniques-list-sheet li {
margin-bottom: 0.25rem;
font-size: 0.95rem;
}
.footnote-desc {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.15rem;
margin-left: 1.5rem;
}
.margin-top-small {
margin-top: 0.5rem;
}
/* --- Visual & Tactile Technique Assignment --- */
.face-tech-drag-form {
margin-top: 1.5rem;
}
.available-techniques-container {
background: rgba(255, 255, 255, 0.02);
border: 1px dashed var(--glass-border);
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 2rem;
box-shadow: inset 0 0 15px rgba(0,0,0,0.2);
}
.available-techniques-container h4 {
font-family: var(--font-heading);
color: var(--gold);
font-size: 1.1rem;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.instruction-help {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.techniques-drag-pool {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
min-height: 50px;
align-items: center;
}
.draggable-tech-chip {
background: linear-gradient(135deg, rgba(22, 36, 59, 0.9) 0%, rgba(13, 23, 38, 0.9) 100%);
border: 1px solid var(--gold);
color: var(--text-primary);
padding: 0.6rem 1rem;
border-radius: 8px;
cursor: grab;
user-select: none;
font-family: var(--font-body);
font-size: 0.95rem;
font-weight: 500;
transition: var(--transition-smooth);
box-shadow: 0 4px 10px rgba(0,0,0,0.3), 0 0 5px rgba(212, 175, 55, 0.1);
display: flex;
align-items: center;
gap: 0.5rem;
}
.draggable-tech-chip:hover {
transform: translateY(-2px);
border-color: var(--neon-cyan);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4), 0 0 10px rgba(0, 242, 254, 0.3);
}
.draggable-tech-chip:active {
cursor: grabbing;
}
.draggable-tech-chip.dragging {
opacity: 0.4;
border-style: dashed;
}
.draggable-tech-chip.selected {
border-color: var(--neon-cyan);
box-shadow: 0 0 15px rgba(0, 242, 254, 0.5);
background: rgba(0, 242, 254, 0.1);
}
.draggable-tech-chip.assigned-hidden {
opacity: 0.2;
pointer-events: none;
cursor: not-allowed;
border-color: var(--text-muted);
}
.drag-icon {
color: var(--gold);
font-size: 0.8rem;
opacity: 0.7;
}
/* Slots Grid */
.face-cards-slots-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
}
@media (max-width: 768px) {
.face-cards-slots-grid {
grid-template-columns: 1fr;
}
}
.face-card-slot-wrapper {
display: flex;
justify-content: center;
}
.face-card-slot {
position: relative;
width: 100%;
max-width: 220px;
aspect-ratio: 2 / 3;
min-height: 280px;
background: linear-gradient(135deg, rgba(13, 23, 38, 0.8) 0%, rgba(7, 11, 18, 0.9) 100%);
border: 2px dashed rgba(212, 175, 55, 0.3);
border-radius: 12px;
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: var(--transition-smooth);
overflow: hidden;
cursor: pointer;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
.face-card-slot:hover {
border-color: rgba(212, 175, 55, 0.7);
box-shadow: 0 12px 30px rgba(212, 175, 55, 0.15);
transform: translateY(-4px);
}
.face-card-slot.drag-over {
border-color: var(--neon-cyan);
border-style: solid;
background: rgba(0, 242, 254, 0.05);
box-shadow: 0 0 20px rgba(0, 242, 254, 0.2);
transform: scale(1.02);
}
.face-card-slot.has-assignment {
border-style: solid;
border-color: var(--gold);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 15px rgba(212, 175, 55, 0.15);
}
.card-bg-letter {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: var(--font-heading);
font-size: 8rem;
font-weight: 900;
color: rgba(255, 255, 255, 0.025);
line-height: 1;
pointer-events: none;
user-select: none;
transition: var(--transition-smooth);
}
.face-card-slot.has-assignment .card-bg-letter {
color: rgba(212, 175, 55, 0.04);
}
.card-slot-header, .card-slot-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-heading);
font-weight: 700;
font-size: 1.1rem;
color: var(--text-muted);
pointer-events: none;
user-select: none;
}
.face-card-slot.has-assignment .card-slot-header,
.face-card-slot.has-assignment .card-slot-footer {
color: var(--gold);
}
.card-slot-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
z-index: 2;
padding: 0.5rem 0;
}
.card-title {
font-family: var(--font-heading);
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.75rem;
pointer-events: none;
}
.face-card-slot.has-assignment .card-title {
color: var(--gold);
}
.drop-zone-placeholder {
font-size: 0.8rem;
color: var(--text-muted);
border: 1px dashed rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 0.5rem 0.75rem;
transition: var(--transition-smooth);
pointer-events: none;
}
.face-card-slot:hover .drop-zone-placeholder {
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.2);
}
.assigned-tech-content {
width: 100%;
display: none;
}
.face-card-slot.has-assignment .drop-zone-placeholder {
display: none;
}
.face-card-slot.has-assignment .assigned-tech-content {
display: block;
}
.assigned-tech-chip {
background: rgba(212, 175, 55, 0.08);
border: 1px solid var(--gold);
color: var(--text-primary);
padding: 0.5rem;
border-radius: 8px;
font-size: 0.85rem;
position: relative;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
word-break: break-word;
animation: fadeIn 0.3s ease;
}
.remove-tech-btn {
position: absolute;
top: -8px;
right: -8px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #c0392b;
border: 1px solid #e74c3c;
color: white;
font-size: 10px;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.5);
transition: var(--transition-smooth);
}
.remove-tech-btn:hover {
background: #e74c3c;
transform: scale(1.1);
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}

View File

@@ -1,301 +0,0 @@
/* --- UI Panels & Glassmorphism --- */
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
transition: var(--transition-smooth);
}
.glass-panel:hover {
border-color: rgba(212, 175, 55, 0.35);
box-shadow: 0 8px 32px rgba(212, 175, 55, 0.05);
}
.card {
background: var(--bg-ocean-light);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
transition: var(--transition-smooth);
}
.card h3 {
font-family: var(--font-heading);
color: var(--gold);
margin-bottom: 1rem;
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
padding-bottom: 0.5rem;
}
/* --- Forms & Controls --- */
.form-group {
margin-bottom: 1.25rem;
text-align: left;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.95rem;
color: var(--text-muted);
font-weight: 500;
}
.input-field {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(7, 11, 18, 0.8);
border: 1px solid var(--glass-border);
border-radius: 8px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 1rem;
transition: var(--transition-smooth);
}
.input-field:focus {
outline: none;
border-color: var(--neon-cyan);
box-shadow: 0 0 10px rgba(0, 242, 254, 0.2);
}
.select-field {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(7, 11, 18, 0.8);
border: 1px solid var(--glass-border);
border-radius: 8px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 1rem;
transition: var(--transition-smooth);
cursor: pointer;
}
.select-field:focus {
outline: none;
border-color: var(--neon-cyan);
}
.input-row {
display: flex;
gap: 0.5rem;
}
.input-row .input-field {
flex: 1;
}
/* --- Buttons --- */
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
font-family: var(--font-body);
font-weight: 700;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 1px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: var(--transition-smooth);
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-dark) 100%);
color: var(--text-dark);
box-shadow: 0 4px 15px var(--gold-glow);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(212, 175, 55, 0.6);
}
.btn-secondary {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: rgba(255,255,255,0.15);
transform: translateY(-2px);
}
.btn-deep {
background: linear-gradient(135deg, #152238 0%, #0d1726 100%);
border: 1px solid var(--neon-cyan);
color: var(--neon-cyan);
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.1);
}
.btn-deep:hover:not(:disabled) {
background: var(--neon-cyan);
color: var(--text-dark);
box-shadow: 0 4px 20px rgba(0, 242, 254, 0.4);
transform: translateY(-2px);
}
.btn-danger {
background: linear-gradient(135deg, #7a1c1c 0%, #c0392b 100%);
color: var(--text-primary);
box-shadow: 0 4px 15px rgba(192, 57, 43, 0.2);
}
.btn-danger:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(192, 57, 43, 0.5);
}
.btn-gold {
background: transparent;
border: 2px solid var(--gold);
color: var(--gold);
box-shadow: 0 0 10px rgba(212,175,55,0.1);
}
.btn-gold:hover:not(:disabled) {
background: var(--gold);
color: var(--bg-dark);
box-shadow: 0 0 20px var(--gold-glow);
transform: translateY(-2px);
}
.btn-full {
width: 100%;
}
.btn-small {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.1rem;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.glow-effect:hover {
filter: brightness(1.2);
}
/* --- Event Log --- */
.event-log-fab {
position: fixed;
bottom: 20px;
right: 20px;
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--glass-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--gold);
color: var(--gold);
font-size: 1.5rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5), 0 0 10px rgba(212, 175, 55, 0.2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1000;
transition: var(--transition-smooth);
}
.event-log-fab:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6), 0 0 15px rgba(212, 175, 55, 0.4);
}
.event-log-panel {
position: fixed;
bottom: 90px;
right: 20px;
width: 350px;
max-width: calc(100vw - 40px);
height: 400px;
max-height: calc(100vh - 120px);
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
z-index: 999;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.event-log-panel.hidden {
transform: translateY(20px) scale(0.95);
opacity: 0;
pointer-events: none;
}
.event-log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.event-log-header h3 {
margin: 0;
font-family: var(--font-heading);
font-size: 1.1rem;
color: var(--gold);
}
.event-log-container {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.event-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.event-item {
font-size: 0.9rem;
background: rgba(0, 0, 0, 0.3);
padding: 0.75rem;
border-radius: 8px;
border-left: 3px solid var(--neon-cyan);
}
.event-time {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.event-msg {
color: var(--text-primary);
}

View File

@@ -1,231 +0,0 @@
/* --- Variables & Tokens --- */
:root {
--bg-dark: #070b12;
--bg-ocean: #0d1726;
--bg-ocean-light: #16243b;
--gold: #d4af37;
--gold-glow: rgba(212, 175, 55, 0.4);
--gold-dark: #aa841c;
--neon-cyan: #00f2fe;
--neon-emerald: #00ff87;
--red-suit: #ff2a5f;
--red-glow: rgba(255, 42, 95, 0.3);
--black-suit: #e2e8f0;
--black-glow: rgba(226, 232, 240, 0.2);
--glass-bg: rgba(13, 23, 38, 0.7);
--glass-bg-hover: rgba(22, 36, 59, 0.85);
--glass-border: rgba(212, 175, 55, 0.2);
--glass-border-focus: rgba(0, 242, 254, 0.4);
--text-primary: #f1f5f9;
--text-muted: #94a3b8;
--text-dark: #0f172a;
--font-heading: 'Cinzel', serif;
--font-body: 'Outfit', sans-serif;
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* --- Base Reset & Setup --- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-dark);
}
::-webkit-scrollbar-thumb {
background: var(--gold-dark);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--gold);
}
/* --- Layout Components --- */
.app-header {
background: rgba(7, 11, 18, 0.95);
border-bottom: 2px solid var(--gold);
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.logo-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-container h1 {
font-family: var(--font-heading);
font-size: 1.8rem;
font-weight: 900;
letter-spacing: 2px;
background: linear-gradient(135deg, var(--gold) 30%, #fff 70%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 10px var(--gold-glow);
}
.math-magic {
font-family: var(--font-heading);
font-size: 2.2rem;
color: var(--neon-cyan);
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
animation: pulse-glow 3s infinite ease-in-out;
}
.header-status {
display: flex;
align-items: center;
gap: 1rem;
}
.player-pill {
background: rgba(0, 242, 254, 0.1);
border: 1px solid var(--neon-cyan);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.5rem;
}
.player-pill .bullet {
width: 8px;
height: 8px;
background-color: var(--neon-emerald);
border-radius: 50%;
box-shadow: 0 0 8px var(--neon-emerald);
}
.phase-pill {
background: rgba(212, 175, 55, 0.1);
border: 1px solid var(--gold);
color: var(--gold);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 1px;
}
.app-main {
flex: 1;
padding: 2rem;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
.app-footer {
background: var(--bg-dark);
padding: 1.5rem;
text-align: center;
border-top: 1px solid rgba(255,255,255,0.05);
font-size: 0.85rem;
color: var(--text-muted);
}
/* --- Loader / Spinner widgets --- */
.loader-container {
padding: 4rem;
}
.spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(0, 242, 254, 0.1);
border-radius: 50%;
border-top-color: var(--neon-cyan);
animation: spin 1s ease-in-out infinite;
margin: 0 auto 1.5rem;
}
.spinner-small {
width: 20px;
height: 20px;
border: 2px solid rgba(255,255,255,0.1);
border-radius: 50%;
border-top-color: var(--gold);
animation: spin 1s ease-in-out infinite;
display: inline-block;
}
/* --- Alert styles --- */
.alert {
padding: 0.75rem 1.25rem;
margin-bottom: 1.5rem;
border-radius: 8px;
text-align: center;
font-weight: 500;
}
.alert-danger {
background: rgba(192, 57, 43, 0.2);
border: 1px solid #c0392b;
color: #e74c3c;
}
/* --- Animation keyframes --- */
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse-glow {
0% {
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
transform: scale(1);
}
50% {
text-shadow: 0 0 15px var(--neon-cyan), 0 0 30px var(--neon-cyan), 0 0 40px var(--neon-cyan);
transform: scale(1.05);
}
100% {
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
transform: scale(1);
}
}
/* --- Utilities --- */
.text-center { text-align: center; }
.text-left { text-align: left; }
.margin-top { margin-top: 1.5rem; }
.gold-text { color: var(--gold); }
.center-block { margin: 1rem auto; max-width: 500px; }
.inline-form { display: flex; gap: 0.5rem; width: 100%; }
.inline-group { display: flex; gap: 0.5rem; width: 100%; }
.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; }
.select-xsmall { padding: 0.4rem; font-size: 0.85rem; }
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }

View File

@@ -1,106 +0,0 @@
/* --- Lobby / Hold View --- */
.lobby-view {
max-width: 700px;
margin: 2rem auto;
}
.lobby-view h2 {
font-family: var(--font-heading);
font-size: 2.2rem;
color: var(--gold);
margin-bottom: 0.5rem;
}
.lobby-status-box {
margin: 2rem 0;
}
.player-chips {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
}
.player-chip {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
padding: 0.5rem 1.25rem;
border-radius: 30px;
display: flex;
align-items: center;
gap: 0.5rem;
transition: var(--transition-smooth);
}
.player-chip.current-user {
background: rgba(0, 242, 254, 0.15);
border-color: var(--neon-cyan);
box-shadow: 0 0 10px rgba(0,242,254,0.15);
}
.player-chip.voted-chip {
border-color: var(--neon-emerald);
background-color: rgba(0,255,135,0.05);
}
.player-chip.not-voted-chip {
border-color: rgba(255,255,255,0.1);
}
.creator-badge {
background: var(--gold);
color: var(--text-dark);
font-size: 0.7rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 4px;
text-transform: uppercase;
}
.links-box {
text-align: left;
margin-bottom: 2rem;
}
.link-item {
margin-bottom: 1.5rem;
}
.link-item h4 {
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.copy-container {
display: flex;
gap: 0.5rem;
}
.copy-input {
flex: 1;
background: #05080e;
border: 1px solid var(--glass-border);
color: var(--text-primary);
padding: 0.5rem 1rem;
border-radius: 8px;
font-family: var(--font-body);
font-size: 0.95rem;
}
.copy-input.admin-input {
border-color: var(--gold-dark);
color: var(--gold);
}
.admin-link-item {
border-top: 1px dashed rgba(212, 175, 55, 0.3);
padding-top: 1.5rem;
}
.info-text {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}

View File

@@ -1,277 +0,0 @@
/* --- Scene Play Board Layout --- */
.scene-view-layout {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 2rem;
}
@media (max-width: 1100px) {
.scene-view-layout {
grid-template-columns: 1fr;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.card-header h3 {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.deck-counter {
font-size: 0.85rem;
color: var(--gold);
font-weight: 700;
}
/* --- Active Obstacle Item Display --- */
.obstacles-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.obstacle-item {
border: 1px solid var(--glass-border);
border-radius: 10px;
padding: 1.25rem;
background: rgba(7, 11, 18, 0.4);
display: grid;
grid-template-columns: 0.8fr 2fr 1fr 1fr;
gap: 1rem;
position: relative;
overflow: hidden;
}
@media (max-width: 768px) {
.obstacle-item {
grid-template-columns: 0.8fr 2fr 1.5fr;
}
}
@media (max-width: 600px) {
.obstacle-item {
grid-template-columns: 1fr;
}
}
/* Color theme mappings for suits */
.suit-c { border-left: 4px solid var(--black-suit); }
.suit-s { border-left: 4px solid var(--black-suit); }
.suit-h { border-left: 4px solid var(--red-suit); }
.suit-d { border-left: 4px solid var(--red-suit); }
.obstacle-meta {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(0,0,0,0.3);
border-radius: 6px;
padding: 0.5rem;
}
.suit-badge {
font-weight: 900;
font-size: 1.1rem;
}
.suit-c .suit-badge, .suit-s .suit-badge { color: var(--black-suit); }
.suit-h .suit-badge, .suit-d .suit-badge { color: var(--red-suit); }
.card-code {
font-size: 0.8rem;
color: var(--text-muted);
font-family: var(--font-heading);
}
.obstacle-details h4 {
font-family: var(--font-heading);
color: var(--text-primary);
margin-bottom: 0.25rem;
font-size: 1.1rem;
}
.obstacle-details .desc {
font-size: 0.85rem;
color: var(--text-muted);
}
.obstacle-value-display {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(212, 175, 55, 0.04);
border: 1px dashed var(--glass-border);
border-radius: 6px;
padding: 0.5rem;
}
.obstacle-successes-display {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(0, 255, 135, 0.04);
border: 1px dashed rgba(0, 255, 135, 0.25);
border-radius: 6px;
padding: 0.5rem;
}
.obstacle-successes-display .val-number {
color: var(--neon-emerald);
}
.val-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
}
.val-number {
font-size: 1.8rem;
font-family: var(--font-heading);
font-weight: 900;
color: var(--gold);
}
.played-column {
grid-column: span 4;
border-top: 1px solid rgba(255,255,255,0.05);
padding-top: 0.75rem;
margin-top: 0.5rem;
}
@media (max-width: 600px) {
.played-column {
grid-column: span 1;
}
}
.played-column h5 {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.column-cards-flex {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
/* --- Challenges Section --- */
.challenges-container {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.challenge-item {
background: rgba(255,255,255,0.02);
border: 1px solid var(--glass-border);
border-radius: 8px;
padding: 1.25rem;
text-align: left;
}
.challenge-item h4 {
font-family: var(--font-heading);
color: var(--neon-cyan);
font-size: 1.2rem;
margin-bottom: 0.25rem;
}
.challenge-item .desc {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.applied-obstacles h5 {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.applied-obs-row {
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.obs-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.obs-info .symbol {
font-size: 1.2rem;
}
.play-card-form {
display: flex;
gap: 0.5rem;
align-items: center;
}
.select-xsmall {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
width: 140px;
background: var(--bg-dark);
}
/* --- Deep Control panel --- */
.deep-text {
color: var(--neon-cyan) !important;
}
.deep-divider {
border: 0;
height: 1px;
background: linear-gradient(to right, rgba(0, 242, 254, 0), rgba(0, 242, 254, 0.4), rgba(0, 242, 254, 0));
margin: 2rem 0;
}
.obstacles-checkboxes {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: rgba(0,0,0,0.2);
padding: 1rem;
border-radius: 8px;
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--glass-border);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.95rem;
}
.hand-flex {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}

View File

@@ -1,83 +0,0 @@
/* --- Scene Setup / Role Choose --- */
.setup-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 2rem;
margin: 2rem 0;
}
@media (max-width: 800px) {
.setup-grid {
grid-template-columns: 1fr;
}
}
.role-buttons {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 1.5rem;
}
.role-btn {
padding: 1.5rem;
font-size: 1.2rem;
background: rgba(255,255,255,0.03);
border: 2px solid rgba(255,255,255,0.1);
color: var(--text-muted);
}
.role-btn.pirat-btn:hover, .role-btn.pirat-btn.active {
border-color: var(--neon-emerald);
color: var(--text-primary);
background-color: rgba(0,255,135,0.06);
box-shadow: 0 0 15px rgba(0,255,135,0.15);
}
.role-btn.deep-btn:hover, .role-btn.deep-btn.active {
border-color: var(--neon-cyan);
color: var(--text-primary);
background-color: rgba(0,242,254,0.06);
box-shadow: 0 0 15px rgba(0,242,254,0.15);
}
.large-badge {
padding: 0.5rem 2rem;
font-size: 1.5rem;
border-radius: 8px;
font-family: var(--font-heading);
margin-top: 0.5rem;
}
.role-badge.deep {
background: rgba(0, 242, 254, 0.15);
border: 2px solid var(--neon-cyan);
color: var(--neon-cyan);
box-shadow: 0 0 15px rgba(0,242,254,0.1);
}
.role-badge.pirat {
background: rgba(0, 255, 135, 0.15);
border: 2px solid var(--neon-emerald);
color: var(--neon-emerald);
box-shadow: 0 0 15px rgba(0,255,135,0.1);
}
.list-chips {
justify-content: flex-start;
}
.list-chips .player-chip {
padding: 0.4rem 1rem;
font-size: 0.9rem;
}
.deep-chip {
border-color: var(--neon-cyan);
background: rgba(0, 242, 254, 0.03);
}
.pirat-chip {
border-color: var(--neon-emerald);
background: rgba(0, 255, 135, 0.03);
}

View File

@@ -1,18 +0,0 @@
/* ==========================================================================
RATS WITH GATS DESIGN SYSTEM & STYLESHEET
A weathered math-magic pirate aesthetic: deep-ocean dark mode, glassmorphism,
neon math runes, gold accents, and animated card widgets.
This file imports modular stylesheets divided by page phase and components.
========================================================================== */
@import url("core.css");
@import url("components.css");
@import url("card.css");
@import url("welcome.css");
@import url("lobby.css");
@import url("character.css");
@import url("scene-setup.css");
@import url("scene-play.css");
@import url("upkeep.css");
@import url("admin.css");

View File

@@ -1,100 +0,0 @@
/* --- Between Scenes Upkeep --- */
.between-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin: 2rem 0;
}
@media (max-width: 800px) {
.between-grid {
grid-template-columns: 1fr;
}
}
.voting-card, .deep-rest-card {
text-align: left;
}
.voted-confirmation-box {
padding: 1.5rem;
text-align: center;
border: 1px solid var(--neon-emerald);
}
.success-text {
color: var(--neon-emerald);
font-weight: 700;
}
.ranks-ledger {
list-style: none;
}
.ranks-ledger li {
padding: 0.5rem;
border-bottom: 1px solid rgba(255,255,255,0.05);
display: flex;
justify-content: space-between;
}
.deep-badge {
background: rgba(0, 242, 254, 0.15);
border: 1px solid var(--neon-cyan);
color: var(--neon-cyan);
padding: 0.1rem 0.4rem;
font-size: 0.75rem;
border-radius: 4px;
}
.pirat-badge {
background: rgba(0, 255, 135, 0.15);
border: 1px solid var(--neon-emerald);
color: var(--neon-emerald);
padding: 0.1rem 0.4rem;
font-size: 0.75rem;
border-radius: 4px;
}
/* --- Deep Upkeep Hand Refresh Drag & Drop --- */
.upkeep-drag-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin: 2rem 0;
}
@media (max-width: 768px) {
.upkeep-drag-container {
grid-template-columns: 1fr;
}
}
.upkeep-box {
min-height: 400px;
display: flex;
flex-direction: column;
}
.upkeep-box h3 {
margin-bottom: 1rem;
font-family: var(--font-heading);
}
.upkeep-flex {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.3);
border: 1px dashed var(--glass-border);
border-radius: 8px;
align-content: flex-start;
min-height: 300px;
transition: var(--transition-smooth);
}
.upkeep-box[ondragover]:hover .upkeep-flex {
border-color: var(--neon-cyan);
}

View File

@@ -1,50 +0,0 @@
/* --- Welcome / Main Menu Screen --- */
.welcome-container {
max-width: 900px;
margin: 4rem auto;
}
.welcome-hero h2 {
font-family: var(--font-heading);
font-size: 2.5rem;
color: var(--text-primary);
margin-bottom: 1rem;
}
.welcome-hero .subtitle {
font-size: 1.2rem;
color: var(--text-muted);
margin-bottom: 3rem;
}
.welcome-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
@media (max-width: 768px) {
.welcome-actions {
grid-template-columns: 1fr;
}
}
.action-card {
padding: 2.5rem 1.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 300px;
}
.action-card h3 {
font-family: var(--font-heading);
color: var(--gold);
font-size: 1.5rem;
margin-bottom: 0.75rem;
}
.action-card p {
color: var(--text-muted);
margin-bottom: 2rem;
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import json
from sqlmodel import SQLModel, create_engine, Session from sqlmodel import SQLModel, create_engine, Session
from pirats import cards from pirats import cards
from pirats import crud from pirats import crud
from pirats.models import Game, Player, Obstacle from pirats.models import Obstacle
# In-memory database for testing # In-memory database for testing
@pytest.fixture(name="session") @pytest.fixture(name="session")
@@ -201,7 +201,6 @@ def test_assistant_does_not_draw(session):
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id]) ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
assert ok assert ok
session.refresh(game) session.refresh(game)
challenge = game.challenges[0]
# p3 assists: plays a color-matching card but must NOT draw back # p3 assists: plays a color-matching card but must NOT draw back
ok, msg, res = crud.play_challenge_card(session, p3.id, obs.id, helper_card) ok, msg, res = crud.play_challenge_card(session, p3.id, obs.id, helper_card)
@@ -409,7 +408,6 @@ def test_create_game_endpoint():
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, create_engine, Session, select from sqlmodel import SQLModel, create_engine, Session, select
from pirats.main import app from pirats.main import app
from pirats import crud
# Setup in-memory DB # Setup in-memory DB
engine = create_engine( engine = create_engine(
@@ -963,7 +961,7 @@ def test_submit_techniques_rejects_collisions(session):
def test_roll_new_character_smell_name_normalization(session): def test_roll_new_character_smell_name_normalization(session):
game = crud.create_game(session) game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1") p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2") crud.add_player(session, game.id, "P2")
p1.is_dead = True p1.is_dead = True
session.add(p1) session.add(p1)
session.commit() session.commit()
@@ -1009,3 +1007,115 @@ def test_roll_new_character_avoids_technique_collisions(session):
session.refresh(p1) session.refresh(p1)
recruit_techs = {p1.tech_jack, p1.tech_queen, p1.tech_king} recruit_techs = {p1.tech_jack, p1.tech_queen, p1.tech_king}
assert recruit_techs == set(original[3:6]) assert recruit_techs == set(original[3:6])
def test_reshuffle_keeps_open_pvp_temp_card_out_of_deck(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
p2.hand_cards = json.dumps(["7C"])
session.add(p2)
session.commit()
ok, msg = crud.create_pvp_challenge(session, game.id, p2.id, p3.id, "7C")
assert ok, msg
# A mid-duel reshuffle must not pull the temporary Obstacle back into the deck
session.refresh(game)
crud.reshuffle_discard_pile(session, game)
assert "7C" not in crud.get_game_deck(game)
# Once the duel resolves, the card is discarded and reshuffles normally
duel = [c for c in game.challenges if c.challenge_type == "pvp"][0]
p3.hand_cards = json.dumps(["9C"])
session.add(p3)
session.commit()
ok, _, _ = crud.play_pvp_defense(session, duel.id, p3.id, "9C")
assert ok
crud.reshuffle_discard_pile(session, game)
assert "7C" in crud.get_game_deck(game)
def test_late_joiner_is_dealt_a_hand(session):
game, deep, (p2,) = make_scene_game(session)
assert game.phase == "scene"
newbie = crud.add_player(session, game.id, "Stowaway")
assert newbie.role == "pirat"
session.refresh(game)
expected = crud.calculate_max_hand_size(newbie, game.players, game.captain_player_id)
assert expected > 0
assert len(crud.get_player_hand(newbie)) == expected
def test_technique_suggestions_endpoint():
from fastapi.testclient import TestClient
from pirats.main import app
client = TestClient(app, base_url="http://testserver")
response = client.get("/api/suggestions/techniques")
assert response.status_code == 200
techs = response.json()["techniques"]
assert techs == cards.TECHNIQUE_SUGGESTIONS
assert len(techs) >= 100
def test_admin_key_returned_at_create_but_hidden_from_state():
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, create_engine, Session
from pirats.main import app
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
def get_session_override():
yield session
from pirats.database import get_session
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app, base_url="http://testserver")
try:
created = client.post("/api/game", data={"crew_name": "Leak Test"}).json()
assert created["admin_key"]
game_id = created["id"]
player_id = client.post(f"/api/game/{game_id}/join", data={"name": "Carlos"}).json()["id"]
state = client.get(f"/api/game/{game_id}/player/{player_id}/state").json()
assert "admin_key" not in state["game"]
finally:
app.dependency_overrides.clear()
def test_event_log_pagination(session):
from pirats.models import GameEvent
game = crud.create_game(session)
for i in range(120):
# Explicit timestamps keep ordering deterministic
session.add(GameEvent(game_id=game.id, timestamp=1000.0 + i, message=f"event {i}", kind="info"))
session.commit()
# Newest page
events, have_more = crud.get_events_page(session, game.id, limit=50)
assert have_more
assert len(events) == 50
assert events[0].message == "event 70"
assert events[-1].message == "event 119"
# Page back from the oldest loaded event
older, have_more = crud.get_events_page(session, game.id, before=events[0].timestamp, limit=50)
assert have_more
assert older[0].message == "event 20"
assert older[-1].message == "event 69"
# Final page is short and reports no more history
oldest, have_more = crud.get_events_page(session, game.id, before=older[0].timestamp, limit=50)
assert not have_more
assert len(oldest) == 20
assert oldest[0].message == "event 0"
assert oldest[-1].message == "event 19"
# Exact page boundary still reports no remaining history
exact, have_more = crud.get_events_page(session, game.id, before=1020.0, limit=20)
assert not have_more
assert [e.message for e in exact] == [f"event {i}" for i in range(20)]