Fable will fix it! Pt 2
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
.venv
|
||||
rats_with_gats.db
|
||||
smoke_test.db
|
||||
result
|
||||
*.pdf
|
||||
# Built Svelte frontend (copied in from frontend/dist by the Nix build)
|
||||
src/pirats/static/
|
||||
|
||||
38
README.md
38
README.md
@@ -1,8 +1,8 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -126,15 +126,29 @@ nix develop --command pytest
|
||||
```
|
||||
pirats/
|
||||
├── pyproject.toml # Python package metadata and dependencies
|
||||
├── flake.nix # Nix package, dev shell, and NixOS service module
|
||||
├── tests/ # Unit and integration tests
|
||||
│ └── test_game.py # Game logic and CRUD database test suites
|
||||
├── flake.nix # Nix package (incl. frontend build), dev shell, NixOS service module
|
||||
├── tests/
|
||||
│ └── 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/
|
||||
└── pirats/ # Core Python package
|
||||
├── main.py # FastAPI routes, HTTP endpoints, CLI entrypoint
|
||||
├── crud.py # Core database CRUD, game mechanics, and transitions
|
||||
├── models.py # SQLModel database tables (Game, Player, Obstacle, Challenge, Vote)
|
||||
├── cards.py # Playing card parser, deck setups, and obstacle definitions
|
||||
├── templates/ # Jinja2 HTML pages and HTMX snippets
|
||||
└── static/ # CSS styles and frontend assets
|
||||
└── pirats/ # FastAPI backend package
|
||||
├── main.py # App setup, /api/game create/join/state endpoints, CLI entrypoint
|
||||
├── models.py # SQLModel tables (Game, Player, Obstacle, Challenge, Vote, GameEvent)
|
||||
├── cards.py # Card parsing, deck building, obstacle table from the rulebook
|
||||
├── crud.py # Facade re-exporting all crud_* modules
|
||||
├── crud_*.py # Game logic by phase: base (deck/hands/rank), character, scene, challenge, upkeep
|
||||
├── 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 |
@@ -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 |
52
frontend/src/components/Card.svelte
Normal file
52
frontend/src/components/Card.svelte
Normal 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}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { getSuggestion, getSuggestions } from '../lib/suggestions';
|
||||
import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions';
|
||||
|
||||
export let state;
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
// grabbed the same name first (the backend rejects collisions)
|
||||
if (createdTechniques.length === 0) {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
[tech1, tech2, tech3] = getSuggestions('techniques', 3);
|
||||
[tech1, tech2, tech3] = await getTechniqueSuggestions(3);
|
||||
await submitTechniques();
|
||||
if (!techError) break;
|
||||
}
|
||||
@@ -313,21 +313,21 @@
|
||||
<label for="tech1">Technique #1:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech1 = getSuggestion('techniques', [tech2, tech3])}>Suggest</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech1 = await getTechniqueSuggestion([tech2, tech3])}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tech2">Technique #2:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech2 = getSuggestion('techniques', [tech1, tech3])}>Suggest</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech2 = await getTechniqueSuggestion([tech1, tech3])}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tech3">Technique #3:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech3 = getSuggestion('techniques', [tech1, tech2])}>Suggest</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
||||
|
||||
280
frontend/src/components/EventLog.svelte
Normal file
280
frontend/src/components/EventLog.svelte
Normal 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>
|
||||
@@ -1,231 +1,17 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
import Card from './Card.svelte';
|
||||
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||
import DeepControlPanel from './scene/DeepControlPanel.svelte';
|
||||
import CharacterSheet from './scene/CharacterSheet.svelte';
|
||||
import EventLog from './EventLog.svelte';
|
||||
|
||||
export let state;
|
||||
|
||||
let playingCard = false;
|
||||
let error = '';
|
||||
|
||||
// Helpers
|
||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
|
||||
$: isCaptain = state.game.captain_player_id === state.player.id;
|
||||
$: challenges = state.challenges || [];
|
||||
$: openChallenges = challenges.filter(c => c.status === 'open');
|
||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||
$: myOpenChallenge = openChallenges.find(c => c.acting_player_id === state.player.id) || null;
|
||||
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
|
||||
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
|
||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||
|
||||
function playerName(id) {
|
||||
return state.players.find(p => p.id === id)?.name || '???';
|
||||
}
|
||||
|
||||
function isJoker(code) {
|
||||
return !!code && code.startsWith('Joker');
|
||||
}
|
||||
|
||||
async function playCard(obstacleId, cardCode) {
|
||||
playingCard = true;
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
|
||||
obstacle_id: obstacleId,
|
||||
card_code: cardCode
|
||||
});
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
playingCard = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function playJoker(obstacleId, cardCode) {
|
||||
playingCard = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-joker`, 'POST', {
|
||||
obstacle_id: obstacleId,
|
||||
card_code: cardCode
|
||||
});
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
playingCard = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function endScene() {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearObstacle(obstacleId) {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleObjective(targetPlayerId, type) {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', {
|
||||
type: type
|
||||
});
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Challenge controls (Deep) ---
|
||||
let challengeTargetId = '';
|
||||
let challengeStakes = '';
|
||||
let selectedObstacles = {};
|
||||
|
||||
async function createChallenge() {
|
||||
error = '';
|
||||
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', {
|
||||
target_player_id: challengeTargetId,
|
||||
obstacle_ids: JSON.stringify(ids),
|
||||
stakes: challengeStakes
|
||||
});
|
||||
challengeTargetId = '';
|
||||
challengeStakes = '';
|
||||
selectedObstacles = {};
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveChallenge(challengeId) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/resolve`, 'POST');
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelChallenge(challengeId) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/cancel`, 'POST');
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Captain controls (Deep / current Captain) ---
|
||||
let captainSelectId = '';
|
||||
async function setCaptain() {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-captain`, 'POST', {
|
||||
target_player_id: captainSelectId
|
||||
});
|
||||
captainSelectId = '';
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gat/Name Tax ---
|
||||
let taxTargetId = '';
|
||||
function taxType(player) {
|
||||
if (!player.completed_personal_1) return 'Gat';
|
||||
if (!player.completed_personal_2) return 'Name';
|
||||
return null;
|
||||
}
|
||||
function eligibleTaxTargets(challenge) {
|
||||
const me = state.player;
|
||||
const type = taxType(me);
|
||||
if (!type) return [];
|
||||
return scenePirats.filter(p =>
|
||||
p.id !== me.id && !p.is_dead &&
|
||||
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
|
||||
);
|
||||
}
|
||||
async function requestTax(challengeId) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/request`, 'POST', {
|
||||
target_player_id: taxTargetId
|
||||
});
|
||||
taxTargetId = '';
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
async function respondTax(challengeId, accept) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/respond`, 'POST', {
|
||||
accept: accept ? 'true' : 'false'
|
||||
});
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pi-Rat vs Pi-Rat duels ---
|
||||
let pvpOpponentId = '';
|
||||
let pvpCard = '';
|
||||
async function createPvp() {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
|
||||
defender_id: pvpOpponentId,
|
||||
card_code: pvpCard
|
||||
});
|
||||
pvpOpponentId = '';
|
||||
pvpCard = '';
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
async function pvpDefend(challengeId, cardCode) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/pvp/defend`, 'POST', {
|
||||
card_code: cardCode
|
||||
});
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
let showEventLog = false;
|
||||
|
||||
let newNameInput = '';
|
||||
async function submitNewName() {
|
||||
if (!newNameInput.trim()) return;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
|
||||
new_name: newNameInput.trim()
|
||||
});
|
||||
newNameInput = '';
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Parsing card helpers
|
||||
function getCardDisplay(code) {
|
||||
if (!code) return "";
|
||||
if (isJoker(code)) return "🃏 JOKER";
|
||||
let rank = code.slice(0, -1);
|
||||
let suit = code.slice(-1);
|
||||
let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit;
|
||||
return `${rank}${suitEmoji}`;
|
||||
}
|
||||
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
||||
</script>
|
||||
|
||||
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||
@@ -274,280 +60,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- OPEN CHALLENGES -->
|
||||
{#each openChallenges as ch}
|
||||
{@const chPlays = JSON.parse(ch.plays || '[]')}
|
||||
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
|
||||
<div class="glass-panel" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red-suit, #ff2a5f); border-radius: 8px; background: rgba(255, 42, 95, 0.06);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h4 style="margin: 0; font-family: var(--font-heading);">
|
||||
{#if ch.challenge_type === 'pvp'}
|
||||
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
|
||||
{:else}
|
||||
⚔️ The Deep challenges {playerName(ch.target_player_id)}!
|
||||
{/if}
|
||||
</h4>
|
||||
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn btn-primary" on:click={() => resolveChallenge(ch.id)} disabled={ch.tax_state === 'requested'}>Resolve</button>
|
||||
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ch.stakes}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
|
||||
{/if}
|
||||
{#if ch.challenge_type === 'pvp'}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||
Temporary Obstacle: <strong>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
||||
</p>
|
||||
{:else}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||
Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'}
|
||||
— attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
|
||||
Other Pi-Rats may assist (assistants don't draw back).
|
||||
</p>
|
||||
{/if}
|
||||
{#if chPlays.length > 0}
|
||||
<p class="info-text" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;">
|
||||
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Tax: pending request aimed at me -->
|
||||
{#if ch.tax_state === 'requested'}
|
||||
{#if myTaxRequest && myTaxRequest.id === ch.id}
|
||||
<div style="margin-top: 0.75rem; padding: 0.75rem; border: 1px dashed var(--gold); border-radius: 6px;">
|
||||
<p style="margin: 0 0 0.5rem 0;"><strong>{playerName(ch.target_player_id)}</strong> calls a <strong>{ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax</strong> on you: take this Challenge for them!</p>
|
||||
<p class="info-text" style="font-size: 0.85rem;">Accept: you get one random card from their hand and attempt the Challenge. Refuse: you hand over your {ch.tax_type === 'gat' ? 'Gat' : 'Name'} — they keep it if they succeed!</p>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
|
||||
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="info-text gold-text" style="margin-top: 0.5rem;">⏳ Waiting for {playerName(ch.tax_target_id)} to answer the {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax...</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Tax: option for the challenged player -->
|
||||
{#if ch.challenge_type === 'deep' && ch.acting_player_id === state.player.id && !ch.tax_state && !state.player.tax_banned && taxType(state.player) && eligibleTaxTargets(ch).length > 0}
|
||||
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<span class="info-text" style="font-size: 0.9rem;">No {taxType(state.player)}? Make it someone else's problem:</span>
|
||||
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
|
||||
<option value="">Pick a crewmate...</option>
|
||||
{#each eligibleTaxTargets(ch) as p}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- PvP: defender picks a card -->
|
||||
{#if myPvpDefense && myPvpDefense.id === ch.id}
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
{#each hand.filter(c => !isJoker(c)) as card}
|
||||
<button class="btn btn-secondary" on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each state.obstacles as obs}
|
||||
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
|
||||
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card}
|
||||
{@const success_count = column_cards.filter(c => c.success).length}
|
||||
{@const is_completed = success_count >= state.players.length}
|
||||
{@const in_challenge = challengedObstacleIds.has(obs.id)}
|
||||
<div class="obstacle-item suit-{obs.suit.toLowerCase()} {is_completed ? 'completed' : ''}"
|
||||
style={in_challenge ? 'box-shadow: 0 0 0 2px var(--red-suit, #ff2a5f);' : ''}
|
||||
data-obstacle-id={obs.id}
|
||||
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
||||
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
||||
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
|
||||
on:drop={(e) => {
|
||||
if (is_completed) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove("drag-over");
|
||||
const cardCode = e.dataTransfer.getData('text/plain');
|
||||
if (cardCode) {
|
||||
if (isJoker(cardCode)) playJoker(obs.id, cardCode);
|
||||
else playCard(obs.id, cardCode);
|
||||
}
|
||||
}}>
|
||||
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
|
||||
<div class="card-medium suit-{active_card_code.slice(-1).toLowerCase()}" title="Active Card: {getCardDisplay(active_card_code)}">
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{active_card_code.slice(0, -1)}</span>
|
||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-center">
|
||||
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{active_card_code.slice(0, -1)}</span>
|
||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="obstacle-details">
|
||||
<h4>{obs.title} {#if in_challenge}<span style="color: var(--red-suit, #ff2a5f); font-size: 0.8rem;">⚔️ In Challenge</span>{/if}</h4>
|
||||
<p class="desc">{obs.description}</p>
|
||||
</div>
|
||||
|
||||
<!-- Value Display -->
|
||||
<div class="obstacle-value-display text-center">
|
||||
<span class="val-label">Current Difficulty</span>
|
||||
<span class="val-number">
|
||||
{#if ['J', 'Q', 'K'].includes((column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card).slice(0, -1))}
|
||||
Challenged Rat's Rank
|
||||
{:else}
|
||||
{obs.current_value}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Successes Tracker -->
|
||||
<div class="obstacle-successes-display text-center">
|
||||
<span class="val-label">Successes</span>
|
||||
<span class="val-number" style="font-size: 1.5rem;">
|
||||
{success_count} / {state.players.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Played Cards Column -->
|
||||
<div class="played-column">
|
||||
<h5>Card History (Column)</h5>
|
||||
<div class="column-cards-flex">
|
||||
<div class="card-mini base-card">
|
||||
<span class="val">{obs.original_card.slice(0, -1)}</span>
|
||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[obs.original_card.slice(-1)]}</span>
|
||||
</div>
|
||||
{#each column_cards as pc}
|
||||
<div class="card-mini played-card rotated {pc.success ? 'success' : 'failure'}"
|
||||
title="{pc.player_name} played {getCardDisplay(pc.card)}">
|
||||
<span class="val">{pc.card.slice(0, -1)}</span>
|
||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
|
||||
<span class="owner">{pc.player_name.slice(0,4)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if is_completed && state.player.role === 'deep'}
|
||||
<div class="text-center" style="margin-top: 1rem;">
|
||||
<button class="btn btn-success" on:click={() => clearObstacle(obs.id)} style="width: 100%; border: 1px solid rgba(255,255,255,0.2);">Clear Obstacle</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
||||
{/each}
|
||||
|
||||
<ChallengePanel {state} />
|
||||
<ObstacleBoard {state} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
||||
<div class="private-column">
|
||||
<!-- DEEP CONTROLS: Challenges, End Scene & Objectives -->
|
||||
{#if state.player.role === "deep"}
|
||||
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
|
||||
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
|
||||
|
||||
<!-- Call a Challenge -->
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">⚔️ Call a Challenge</h4>
|
||||
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
|
||||
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<option value="">Challenge which Pi-Rat?</option>
|
||||
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||
<option value={p.id}>{p.name} (Rank {p.rank})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div style="margin-bottom: 0.5rem;">
|
||||
{#each state.obstacles as obs}
|
||||
{@const taken = challengedObstacleIds.has(obs.id)}
|
||||
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
|
||||
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
|
||||
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<input type="text" class="form-control" placeholder="Stakes (optional): what happens on success/failure?" bind:value={challengeStakes} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<button class="btn btn-primary btn-full" on:click={createChallenge}
|
||||
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
|
||||
Call Challenge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Captain Assignment -->
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">🏴☠️ Captaincy</h4>
|
||||
<p class="info-text" style="font-size: 0.85rem;">The Captain holds power until they step down, lose a duel, die, or the ship is lost. Reassign after a leadership duel or resignation.</p>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<select class="select-field" bind:value={captainSelectId} style="flex: 1;">
|
||||
<option value="">No Captain</option>
|
||||
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn btn-secondary" on:click={setCaptain}>Set</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
||||
<div class="objectives-checklist">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_1} on:change={() => toggleObjective(state.player.id, 'crew_1')}>
|
||||
<span>Steal a Ship</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_2} on:change={() => toggleObjective(state.player.id, 'crew_2')}>
|
||||
<span>Choose a Captain</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_3} on:change={() => toggleObjective(state.player.id, 'crew_3')}>
|
||||
<span>Commit Piracy</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">Pi-Rat Personal Objectives</h4>
|
||||
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
|
||||
{#each state.players.filter(p => p.role === 'pirat') as p}
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
|
||||
<strong>{p.name}</strong>
|
||||
<div class="objectives-checklist" style="margin-top: 0.25rem;">
|
||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_1} on:change={() => toggleObjective(p.id, 'personal_1')}> <span>1. Gat</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_2} on:change={() => toggleObjective(p.id, 'personal_2')}> <span>2. Name</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_3} on:change={() => toggleObjective(p.id, 'personal_3')}> <span>3. Die/Retire</span></label>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- End Scene Button -->
|
||||
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
|
||||
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
|
||||
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
|
||||
End Scene & Proceed to Ranking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DeepControlPanel {state} />
|
||||
{/if}
|
||||
|
||||
<!-- Player Hand Card -->
|
||||
@@ -557,7 +79,7 @@
|
||||
|
||||
<div class="hand-flex">
|
||||
{#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"}
|
||||
on:dragstart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', card);
|
||||
@@ -565,211 +87,17 @@
|
||||
}}
|
||||
on:dragend={(e) => {
|
||||
e.currentTarget.classList.remove("dragging");
|
||||
}}>
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-center">
|
||||
{#if isJoker(card)}
|
||||
🃏
|
||||
{:else}
|
||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-tech-overlay">
|
||||
{#if !isJoker(card) && card.slice(0, -1) === "J"}
|
||||
<div class="tech-tag" title="Automatic success when played">J: "{state.player.tech_jack}"</div>
|
||||
{:else if !isJoker(card) && card.slice(0, -1) === "Q"}
|
||||
<div class="tech-tag" title="Automatic success when played">Q: "{state.player.tech_queen}"</div>
|
||||
{:else if !isJoker(card) && card.slice(0, -1) === "K"}
|
||||
<div class="tech-tag" title="Automatic success when played">K: "{state.player.tech_king}"</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
}} />
|
||||
{:else}
|
||||
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Character Sheet Card -->
|
||||
{#if state.player.role !== "deep"}
|
||||
<div class="card glass-panel sheet-card">
|
||||
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
||||
|
||||
<div class="character-sheet-details">
|
||||
{#if state.player.is_dead}
|
||||
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.1); border: 1px dashed var(--red-suit); padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
||||
<h4 style="color: var(--red-suit); margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">💀 You have Died / Retired!</h4>
|
||||
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">Your story arc is complete. You will choose to become a Ghost or roll a new character during the upkeep phase between scenes.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.is_ghost}
|
||||
<div class="sheet-group prompt-box" style="background: rgba(120, 150, 180, 0.1); border: 1px dashed #7890a8; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
||||
<h4 style="color: #a0b0c0; margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">👻 Playing as a Ghost</h4>
|
||||
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You are in the Ghost World (grayscale view). You cannot interact well with the living but you can still help them resolve challenges!</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.needs_name}
|
||||
<div class="sheet-group prompt-box" style="background: rgba(212, 175, 55, 0.1); border: 1px dashed var(--gold); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
|
||||
<h4 style="color: var(--gold); margin-top: 0;">🏴☠️ You Earned a Name!</h4>
|
||||
<p class="info-text" style="margin-bottom: 0.5rem;">You completed your second objective and ranked up! What is your new Pirate Name?</p>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" bind:value={newNameInput} class="form-control" placeholder="Enter new name...">
|
||||
<button class="btn btn-gold" on:click={submitNewName} disabled={!newNameInput.trim()}>Claim Name</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.tax_banned}
|
||||
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.08); border: 1px dashed var(--red-suit); padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
||||
<p class="info-text" style="margin: 0; font-size: 0.9rem;">🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sheet-group">
|
||||
<h4>Description:</h4>
|
||||
<p><strong>Look:</strong> {state.player.avatar_look}</p>
|
||||
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
|
||||
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
||||
<ul class="techniques-list-sheet">
|
||||
<li><strong>Jack (J):</strong> "{state.player.tech_jack}"</li>
|
||||
<li><strong>Queen (Q):</strong> "{state.player.tech_queen}"</li>
|
||||
<li><strong>King (K):</strong> "{state.player.tech_king}"</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Duel another Pi-Rat -->
|
||||
{#if !state.player.is_dead && scenePirats.filter(p => p.id !== state.player.id && !p.is_dead).length > 0}
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">⚔️ Duel a Pi-Rat</h4>
|
||||
<p class="info-text" style="font-size: 0.85rem;">Challenge a crewmate (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
|
||||
<select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<option value="">Challenge whom?</option>
|
||||
{#each scenePirats.filter(p => p.id !== state.player.id && !p.is_dead) as p}
|
||||
<option value={p.id}>{p.name} (Rank {p.rank})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<option value="">Throw which card?</option>
|
||||
{#each hand.filter(c => !isJoker(c)) as card}
|
||||
<option value={card}>{getCardDisplay(card)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
||||
<div class="objectives-checklist">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
|
||||
<span>Steal a Ship</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
|
||||
<span>Choose a Captain</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
|
||||
<span>Commit Piracy</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">Personal Objectives</h4>
|
||||
<div class="objectives-checklist">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.player.completed_personal_1} disabled>
|
||||
<span>1. Gat</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.player.completed_personal_2} disabled>
|
||||
<span>2. Name</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.player.completed_personal_3} disabled>
|
||||
<span>3. Die/Retire</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CharacterSheet {state} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Event Log -->
|
||||
<div class="floating-event-log {showEventLog ? 'open' : ''}">
|
||||
<button class="toggle-log-btn" on:click={() => showEventLog = !showEventLog}>
|
||||
{showEventLog ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||
</button>
|
||||
|
||||
{#if showEventLog}
|
||||
<div class="log-content font-mono text-sm space-y-2 p-2">
|
||||
{#each state.events as event}
|
||||
<div class="p-2 border-l-2 border-gray-600 bg-black text-gray-400" style="border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 0.25rem;">
|
||||
{event.message}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-2 text-gray-500">No events yet.</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.floating-event-log {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 350px;
|
||||
background: rgba(10, 15, 25, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 60vh;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.toggle-log-btn {
|
||||
background: var(--dark-bg, #1a1a2e);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.toggle-log-btn:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.log-content {
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
.floating-event-log:not(.open) {
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
<EventLog {state} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { getSuggestion } from '../lib/suggestions';
|
||||
import Card from './Card.svelte';
|
||||
|
||||
export let state;
|
||||
|
||||
@@ -319,28 +320,10 @@
|
||||
<h4 class="gold-text">Hand (Keep)</h4>
|
||||
<div class="upkeep-flex">
|
||||
{#each keepCards as card}
|
||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}"
|
||||
draggable="true"
|
||||
<Card {card} draggable={true} style="cursor: pointer;"
|
||||
title="Click or drag to discard"
|
||||
on:dragstart={(e) => handleDragStart(e, card)}
|
||||
on:click={() => moveToDiscard(card)}
|
||||
style="cursor: pointer;"
|
||||
title="Click or drag to discard">
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
<div class="card-center">
|
||||
{#if card.startsWith('Joker')}
|
||||
🃏
|
||||
{:else}
|
||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
on:click={() => moveToDiscard(card)} />
|
||||
{:else}
|
||||
<p class="empty-text text-center w-full">No cards in hand.</p>
|
||||
{/each}
|
||||
@@ -354,28 +337,10 @@
|
||||
<h4 class="gold-text">Discard Pile</h4>
|
||||
<div class="upkeep-flex">
|
||||
{#each discardCards as card}
|
||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}"
|
||||
draggable="true"
|
||||
<Card {card} draggable={true} style="cursor: pointer;"
|
||||
title="Click or drag to keep"
|
||||
on:dragstart={(e) => handleDragStart(e, card)}
|
||||
on:click={() => moveToKeep(card)}
|
||||
style="cursor: pointer;"
|
||||
title="Click or drag to keep">
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
<div class="card-center">
|
||||
{#if card.startsWith('Joker')}
|
||||
🃏
|
||||
{:else}
|
||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
on:click={() => moveToKeep(card)} />
|
||||
{:else}
|
||||
<p class="empty-text text-center w-full">Drag cards here to discard them.</p>
|
||||
{/each}
|
||||
|
||||
143
frontend/src/components/scene/ChallengePanel.svelte
Normal file
143
frontend/src/components/scene/ChallengePanel.svelte
Normal 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}
|
||||
156
frontend/src/components/scene/CharacterSheet.svelte
Normal file
156
frontend/src/components/scene/CharacterSheet.svelte
Normal 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>
|
||||
152
frontend/src/components/scene/DeepControlPanel.svelte
Normal file
152
frontend/src/components/scene/DeepControlPanel.svelte
Normal 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>
|
||||
131
frontend/src/components/scene/ObstacleBoard.svelte
Normal file
131
frontend/src/components/scene/ObstacleBoard.svelte
Normal 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}
|
||||
@@ -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
27
frontend/src/lib/cards.js
Normal 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 || '???';
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { apiRequest } from './api';
|
||||
|
||||
// Auto-generated math-pirate suggestions pool for character creation
|
||||
const SUGGESTIONS = {
|
||||
"look": [
|
||||
@@ -1211,227 +1213,42 @@ const SUGGESTIONS = {
|
||||
"They stubbornly argues about the slide rule and has the abacus tattoos to prove it.",
|
||||
"They accidently misplaces the dry gunpowder bags and has a certificate from the Stowaway Academy.",
|
||||
"They frequently drops the slide rule but gets confused by negative numbers."
|
||||
],
|
||||
"techniques": [
|
||||
"I meant to do that",
|
||||
"The floor was always a trap",
|
||||
"Carlos owes me one",
|
||||
"Barry already disarmed it",
|
||||
"Jaspar took the blame",
|
||||
"Greg always comes back",
|
||||
"We left Greg on purpose",
|
||||
"The seagulls work for me",
|
||||
"My whiskers told me so",
|
||||
"I was behind you the whole time",
|
||||
"I am wearing two eyepatches",
|
||||
"Nobody suspects the small one",
|
||||
"I licked it, so it's mine",
|
||||
"The grog made me do it",
|
||||
"I know a guy",
|
||||
"The guy knows another guy",
|
||||
"I greased the entire ship",
|
||||
"I hid the cannonball in my cheeks",
|
||||
"I taught the parrot everything it knows",
|
||||
"Hold my grog",
|
||||
"Watch this",
|
||||
"I counted to Pi",
|
||||
"Pi is exactly three when I'm in a hurry",
|
||||
"I cast Pie, again",
|
||||
"I memorized the wrong map on purpose",
|
||||
"I sneezed at exactly the right moment",
|
||||
"I read about this in a book I ate",
|
||||
"I learned this from a ghost",
|
||||
"The barrel was empty on purpose",
|
||||
"This is my emotional support cannon",
|
||||
"That wasn't even my final form",
|
||||
"I've been holding my breath since Tuesday",
|
||||
"It's a rental",
|
||||
"The ship likes me better",
|
||||
"My cousin is a cannonball",
|
||||
"My scars spell out a map",
|
||||
"My other gat is a crossbow",
|
||||
"I prepared this speech in advance",
|
||||
"I read the rulebook upside down",
|
||||
"Technically, that's legal",
|
||||
"Objection!",
|
||||
"It's called a heist now",
|
||||
"You wouldn't hit a rat with glasses",
|
||||
"These aren't even my real glasses",
|
||||
"I have the high ground",
|
||||
"You activated my trap card",
|
||||
"It was me all along",
|
||||
"Behold, a distraction",
|
||||
"We meet again, stairs",
|
||||
"I've seen this in a dream",
|
||||
"The prophecy said nothing about Tuesdays",
|
||||
"Catch!",
|
||||
"Think fast",
|
||||
"Look behind you",
|
||||
"No, seriously, look behind you",
|
||||
"This one goes to eleven",
|
||||
"Critical squeak",
|
||||
"Nothing up my sleeves but more sleeves",
|
||||
"The anchor was a decoy",
|
||||
"Six rats in a coat",
|
||||
"The Deep blinked first",
|
||||
"The Squid owed me a favor",
|
||||
"The Albatross vouches for me",
|
||||
"The Mermaid pinky-swore",
|
||||
"It worked in the bathtub",
|
||||
"We rehearsed this exactly once",
|
||||
"The smell did the negotiating",
|
||||
"Carry me, I'm dramatic",
|
||||
"It's only stealing if you get caught",
|
||||
"Borrowed permanently",
|
||||
"Never trust a dry deck",
|
||||
"Never trust a barefoot vampire",
|
||||
"Never duel before lunch",
|
||||
"Never sing the second verse",
|
||||
"Never play cards with a Goblin",
|
||||
"Never wake a sleeping Bean Whale",
|
||||
"Never bite the hand that feeds you cheese",
|
||||
"Never bring a sword to a math fight",
|
||||
"A rat always has an exit",
|
||||
"Two exits, actually",
|
||||
"A wet rat fears no rain",
|
||||
"A captain never looks up",
|
||||
"A good plan smells like cheese",
|
||||
"The loudest rat is rarely the problem",
|
||||
"Dead men file no complaints",
|
||||
"The best sword is a longer sword",
|
||||
"If you can't tie a knot, tie a lot",
|
||||
"When in doubt, scream and leap",
|
||||
"Every storm is someone's fault",
|
||||
"All maps lead to treasure if you're stubborn enough",
|
||||
"The sea forgives nothing but forgets everything",
|
||||
"Steal the boat first, the hearts will follow",
|
||||
"You miss every shot you don't blame on the wind",
|
||||
"There's no such thing as too much rope",
|
||||
"It is always squid o'clock somewhere",
|
||||
"Fortune favors the furry",
|
||||
"Don't count your doubloons until they're stolen",
|
||||
"Cheese before glory",
|
||||
"Glory before breakfast",
|
||||
"Gravity is a suggestion",
|
||||
"Rule one: there are no rules",
|
||||
"Rule two: see rule one",
|
||||
"The knot unties itself if you believe",
|
||||
"Smile until they get nervous",
|
||||
"Just keep nodding",
|
||||
"Pretend it's Tuesday",
|
||||
"Speak softly and carry a loud gat",
|
||||
"Improvise, adapt, overboard",
|
||||
"Cry first, ask questions later",
|
||||
"The tide pays its debts",
|
||||
"Apologize mid-swing",
|
||||
"Bite first, monologue later",
|
||||
"Lose dramatically, win quietly",
|
||||
"Fight like the ghosts are watching",
|
||||
"Trip over the right thing",
|
||||
"Be the cannonball",
|
||||
"Always fall on the soft pirate",
|
||||
"Flee now, gloat later",
|
||||
"Dramatic timing is a weapon",
|
||||
"Cheese knows the way home",
|
||||
"Trust the mustache",
|
||||
"The plank is a state of mind",
|
||||
"Every plank is a trapdoor if you stomp hard enough",
|
||||
"The pointy end goes in the other guy",
|
||||
"An apology and a gat beats an apology",
|
||||
"It's not a bribe, it's a gift",
|
||||
"The fourth wall is load-bearing",
|
||||
"Vampires can't resist counting spilled rice",
|
||||
"Distract them with interpretive dance",
|
||||
"Sing the shanty of unreasonable confidence",
|
||||
"The Cheddar Gambit",
|
||||
"The Gouda Guillotine",
|
||||
"The Brie Breach",
|
||||
"The Camembert Cannonade",
|
||||
"The Reverse Plank Walk",
|
||||
"The Drunken Compass Defense",
|
||||
"The Whisker Feint",
|
||||
"The Triple Barrel Roll",
|
||||
"The Stowaway Shuffle",
|
||||
"The Bilge Rat Backflip",
|
||||
"The Captain's Distraction Waltz",
|
||||
"The Forbidden Knot",
|
||||
"The Unforgivable Cannonball",
|
||||
"The Polite Mutiny",
|
||||
"The Reverse Mutiny",
|
||||
"The Squeak of a Thousand Regrets",
|
||||
"The Tail-First Retreat",
|
||||
"The Upside-Down Boarding Party",
|
||||
"The Invisible Rope Trick",
|
||||
"The Two-Hat Bamboozle",
|
||||
"The Ghost Pepper Defense",
|
||||
"The Barnacle Embrace",
|
||||
"The Crow's Nest Cannonball",
|
||||
"The Rigging Tango",
|
||||
"The Anchor Yo-Yo",
|
||||
"The Powder Keg Lullaby",
|
||||
"The Mermaid's IOU",
|
||||
"The Goblin Refund Policy",
|
||||
"The Fifth Ace",
|
||||
"The Sixth Ace",
|
||||
"The Emergency Wedding",
|
||||
"The Slow-Motion Walk Away",
|
||||
"The Borrowed Thunder",
|
||||
"The Cheese Wheel of Fortune",
|
||||
"The Last Rat Standing",
|
||||
"The First Rat Running",
|
||||
"The Old Switcheroo",
|
||||
"The Old Double Switcheroo",
|
||||
"The Long Con (Short Version)",
|
||||
"The Accidental Masterpiece",
|
||||
"The Sympathy Limp",
|
||||
"The Decoy Funeral",
|
||||
"The Surprise Encore",
|
||||
"The Overly Specific Alibi",
|
||||
"The Group Hug Ambush",
|
||||
"The Cannonball Curveball",
|
||||
"The Soup of Truth",
|
||||
"The Hat Trick (With Actual Hats)",
|
||||
"The Thousand-Yard Squint",
|
||||
"The Completely Legal Salvage",
|
||||
"Carry the one",
|
||||
"Round down, shoot up",
|
||||
"Divide and run away",
|
||||
"Subtract yourself from the situation",
|
||||
"Multiply by chaos",
|
||||
"Add insult to injury",
|
||||
"The remainder goes in my pocket",
|
||||
"Long division, short fuse",
|
||||
"Triangulate, then detonate",
|
||||
"Roll for cheese",
|
||||
"Yo ho ho and a barrel of math",
|
||||
"Ask the cook, he's seen worse",
|
||||
"Barry has a boat for this",
|
||||
"Tell Jaspar I said hello",
|
||||
"The first mate signs my alibis",
|
||||
"Piss Whiskers takes the watch",
|
||||
"We voted, you lost",
|
||||
"The cheese was a lie",
|
||||
"One more verse won't hurt",
|
||||
"Abandon ship, but stylishly"
|
||||
]
|
||||
};
|
||||
|
||||
export function getSuggestion(type, exclude = []) {
|
||||
let arr = SUGGESTIONS[type];
|
||||
if (!arr) return "";
|
||||
function pickFrom(arr, exclude = []) {
|
||||
const taken = new Set(exclude.map(s => s.trim().toLowerCase()));
|
||||
const available = arr.filter(s => !taken.has(s.trim().toLowerCase()));
|
||||
if (available.length > 0) arr = available;
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
const pool = available.length > 0 ? available : arr;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
// Draws `count` distinct suggestions from a pool.
|
||||
export function getSuggestions(type, count) {
|
||||
export function getSuggestion(type, exclude = []) {
|
||||
const arr = SUGGESTIONS[type];
|
||||
if (!arr) return "";
|
||||
return pickFrom(arr, exclude);
|
||||
}
|
||||
|
||||
// Secret Pirate Technique names live in the backend (it also uses them to equip
|
||||
// re-rolled recruits); fetch the pool once and pick from it client-side.
|
||||
let techniquePoolPromise = null;
|
||||
function loadTechniquePool() {
|
||||
if (!techniquePoolPromise) {
|
||||
techniquePoolPromise = apiRequest('/suggestions/techniques').then(d => d.techniques);
|
||||
}
|
||||
return techniquePoolPromise;
|
||||
}
|
||||
|
||||
export async function getTechniqueSuggestion(exclude = []) {
|
||||
return pickFrom(await loadTechniquePool(), exclude);
|
||||
}
|
||||
|
||||
// Draws `count` distinct technique suggestions.
|
||||
export async function getTechniqueSuggestions(count, exclude = []) {
|
||||
const pool = await loadTechniquePool();
|
||||
const picked = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const s = getSuggestion(type, picked);
|
||||
if (!s) break;
|
||||
picked.push(s);
|
||||
picked.push(pickFrom(pool, [...exclude, ...picked]));
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import random
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from typing import Dict, Any, List
|
||||
|
||||
SUITS = {
|
||||
"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
|
||||
# names plus the same "{math word} {move} of {target}" combinations the frontend
|
||||
# suggestion pool (frontend/src/lib/suggestions.js) is built from.
|
||||
_HANDCRAFTED_TECHNIQUES = [
|
||||
"I missed on purpose",
|
||||
"Carlos will figure it out",
|
||||
"Never trust a hatless captain",
|
||||
"I'm not left handed",
|
||||
"The Albatross is always watching",
|
||||
"I've got a bomb",
|
||||
"Divide by Pi",
|
||||
"Cheese-scented smoke screen",
|
||||
"Look behind you, a three-headed parrot!",
|
||||
"Parabolic boarding leap",
|
||||
"The Pocket Cannon",
|
||||
"Aggressive Math-whining",
|
||||
"Unspeakable Tail Swish",
|
||||
"Bandana Whip",
|
||||
"Double-barrelled cheese blaster",
|
||||
"Mathematical Scurvy cure",
|
||||
"I was hiding under the carpet",
|
||||
"Batten down my feelings",
|
||||
"Wait, is that cheese?",
|
||||
"Sudden pirate flip"
|
||||
]
|
||||
|
||||
_TECHNIQUE_MATH_WORDS = [
|
||||
"Geometric", "Parabolic", "Fibonacci", "Trigonometric", "Prime Number",
|
||||
"Logarithmic", "Hypotenuse", "Algebraic", "Binary", "Vector"
|
||||
]
|
||||
_TECHNIQUE_MOVES = [
|
||||
"Drift", "Leap", "Flurry", "Slash", "Parry",
|
||||
"Slam", "Dodge", "Vortex", "Shield", "Barrage"
|
||||
]
|
||||
_TECHNIQUE_TARGETS = [
|
||||
"the Cheese Reef", "the Stormy Sea", "Euler", "the Abacus", "the Gat",
|
||||
"Carlos", "the Deep", "Gauss", "the Coordinate Plane", "Shrapnel"
|
||||
]
|
||||
|
||||
TECHNIQUE_SUGGESTIONS = _HANDCRAFTED_TECHNIQUES + [
|
||||
f"{math_word} {move} of {target}"
|
||||
for math_word in _TECHNIQUE_MATH_WORDS
|
||||
for move in _TECHNIQUE_MOVES
|
||||
for target in _TECHNIQUE_TARGETS
|
||||
# Secret Pirate Technique suggestions: served to the frontend's Suggest buttons
|
||||
# via GET /api/suggestions/techniques and used to equip re-rolled recruits.
|
||||
TECHNIQUE_SUGGESTIONS = [
|
||||
"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"
|
||||
]
|
||||
|
||||
def get_fresh_deck() -> List[str]:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import json
|
||||
import random
|
||||
from typing import List, Optional
|
||||
from sqlmodel import Session
|
||||
from .models import Game, Player, Obstacle, GameEvent
|
||||
from typing import Any, Dict, List, Optional
|
||||
from sqlmodel import Session, select
|
||||
from .models import Game, Player, GameEvent
|
||||
from . import cards
|
||||
|
||||
FACE_VALUES = ("J", "Q", "K")
|
||||
|
||||
# --- Card and Hand Utilities ---
|
||||
|
||||
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]):
|
||||
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:
|
||||
"""
|
||||
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!"
|
||||
if reason:
|
||||
msg += f" ({reason})"
|
||||
add_game_event(db, game.id, msg)
|
||||
add_game_event(db, game.id, msg, kind="captain")
|
||||
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):
|
||||
"""
|
||||
@@ -115,6 +175,10 @@ def reshuffle_discard_pile(db: Session, game: Game):
|
||||
played = json.loads(obs.played_cards)
|
||||
for entry in played:
|
||||
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
|
||||
full_deck = []
|
||||
@@ -197,11 +261,29 @@ def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -
|
||||
db.commit()
|
||||
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
|
||||
|
||||
def add_game_event(db: Session, game_id: str, message: str):
|
||||
event = GameEvent(game_id=game_id, message=message)
|
||||
def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
|
||||
event = GameEvent(game_id=game_id, message=message, kind=kind)
|
||||
db.add(event)
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@ from .models import Game, Player, Obstacle, Challenge
|
||||
from . import cards
|
||||
from .crud_base import (
|
||||
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
|
||||
|
||||
@@ -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."""
|
||||
if game.captain_player_id == acting_player.id and acting_player.rank > 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 ---
|
||||
|
||||
@@ -80,7 +80,7 @@ def create_challenge(
|
||||
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
|
||||
if 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!"
|
||||
|
||||
def play_challenge_card(
|
||||
@@ -156,7 +156,7 @@ def play_challenge_card(
|
||||
result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)"
|
||||
|
||||
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["is_joker"] = False
|
||||
@@ -191,7 +191,7 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
|
||||
db.commit()
|
||||
|
||||
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.
|
||||
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"
|
||||
if refuser:
|
||||
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:
|
||||
if challenge.tax_type == "gat":
|
||||
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.commit()
|
||||
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:
|
||||
_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.commit()
|
||||
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."
|
||||
|
||||
# --- 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)
|
||||
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}."
|
||||
|
||||
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"
|
||||
db.add(challenge)
|
||||
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."
|
||||
|
||||
# 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.commit()
|
||||
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!"
|
||||
|
||||
# --- Pi-Rat vs Pi-Rat Challenges ---
|
||||
@@ -397,7 +397,7 @@ def create_pvp_challenge(
|
||||
|
||||
parsed = cards.parse_card(card_code)
|
||||
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!"
|
||||
|
||||
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)
|
||||
db.add(defender)
|
||||
|
||||
temp_parsed = cards.parse_card(challenge.temp_card)
|
||||
|
||||
success = False
|
||||
details = ""
|
||||
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})."
|
||||
# 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"]
|
||||
result = evaluate_card_play(defender, card_code, challenge.temp_card, temp_value, defender.rank)
|
||||
success = result["success"]
|
||||
|
||||
plays = json.loads(challenge.plays)
|
||||
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_name": defender.name,
|
||||
"success": success,
|
||||
"details": details
|
||||
"details": result["details"]
|
||||
})
|
||||
challenge.plays = json.dumps(plays)
|
||||
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)
|
||||
if drawn:
|
||||
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"
|
||||
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:
|
||||
_apply_captain_tax(db, game, defender)
|
||||
|
||||
return True, "Duel resolved.", {
|
||||
"success": success,
|
||||
"details": details,
|
||||
"is_technique": is_technique,
|
||||
"tech_name": tech_name,
|
||||
"drew_card": drew_card,
|
||||
"is_joker": False
|
||||
}
|
||||
result["drew_card"] = drew_card
|
||||
result["is_joker"] = False
|
||||
return True, "Duel resolved.", result
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import random
|
||||
from sqlmodel import Session
|
||||
from .models import Game, Player
|
||||
from .models import Game
|
||||
from .crud_base import get_player, get_game, add_game_event
|
||||
|
||||
# --- Character Creation Operations ---
|
||||
@@ -126,7 +126,7 @@ def trigger_technique_swap(db: Session, game: Game):
|
||||
p.created_techniques = "[]"
|
||||
db.add(p)
|
||||
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
|
||||
|
||||
# Swap algorithm with simple retry backtracking
|
||||
@@ -166,7 +166,7 @@ def trigger_technique_swap(db: Session, game: Game):
|
||||
game.phase = "swap_techniques"
|
||||
db.add(game)
|
||||
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
|
||||
break
|
||||
|
||||
@@ -180,7 +180,7 @@ def trigger_technique_swap(db: Session, game: Game):
|
||||
game.phase = "swap_techniques"
|
||||
db.add(game)
|
||||
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):
|
||||
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"
|
||||
db.add(game)
|
||||
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(
|
||||
db: Session,
|
||||
@@ -347,4 +347,4 @@ def roll_new_character(
|
||||
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"{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")
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import json
|
||||
import random
|
||||
from typing import Tuple, Dict, Any, Optional
|
||||
from typing import Tuple, Dict, Any
|
||||
from sqlmodel import Session
|
||||
from .models import Game, Player, Obstacle, Challenge
|
||||
from .models import Game, Player, Obstacle
|
||||
from . import cards
|
||||
from .crud_base import (
|
||||
get_player, get_game, get_player_hand, set_player_hand,
|
||||
get_game_deck, set_game_deck, calculate_max_hand_size, draw_cards_for_player,
|
||||
add_game_event, reshuffle_discard_pile, capture_hand_maxes, apply_hand_increases,
|
||||
change_player_rank, set_captain
|
||||
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
||||
change_player_rank, set_captain, evaluate_card_play
|
||||
)
|
||||
|
||||
# --- 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
|
||||
# number of Obstacles required for following scenes by 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
|
||||
|
||||
info = cards.get_obstacle_info(card)
|
||||
@@ -148,7 +147,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
||||
db.commit()
|
||||
|
||||
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!"
|
||||
|
||||
@@ -193,83 +192,30 @@ def resolve_card_against_obstacle(
|
||||
acting_rank: int
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolves one (non-Joker) card from a player against an Obstacle:
|
||||
determines success (Secret Pirate Techniques, Gat/Name privileges, face-card
|
||||
Obstacle values), appends the card to the Obstacle's column, and updates
|
||||
the Obstacle's current value. Does not touch hands or draws.
|
||||
Resolves one (non-Joker) card from a player against an Obstacle via the shared
|
||||
rules in evaluate_card_play, appends the card to the Obstacle's column, and
|
||||
updates 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
|
||||
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}."
|
||||
result = evaluate_card_play(player, card_code, obstacle_card, obstacle.current_value, acting_rank)
|
||||
|
||||
# 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({
|
||||
"card": card_code,
|
||||
"player_id": player.id,
|
||||
"player_name": player.name,
|
||||
"success": success,
|
||||
"details": details
|
||||
"success": result["success"],
|
||||
"details": result["details"]
|
||||
})
|
||||
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.commit()
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"details": details,
|
||||
"is_technique": is_technique,
|
||||
"tech_name": tech_name
|
||||
}
|
||||
return result
|
||||
|
||||
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)
|
||||
if new_parsed["is_joker"]:
|
||||
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
|
||||
|
||||
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" 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
|
||||
|
||||
@@ -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()
|
||||
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]:
|
||||
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):
|
||||
obstacle = db.get(Obstacle, obstacle_id)
|
||||
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.commit()
|
||||
|
||||
@@ -483,4 +429,4 @@ def finish_game(db: Session, game_id: str):
|
||||
game.phase = "ended"
|
||||
db.add(game)
|
||||
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")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import json
|
||||
import random
|
||||
from typing import List, Tuple
|
||||
from sqlmodel import Session, select
|
||||
from .models import Game, Player, Vote
|
||||
from .models import Game, Vote
|
||||
from .crud_base import (
|
||||
get_player, get_game, get_player_hand, set_player_hand,
|
||||
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
|
||||
@@ -56,7 +55,7 @@ def end_scene_and_transition(db: Session, game_id: str):
|
||||
db.add(game)
|
||||
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):
|
||||
"""
|
||||
@@ -82,7 +81,7 @@ def process_between_scenes_votes(db: Session, game: Game):
|
||||
winner = get_player(db, winner_id)
|
||||
if winner:
|
||||
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
|
||||
for v in votes:
|
||||
@@ -107,13 +106,13 @@ def transition_to_deep_upkeep(db: Session, game_id: str):
|
||||
|
||||
if has_resting_deep:
|
||||
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:
|
||||
# Fallback in case there were no Deep players
|
||||
# Just advance directly to scene_setup
|
||||
game.current_scene_number += 1
|
||||
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:
|
||||
p.role = None
|
||||
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!
|
||||
game.current_scene_number += 1
|
||||
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:
|
||||
p.is_ready = False
|
||||
p.role = None
|
||||
|
||||
@@ -31,6 +31,7 @@ def create_db_and_tables():
|
||||
("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
|
||||
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
|
||||
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
|
||||
("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
|
||||
]
|
||||
|
||||
for table, col, def_type in columns:
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status, APIRouter
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session
|
||||
import uvicorn
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from . import crud
|
||||
from .database import create_db_and_tables, get_session
|
||||
from .models import GameEvent
|
||||
|
||||
EVENT_PAGE_SIZE = 50
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
|
||||
app = FastAPI(title="Rats with Gats Remote Play API")
|
||||
api = APIRouter()
|
||||
|
||||
# Register startup event to create tables
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
create_db_and_tables()
|
||||
yield
|
||||
|
||||
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)
|
||||
api = APIRouter()
|
||||
|
||||
# --- API Core Route Handlers ---
|
||||
|
||||
@@ -29,7 +30,8 @@ def create_game_route(
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
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")
|
||||
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:
|
||||
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 {
|
||||
"game": game.model_dump(),
|
||||
"game": game.model_dump(exclude={"admin_key"}),
|
||||
"player": player.model_dump(),
|
||||
"players": [p.model_dump() for p in game.players],
|
||||
"obstacles": [o.model_dump() for o in game.obstacles],
|
||||
"challenges": [c.model_dump() for c in game.challenges],
|
||||
"votes": [v.model_dump() for v in game.votes],
|
||||
"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 ---
|
||||
|
||||
@@ -121,5 +121,6 @@ class GameEvent(SQLModel, table=True):
|
||||
game_id: str = Field(foreign_key="game.id", index=True)
|
||||
timestamp: float = Field(default_factory=time.time)
|
||||
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")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
from .database import get_session
|
||||
from . import crud
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Form, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Form, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
from .database import get_session
|
||||
from . import crud
|
||||
from .cards import TECHNIQUE_SUGGESTIONS
|
||||
|
||||
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
|
||||
@router.post("/game/{game_id}/player/{player_id}/save-basic")
|
||||
def player_save_basic_details(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session
|
||||
from .database import get_session
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Form, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Form, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Form, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
from .database import get_session
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -3,7 +3,7 @@ import json
|
||||
from sqlmodel import SQLModel, create_engine, Session
|
||||
from pirats import cards
|
||||
from pirats import crud
|
||||
from pirats.models import Game, Player, Obstacle
|
||||
from pirats.models import Obstacle
|
||||
|
||||
# In-memory database for testing
|
||||
@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])
|
||||
assert ok
|
||||
session.refresh(game)
|
||||
challenge = game.challenges[0]
|
||||
|
||||
# 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)
|
||||
@@ -409,7 +408,6 @@ def test_create_game_endpoint():
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import SQLModel, create_engine, Session, select
|
||||
from pirats.main import app
|
||||
from pirats import crud
|
||||
|
||||
# Setup in-memory DB
|
||||
engine = create_engine(
|
||||
@@ -963,7 +961,7 @@ def test_submit_techniques_rejects_collisions(session):
|
||||
def test_roll_new_character_smell_name_normalization(session):
|
||||
game = crud.create_game(session)
|
||||
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
|
||||
session.add(p1)
|
||||
session.commit()
|
||||
@@ -1009,3 +1007,115 @@ def test_roll_new_character_avoids_technique_collisions(session):
|
||||
session.refresh(p1)
|
||||
recruit_techs = {p1.tech_jack, p1.tech_queen, p1.tech_king}
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user