Files
pirats/frontend/src/pages/Dashboard.svelte
Tim McCarthy 44bbb88836 Add gat-description modal when a Pi-Rat earns a Gat
Mirrors the name modal: earning the Gat objective sets
Player.needs_gat_description, and GatModal.svelte (rendered by Dashboard
across all phases) prompts for a one-of-a-kind Gat description, stored in
Player.gat_description and shown on the character sheet. Like a stolen
Name, the Gat's description travels with it through Gat Tax transfers.

- Player.gat_description / needs_gat_description (+ migration)
- toggle_objective personal_1 sets/clears the prompt
- crud_challenge gat-tax paths move the description with the Gat
- set-gat-description route; CharacterSheet displays it

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:46:02 -07:00

233 lines
8.5 KiB
Svelte

<script>
import { onMount, onDestroy } from 'svelte';
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api';
import { extraMenuLinks } from '../lib/menu';
import { getSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards';
import { setPageTitle } from '../lib/title';
import { saveSession, removeSession } from '../lib/sessions';
import LobbyPhase from '../components/LobbyPhase.svelte';
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
import ScenePhase from '../components/ScenePhase.svelte';
import UpkeepPhase from '../components/UpkeepPhase.svelte';
import RecruitPhase from '../components/RecruitPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.svelte';
import EventLog from '../components/EventLog.svelte';
import NameModal from '../components/NameModal.svelte';
import GatModal from '../components/GatModal.svelte';
export let params = {};
let gameId = params.id;
let playerId = params.pid;
let state = null;
let error = '';
let intervalId;
let ws = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
let destroyed = false;
async function fetchState() {
try {
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
state = data;
error = '';
} catch (err) {
error = err.message;
}
}
// The server pushes a ping over this socket whenever the game changes;
// each ping triggers a refetch of the state blob.
function connectSocket() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/api/game/${gameId}/ws`);
ws.onopen = () => {
reconnectDelay = 1000;
fetchState(); // catch up on anything missed while disconnected
};
ws.onmessage = () => fetchState();
ws.onclose = () => {
if (destroyed) return;
reconnectTimer = setTimeout(connectSocket, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
};
}
onMount(() => {
fetchState();
connectSocket();
// Slow fallback poll in case a push is ever missed
intervalId = setInterval(fetchState, 30000);
});
// Creator-only corner menu entries: Dev Mode toggle (all phases), the
// Skip Character Creation shortcut (Dev Mode only), and the Admin link.
async function toggleDevMode() {
try {
await apiRequest(`/game/${gameId}/player/${playerId}/dev-mode`, 'POST', {
enabled: !state.game.dev_mode
});
} catch (err) {
error = err.message;
}
}
async function skipCharacterCreation() {
// Suggested values for everyone's free-text fields; the backend only
// applies them to fields players haven't filled in themselves.
const fills = {};
for (const p of state.players) {
fills[p.id] = {
avatar_look: getSuggestion('look'),
avatar_smell: getSuggestion('smell'),
first_words: getSuggestion('first_words'),
good_at_math: getSuggestion('good_at_math'),
like: getSuggestion('like'),
hate: getSuggestion('hate'),
};
}
try {
await apiRequest(`/game/${gameId}/player/${playerId}/skip-character-creation`, 'POST', {
fills: JSON.stringify(fills)
});
} catch (err) {
error = err.message;
}
}
// Admin escape hatch: hand out every technique randomly and auto-assign J/Q/K
async function autoAssignTechniques() {
try {
await apiRequest(`/game/${gameId}/player/${playerId}/auto-assign-techniques`, 'POST');
} catch (err) {
error = err.message;
}
}
// Voluntarily remove yourself from the game (cards go to the discard)
async function leaveGame() {
if (!confirm("Leave this game? Your cards go to the discard and you'll need a new invite to rejoin. This can't be undone.")) return;
try {
await apiRequest(`/game/${gameId}/player/${playerId}/leave`, 'POST');
removeSession(gameId, playerId);
push('/');
} catch (err) {
error = err.message;
}
}
// Distinguish tabs/history by the Pi-Rat (and crew) this dashboard is for
$: setPageTitle(state ? displayName(state.player) : null, state?.game?.crew_name);
// Keep this browser's saved session up to date so the home page can rejoin
// with current crew/rat names (the rat name changes when a Name is earned)
$: if (state?.game && state?.player) {
saveSession({
gameId,
playerId,
crewName: state.game.crew_name,
playerName: state.player.player_name,
ratName: state.player.name,
});
}
// Admins get the admin key in their state blob; stash it so the Admin page works for them too
$: if (state?.player?.is_admin && state?.game?.admin_key) {
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);
}
$: {
const items = [];
if (state?.player?.is_admin) {
items.push({
label: `🧪 Dev Mode: ${state.game.dev_mode ? 'On' : 'Off'}`,
action: toggleDevMode,
title: 'Toggle Dev Mode testing shortcuts for the whole table',
});
if (state.game.dev_mode && ['character_creation', 'swap_techniques', 'assign_techniques'].includes(state.game.phase)) {
items.push({
label: '⏭️ Skip Character Creation',
action: skipCharacterCreation,
title: 'Auto-fill anything unfinished for all players and jump to scene setup',
});
}
if (state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques') {
items.push({
label: '🎲 Auto-Assign Techniques',
action: autoAssignTechniques,
title: 'Randomly distribute all techniques to eligible players and assign them to J/Q/K',
});
}
items.push({ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' });
}
if (state?.player) {
items.push({
label: '🚪 Leave Game',
action: leaveGame,
title: 'Remove yourself from this game (your cards go to the discard)',
});
}
extraMenuLinks.set(items);
}
onDestroy(() => {
destroyed = true;
if (intervalId) clearInterval(intervalId);
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) ws.close();
extraMenuLinks.set([]);
});
</script>
{#if error}
<div class="alert alert-danger" style="margin: 20px;">
Error connecting to game: {error}
</div>
{/if}
{#if state}
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
{#if state.game.phase === 'lobby'}
<LobbyPhase {state} />
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
<CharacterCreationPhase {state} />
{:else if state.game.phase === 'scene_setup'}
<SceneSetupPhase {state} />
{:else if state.game.phase === 'scene'}
<ScenePhase {state} />
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
<UpkeepPhase {state} />
{:else if state.game.phase === 'recruit_creation'}
<RecruitPhase {state} />
{:else if state.game.phase === 'ended'}
<GameOverPhase {state} />
{:else}
<div class="card p-4">Unknown phase: {state.game.phase}</div>
{/if}
</div>
<EventLog {state} />
<NameModal {state} />
<GatModal {state} />
{:else if !error}
<div class="loading-container">
<p>Loading game state...</p>
</div>
{/if}
<style>
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
color: white;
font-size: 1.5rem;
}
</style>