238 lines
8.7 KiB
Svelte
238 lines
8.7 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';
|
|
import RankBonusModal from '../components/RankBonusModal.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>
|
|
|
|
<!-- The scene phase renders its own inline Event Log as the third column. -->
|
|
{#if state.game.phase !== 'scene'}
|
|
<EventLog {state} />
|
|
{/if}
|
|
<NameModal {state} />
|
|
<GatModal {state} />
|
|
<RankBonusModal {state} />
|
|
{:else if !error}
|
|
<div class="loading-container">
|
|
<p>Loading game state...</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.loading-container {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100vh;
|
|
color: white;
|
|
font-size: 1.5rem;
|
|
}
|
|
</style>
|