Fable will fix it! Pt 3

This commit is contained in:
2026-06-12 01:13:11 -07:00
parent 6dcb2a9ae9
commit 3a00774b50
21 changed files with 259 additions and 56 deletions

View File

@@ -1,5 +1,5 @@
<script> <script>
import { SUIT_EMOJI, isJoker, cardValue, cardSuit } from '../lib/cards'; import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip } from '../lib/cards';
export let card; // card code, e.g. "10D" or "Joker1" export let card; // card code, e.g. "10D" or "Joker1"
export let size = 'large'; // 'large' | 'medium' | 'mini' export let size = 'large'; // 'large' | 'medium' | 'mini'
@@ -16,17 +16,19 @@
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)]; $: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase(); $: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
$: techName = techs && !joker ? techs[cardValue(card)] : null; $: techName = techs && !joker ? techs[cardValue(card)] : null;
// Tooltip: explicit title wins; otherwise remind the player of the suit's theme
$: tooltip = title || cardTooltip(card);
</script> </script>
{#if size === 'mini'} {#if size === 'mini'}
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}" {title}> <div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}" title={tooltip}>
<span class="val">{val}</span> <span class="val">{val}</span>
<span class="suit">{suit}</span> <span class="suit">{suit}</span>
{#if owner}<span class="owner">{owner}</span>{/if} {#if owner}<span class="owner">{owner}</span>{/if}
</div> </div>
{:else} {:else}
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}" <div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
{title} {draggable} {style} title={tooltip} {draggable} {style}
on:dragstart on:dragend on:click> on:dragstart on:dragend on:click>
<div class="card-corner top-left"> <div class="card-corner top-left">
<span class="val">{val}</span> <span class="val">{val}</span>

View File

@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions'; import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions';
import { displayName } from '../lib/cards';
export let state; export let state;
@@ -42,7 +43,7 @@
function getPlayerName(id) { function getPlayerName(id) {
if (!id) return "None"; if (!id) return "None";
const p = state.players.find(x => x.id === id); const p = state.players.find(x => x.id === id);
return p ? p.name : "Unknown"; return p ? displayName(p) : "Unknown";
} }
// Techniques // Techniques
@@ -274,7 +275,7 @@
{#each state.players as p} {#each state.players as p}
{#if p.other_like_from_player_id === state.player.id && !p.other_like} {#if p.other_like_from_player_id === state.player.id && !p.other_like}
<div class="inbox-item"> <div class="inbox-item">
<label>What do you LIKE about {p.name}?</label> <label>What do you LIKE about {displayName(p)}?</label>
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button> <button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>
@@ -284,7 +285,7 @@
{/if} {/if}
{#if p.other_hate_from_player_id === state.player.id && !p.other_hate} {#if p.other_hate_from_player_id === state.player.id && !p.other_hate}
<div class="inbox-item"> <div class="inbox-item">
<label>What do you HATE about {p.name}?</label> <label>What do you HATE about {displayName(p)}?</label>
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button> <button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>
@@ -512,7 +513,7 @@
<ul class="space-y-2 mt-4"> <ul class="space-y-2 mt-4">
{#each state.players as p} {#each state.players as p}
<li class="p-3 bg-dark-800 rounded flex justify-between items-center border-l-4 {p.tech_jack ? 'border-green-500' : 'border-gray-500'}"> <li class="p-3 bg-dark-800 rounded flex justify-between items-center border-l-4 {p.tech_jack ? 'border-green-500' : 'border-gray-500'}">
<span>{p.name}</span> <span>{displayName(p)}</span>
<span class="text-sm"> <span class="text-sm">
{#if p.tech_jack} {#if p.tech_jack}
<span class="text-green-400">Ready</span> <span class="text-green-400">Ready</span>

View File

@@ -1,4 +1,6 @@
<script> <script>
import { displayName } from '../lib/cards';
export let state; export let state;
$: pirats = state.players; $: pirats = state.players;
@@ -33,7 +35,7 @@
<div style="background: rgba(0,0,0,0.2); padding: 0.75rem; border-radius: 6px; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;"> <div style="background: rgba(0,0,0,0.2); padding: 0.75rem; border-radius: 6px; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<span> <span>
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if}
<strong>{p.name}</strong> (Rank {p.rank}) <strong>{displayName(p)}</strong> (Rank {p.rank})
{#if p.id === state.game.captain_player_id}<span style="color: var(--gold);"> ⭐ Captain</span>{/if} {#if p.id === state.game.captain_player_id}<span style="color: var(--gold);"> ⭐ Captain</span>{/if}
</span> </span>
<span style="font-size: 0.85rem; color: var(--text-muted);"> <span style="font-size: 0.85rem; color: var(--text-muted);">

View File

@@ -1,10 +1,10 @@
<script> <script>
import Card from './Card.svelte'; import Card from './Card.svelte';
import { displayName } from '../lib/cards';
import ChallengePanel from './scene/ChallengePanel.svelte'; import ChallengePanel from './scene/ChallengePanel.svelte';
import ObstacleBoard from './scene/ObstacleBoard.svelte'; import ObstacleBoard from './scene/ObstacleBoard.svelte';
import DeepControlPanel from './scene/DeepControlPanel.svelte'; import DeepControlPanel from './scene/DeepControlPanel.svelte';
import CharacterSheet from './scene/CharacterSheet.svelte'; import CharacterSheet from './scene/CharacterSheet.svelte';
import EventLog from './EventLog.svelte';
export let state; export let state;
@@ -33,7 +33,7 @@
<div> <div>
{#if captain} {#if captain}
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;"> <span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;">
🏴‍☠️ Captain: {captain.name} (Rank {captain.rank}){isCaptain ? ' — You!' : ''} 🏴‍☠️ Captain: {displayName(captain)} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
</span> </span>
{:else} {:else}
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;"> <span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
@@ -48,11 +48,11 @@
{#each state.players as p} {#each state.players as p}
{#if p.role === 'deep'} {#if p.role === 'deep'}
<span class="player-chip deep-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem;"> <span class="player-chip deep-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem;">
🌊 {p.name} (Deep) 🌊 {displayName(p)} (Deep)
</span> </span>
{:else if p.role === 'pirat'} {:else if p.role === 'pirat'}
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {p.is_ghost ? 'border-color: #7890a8; color: #a0b0c0; background: rgba(120, 150, 180, 0.1);' : ''} {p.is_dead ? 'border-color: var(--red-suit); color: var(--red-suit); background: rgba(255, 42, 95, 0.1);' : ''} {p.id === state.game.captain_player_id ? 'border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);' : ''}"> <span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {p.is_ghost ? 'border-color: #7890a8; color: #a0b0c0; background: rgba(120, 150, 180, 0.1);' : ''} {p.is_dead ? 'border-color: var(--red-suit); color: var(--red-suit); background: rgba(255, 42, 95, 0.1);' : ''} {p.id === state.game.captain_player_id ? 'border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);' : ''}">
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {p.name} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''} {#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {displayName(p)} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
</span> </span>
{/if} {/if}
{/each} {/each}
@@ -99,5 +99,3 @@
{/if} {/if}
</div> </div>
</div> </div>
<EventLog {state} />

View File

@@ -1,6 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { displayName } from '../lib/cards';
export let state; export let state;
@@ -50,6 +51,18 @@
<h2>Scene Setup (Scene #{state.game.current_scene_number})</h2> <h2>Scene Setup (Scene #{state.game.current_scene_number})</h2>
<p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p> <p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p>
{#if state.game.current_scene_number === 1}
<div class="glass-panel" style="max-width: 700px; margin: 0 auto 1.5rem; padding: 1rem 1.5rem; border: 1px dashed var(--gold); border-radius: 8px; background: rgba(212, 175, 55, 0.08); text-align: left;">
<h4 style="color: var(--gold); margin: 0 0 0.5rem 0; font-family: var(--font-heading);">🚢 First Scene Framing</h4>
<p class="info-text" style="margin: 0; font-size: 0.95rem;">
The first scene must establish that the Pi-Rats are <strong>on a ship they can steal</strong> (or have an immediate opportunity to steal one). <strong>Don't start far from water!</strong>
{#if state.player.role === 'deep'}
<br/><span style="color: var(--gold);">You're playing the Deep — once the scene starts, describe where the crew is and what ship is ripe for the taking before calling any Challenges.</span>
{/if}
</p>
</div>
{/if}
<div class="setup-grid"> <div class="setup-grid">
<!-- Role Selection Card --> <!-- Role Selection Card -->
<div class="card glass-panel role-selection-card"> <div class="card glass-panel role-selection-card">
@@ -93,7 +106,7 @@
<div class="roster-list" id="scene-roster-list"> <div class="roster-list" id="scene-roster-list">
{#each state.players as p} {#each state.players as p}
<div class="roster-item"> <div class="roster-item">
<span class="player-name">{p.name} <span class="text-sm text-gray-400">(Rank {p.rank})</span></span> <span class="player-name">{displayName(p)} <span class="text-sm text-gray-400">(Rank {p.rank})</span></span>
{#if p.role === 'pirat'} {#if p.role === 'pirat'}
<span class="role-badge pirat">Pi-Rat</span> <span class="role-badge pirat">Pi-Rat</span>
{:else if p.role === 'deep'} {:else if p.role === 'deep'}

View File

@@ -1,6 +1,7 @@
<script> <script>
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { getSuggestion } from '../lib/suggestions'; import { getSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards';
import Card from './Card.svelte'; import Card from './Card.svelte';
export let state; export let state;
@@ -270,7 +271,7 @@
<option value="">Nominate crewmate...</option> <option value="">Nominate crewmate...</option>
{#each state.players as p} {#each state.players as p}
{#if p.id !== state.player.id && p.role !== 'deep'} {#if p.id !== state.player.id && p.role !== 'deep'}
<option value={p.id}>{p.name} (Rank {p.rank})</option> <option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/if} {/if}
{/each} {/each}
</select> </select>
@@ -287,7 +288,7 @@
{#each state.players as p} {#each state.players as p}
{@const voted = state.votes.some(v => v.voter_player_id === p.id)} {@const voted = state.votes.some(v => v.voter_player_id === p.id)}
<div class="player-chip {voted ? 'voted-chip' : 'not-voted-chip'}"> <div class="player-chip {voted ? 'voted-chip' : 'not-voted-chip'}">
<span class="name">{p.name}</span> <span class="name">{displayName(p)}</span>
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span> <span class="status-badge">{voted ? 'Done' : 'Voting...'}</span>
</div> </div>
{/each} {/each}
@@ -372,7 +373,7 @@
<ul class="ranks-ledger"> <ul class="ranks-ledger">
{#each state.players as p} {#each state.players as p}
<li> <li>
<strong>{p.name}:</strong> Rank {p.rank} <strong>{displayName(p)}:</strong> Rank {p.rank}
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge" style="background: rgba(120, 150, 180, 0.15); border: 1px solid #7890a8; color: #a0b0c0; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; margin-left: 0.5rem; display: inline-block;">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if} {#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge" style="background: rgba(120, 150, 180, 0.15); border: 1px solid #7890a8; color: #a0b0c0; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; margin-left: 0.5rem; display: inline-block;">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
</li> </li>
{/each} {/each}

View File

@@ -1,6 +1,6 @@
<script> <script>
import { apiRequest } from '../../lib/api'; import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, playerName as lookupName } from '../../lib/cards'; import { getCardDisplay, isJoker, displayName, playerName as lookupName } from '../../lib/cards';
export let state; export let state;
@@ -121,7 +121,7 @@
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;"> <select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
<option value="">Pick a crewmate...</option> <option value="">Pick a crewmate...</option>
{#each eligibleTaxTargets() as p} {#each eligibleTaxTargets() as p}
<option value={p.id}>{p.name}</option> <option value={p.id}>{displayName(p)}</option>
{/each} {/each}
</select> </select>
<button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button> <button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button>

View File

@@ -1,6 +1,6 @@
<script> <script>
import { apiRequest } from '../../lib/api'; import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker } from '../../lib/cards'; import { getCardDisplay, isJoker, displayName } from '../../lib/cards';
export let state; export let state;
@@ -43,6 +43,11 @@
<div class="card glass-panel sheet-card"> <div class="card glass-panel sheet-card">
<h3>🐀 {state.player.name}'s Character Sheet</h3> <h3>🐀 {state.player.name}'s Character Sheet</h3>
{#if state.player.player_name && state.player.player_name !== state.player.name}
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
Played by {state.player.player_name}{#if !state.player.completed_personal_2}&nbsp;— they'll go by their smell until they earn a Name!{/if}
</p>
{/if}
<div class="character-sheet-details"> <div class="character-sheet-details">
{#if error} {#if error}
@@ -104,7 +109,7 @@
<select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;"> <select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge whom?</option> <option value="">Challenge whom?</option>
{#each duelTargets as p} {#each duelTargets as p}
<option value={p.id}>{p.name} (Rank {p.rank})</option> <option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each} {/each}
</select> </select>
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;"> <select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">

View File

@@ -1,5 +1,6 @@
<script> <script>
import { apiRequest } from '../../lib/api'; import { apiRequest } from '../../lib/api';
import { displayName } from '../../lib/cards';
export let state; export let state;
@@ -75,7 +76,7 @@
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;"> <select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge which Pi-Rat?</option> <option value="">Challenge which Pi-Rat?</option>
{#each scenePirats.filter(p => !p.is_dead) as p} {#each scenePirats.filter(p => !p.is_dead) as p}
<option value={p.id}>{p.name} (Rank {p.rank})</option> <option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each} {/each}
</select> </select>
<div style="margin-bottom: 0.5rem;"> <div style="margin-bottom: 0.5rem;">
@@ -102,7 +103,7 @@
<select class="select-field" bind:value={captainSelectId} style="flex: 1;"> <select class="select-field" bind:value={captainSelectId} style="flex: 1;">
<option value="">No Captain</option> <option value="">No Captain</option>
{#each scenePirats.filter(p => !p.is_dead) as p} {#each scenePirats.filter(p => !p.is_dead) as p}
<option value={p.id}>{p.name}</option> <option value={p.id}>{displayName(p)}</option>
{/each} {/each}
</select> </select>
<button class="btn btn-secondary" on:click={setCaptain}>Set</button> <button class="btn btn-secondary" on:click={setCaptain}>Set</button>
@@ -132,7 +133,7 @@
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p> <p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
{#each scenePirats as p} {#each scenePirats as p}
<div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;"> <div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
<strong>{p.name}</strong> <strong>{displayName(p)}</strong>
<div class="objectives-checklist" style="margin-top: 0.25rem;"> <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_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_2} on:change={() => toggleObjective(p.id, 'personal_2')}> <span>2. Name</span></label>

View File

@@ -2,6 +2,21 @@
// Codes look like "10D", "KH", "Joker1" (black) / "Joker2" (red). // Codes look like "10D", "KH", "Joker1" (black) / "Joker2" (red).
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' }; export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
export const SUIT_THEMES = {
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
H: "♥ Hearts — Shoutin' and Singin': social skills, performance, or charisma",
};
// Default tooltip for a card: its suit theme (or what a Joker does).
export function cardTooltip(code) {
if (!code) return '';
if (isJoker(code)) return '🃏 Joker — discards an Obstacle outright and draws a replacement';
return SUIT_THEMES[cardSuit(code)] || '';
}
export function isJoker(code) { export function isJoker(code) {
return !!code && code.startsWith('Joker'); return !!code && code.startsWith('Joker');
} }
@@ -22,6 +37,14 @@ export function getCardDisplay(code) {
return `${cardValue(code)}${SUIT_EMOJI[cardSuit(code)] || cardSuit(code)}`; return `${cardValue(code)}${SUIT_EMOJI[cardSuit(code)] || cardSuit(code)}`;
} }
export function playerName(players, id) { // "Recruit Cheese (Tim)" — the Pi-Rat's identity with the human player's lobby
return players.find(p => p.id === id)?.name || '???'; // name in parentheses (omitted while the two are still identical, e.g. in the lobby).
export function displayName(p) {
if (!p) return '???';
if (p.player_name && p.player_name !== p.name) return `${p.name} (${p.player_name})`;
return p.name;
}
export function playerName(players, id) {
return displayName(players.find(p => p.id === id));
} }

View File

@@ -53,7 +53,8 @@
<table class="w-full text-left bg-dark-900 rounded border border-gray-700"> <table class="w-full text-left bg-dark-900 rounded border border-gray-700">
<thead> <thead>
<tr class="border-b border-gray-700"> <tr class="border-b border-gray-700">
<th class="p-3">Name</th> <th class="p-3">Pi-Rat</th>
<th class="p-3">Player</th>
<th class="p-3">Role</th> <th class="p-3">Role</th>
<th class="p-3">Status</th> <th class="p-3">Status</th>
<th class="p-3">Rank</th> <th class="p-3">Rank</th>
@@ -63,6 +64,7 @@
{#each data.players as p} {#each data.players as p}
<tr class="border-b border-gray-800"> <tr class="border-b border-gray-800">
<td class="p-3">{p.name}</td> <td class="p-3">{p.name}</td>
<td class="p-3">{p.player_name || p.name}</td>
<td class="p-3"> <td class="p-3">
{#if p.role === 'deep'} 🌊 Deep {#if p.role === 'deep'} 🌊 Deep
{:else if p.role === 'pirat'} 🐀 Pi-Rat {:else if p.role === 'pirat'} 🐀 Pi-Rat

View File

@@ -8,6 +8,7 @@
import ScenePhase from '../components/ScenePhase.svelte'; import ScenePhase from '../components/ScenePhase.svelte';
import UpkeepPhase from '../components/UpkeepPhase.svelte'; import UpkeepPhase from '../components/UpkeepPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.svelte'; import GameOverPhase from '../components/GameOverPhase.svelte';
import EventLog from '../components/EventLog.svelte';
export let params = {}; export let params = {};
let gameId = params.id; let gameId = params.id;
@@ -62,6 +63,12 @@
<div class="card p-4">Unknown phase: {state.game.phase}</div> <div class="card p-4">Unknown phase: {state.game.phase}</div>
{/if} {/if}
</div> </div>
<EventLog {state} />
{#if state.player.is_creator}
<a class="admin-corner-link" href="#/game/{gameId}/admin" title="Open the Admin Panel">🛠️ Admin</a>
{/if}
{:else if !error} {:else if !error}
<div class="loading-container"> <div class="loading-container">
<p>Loading game state...</p> <p>Loading game state...</p>
@@ -69,6 +76,24 @@
{/if} {/if}
<style> <style>
.admin-corner-link {
position: fixed;
top: 12px;
right: 16px;
z-index: 1001;
padding: 6px 14px;
border-radius: 16px;
background: rgba(10, 15, 25, 0.9);
border: 1px solid var(--gold, #d4af37);
color: var(--gold, #d4af37);
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
font-family: var(--font-heading);
}
.admin-corner-link:hover {
background: rgba(212, 175, 55, 0.15);
}
.loading-container { .loading-container {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -31,7 +31,11 @@
</div> </div>
<div class="card creation-card"> <div class="card creation-card">
<h2>Enter your Pi-Rat name</h2> <h2>Enter your name</h2>
<p class="info-text" style="margin-bottom: 1rem;">
This is <strong>your</strong> name as a player, not your Pi-Rat's. Your Pi-Rat will be known
by their smell (e.g. "Recruit Gunpowder") until they earn a proper Name in play.
</p>
<form on:submit|preventDefault={joinGame}> <form on:submit|preventDefault={joinGame}>
<div class="form-group"> <div class="form-group">
<label for="playerName">Your Name</label> <label for="playerName">Your Name</label>

View File

@@ -250,7 +250,8 @@ def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -
player = Player( player = Player(
game_id=game_id, game_id=game_id,
name=name, name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
player_name=name,
is_creator=is_creator, is_creator=is_creator,
rank=2, # default starting rank (will be set properly when starting character creation) rank=2, # default starting rank (will be set properly when starting character creation)
hand_cards="[]", hand_cards="[]",

View File

@@ -9,6 +9,7 @@ from .crud_base import (
draw_cards_for_player, add_game_event, change_player_rank, evaluate_card_play draw_cards_for_player, add_game_event, change_player_rank, evaluate_card_play
) )
from .crud_scene import resolve_card_against_obstacle from .crud_scene import resolve_card_against_obstacle
from .crud_character import recruit_name
# --- Helpers --- # --- Helpers ---
@@ -199,15 +200,18 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
thing = "Gat" if challenge.tax_type == "gat" else "Name" thing = "Gat" if challenge.tax_type == "gat" else "Name"
if refuser: if refuser:
if success: if success:
add_game_event(db, game.id, f"{acting.name} succeeded and keeps {refuser.name}'s {thing}! {refuser.name} must find a new one.", kind="tax") add_game_event(db, game.id, f"{acting.name} succeeded and keeps the stolen {thing}! {refuser.name} must find a new one.", kind="tax")
else: else:
if challenge.tax_type == "gat": if challenge.tax_type == "gat":
acting.completed_personal_1 = False acting.completed_personal_1 = False
refuser.completed_personal_1 = True refuser.completed_personal_1 = True
else: else:
# Return the stolen name string; the failed thief reverts to
# their smell-based recruit identity.
acting.completed_personal_2 = False acting.completed_personal_2 = False
acting.needs_name = False
refuser.completed_personal_2 = True refuser.completed_personal_2 = True
refuser.name = acting.name
acting.name = recruit_name(acting)
acting.tax_banned = True acting.tax_banned = True
db.add(refuser) db.add(refuser)
db.add(acting) db.add(acting)
@@ -332,17 +336,24 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
if challenge.tax_type == "gat": if challenge.tax_type == "gat":
requester.completed_personal_1 = True requester.completed_personal_1 = True
responder.completed_personal_1 = False responder.completed_personal_1 = False
event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!"
else: else:
# The Name itself is stolen: the requester literally takes the responder's
# name string, demoting the responder back to their smell-based identity.
stolen_name = responder.name
old_requester_name = requester.name
requester.completed_personal_2 = True requester.completed_personal_2 = True
requester.needs_name = True
responder.completed_personal_2 = False responder.completed_personal_2 = False
responder.name = recruit_name(responder)
requester.name = stolen_name
event_msg = f"{stolen_name} refused the Name Tax and is demoted to {responder.name}! {old_requester_name} steals the name '{stolen_name}' and attempts the Challenge — succeed to keep it!"
challenge.tax_state = "refused" challenge.tax_state = "refused"
db.add(requester) db.add(requester)
db.add(responder) db.add(responder)
db.add(challenge) db.add(challenge)
db.commit() db.commit()
change_player_rank(db, game, requester, 1) change_player_rank(db, game, requester, 1)
add_game_event(db, game.id, f"{responder.name} refused the {thing} Tax and must hand over their {thing}! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!", kind="tax") add_game_event(db, game.id, event_msg, kind="tax")
return True, "Tax refused. The prize is on the line!" return True, "Tax refused. The prize is on the line!"
# --- Pi-Rat vs Pi-Rat Challenges --- # --- Pi-Rat vs Pi-Rat Challenges ---

View File

@@ -6,6 +6,33 @@ from .crud_base import get_player, get_game, add_game_event
# --- Character Creation Operations --- # --- Character Creation Operations ---
def recruit_name_from_smell(avatar_smell: str, fallback: str = "") -> str:
"""
A nameless Pi-Rat goes by "Recruit {Smell}". Strips lead-ins like "smells like"
or "faintly of" (the suggestion pool uses these) so the name reads naturally.
"""
clean_smell = avatar_smell.strip()
if not clean_smell:
return f"Recruit {fallback}".strip() if fallback else "Recruit"
lead_ins = [
"suspiciously smelling of", "pleasantly smelling of", "reminiscent of",
"smells like", "a mix of", "like a mix of", "strongly of", "faintly of",
"like", "of",
]
stripped = True
while stripped:
stripped = False
for lead in lead_ins:
if clean_smell.lower().startswith(lead + " "):
clean_smell = clean_smell[len(lead) + 1:]
stripped = True
clean_smell = clean_smell[:1].upper() + clean_smell[1:]
return f"Recruit {clean_smell}"
def recruit_name(player) -> str:
"""The smell-based temporary identity for a player's Pi-Rat."""
return recruit_name_from_smell(player.avatar_smell, fallback=player.player_name or player.name)
def auto_delegate_all(db: Session, game: Game): def auto_delegate_all(db: Session, game: Game):
""" """
Assigns the Like/Hate delegated questions for every player at once. Assigns the Like/Hate delegated questions for every player at once.
@@ -281,23 +308,7 @@ def roll_new_character(
player.first_words = first_words.strip() player.first_words = first_words.strip()
player.good_at_math = good_at_math.strip() player.good_at_math = good_at_math.strip()
# Scent/Name is "Recruit [Smell]" — strip lead-ins like "smells like" or player.name = recruit_name(player)
# "faintly of" (the suggestion pool uses these) so the name reads naturally.
clean_smell = avatar_smell.strip()
lead_ins = [
"suspiciously smelling of", "pleasantly smelling of", "reminiscent of",
"smells like", "a mix of", "like a mix of", "strongly of", "faintly of",
"like", "of",
]
stripped = True
while stripped:
stripped = False
for lead in lead_ins:
if clean_smell.lower().startswith(lead + " "):
clean_smell = clean_smell[len(lead) + 1:]
stripped = True
clean_smell = clean_smell[:1].upper() + clean_smell[1:]
player.name = f"Recruit {clean_smell}"
# 3. Randomly assign 3 new secret techniques, avoiding any technique already # 3. Randomly assign 3 new secret techniques, avoiding any technique already
# in play on another player's sheet (collisions make for boring recruits). # in play on another player's sheet (collisions make for boring recruits).

View File

@@ -31,6 +31,7 @@ def create_db_and_tables():
("player", "is_dead", "BOOLEAN DEFAULT FALSE"), ("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"), ("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"), ("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
("player", "player_name", "VARCHAR DEFAULT ''"),
("gameevent", "kind", "VARCHAR DEFAULT 'info'"), ("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
] ]
@@ -40,6 +41,12 @@ def create_db_and_tables():
except Exception: except Exception:
pass pass
# Backfill: pre-existing players used a single name for both player and Pi-Rat
try:
session.execute(text("UPDATE player SET player_name = name WHERE player_name = '' OR player_name IS NULL"))
except Exception:
pass
session.commit() session.commit()
def get_session(): def get_session():

View File

@@ -25,7 +25,8 @@ class Game(SQLModel, table=True):
class Player(SQLModel, table=True): class Player(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
game_id: str = Field(foreign_key="game.id", index=True) game_id: str = Field(foreign_key="game.id", index=True)
name: str = Field(default="") name: str = Field(default="") # The Pi-Rat's current identity: "Recruit {smell}" until they earn a Name
player_name: str = Field(default="") # The human player's lobby name, shown alongside the Pi-Rat
is_ready: bool = Field(default=False) is_ready: bool = Field(default=False)
rank: int = Field(default=1) rank: int = Field(default=1)
hand_cards: str = Field(default="[]") # JSON string of list of card codes hand_cards: str = Field(default="[]") # JSON string of list of card codes

View File

@@ -21,6 +21,7 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
players_data.append({ players_data.append({
"id": p.id, "id": p.id,
"name": p.name, "name": p.name,
"player_name": p.player_name,
"rank": p.rank, "rank": p.rank,
"is_dead": p.is_dead, "is_dead": p.is_dead,
"is_ghost": p.is_ghost, "is_ghost": p.is_ghost,

View File

@@ -31,6 +31,9 @@ def player_save_basic_details(
player.avatar_smell = avatar_smell.strip() player.avatar_smell = avatar_smell.strip()
player.first_words = first_words.strip() player.first_words = first_words.strip()
player.good_at_math = good_at_math.strip() player.good_at_math = good_at_math.strip()
# Until a Pi-Rat earns a Name they are identified by their smell
if not player.completed_personal_2:
player.name = crud.recruit_name(player)
db.add(player) db.add(player)
db.commit() db.commit()

View File

@@ -756,6 +756,97 @@ def test_gat_tax_refuse_and_fail(session):
assert p2.rank == p2_start_rank assert p2.rank == p2_start_rank
assert p2.tax_banned assert p2.tax_banned
def test_name_tax_refuse_steals_name_string(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
obs = game.obstacles[0]
obs.current_value = 13
# p2 has a gat but no Name; p3 has both and a fancy name
p2.completed_personal_1 = True
p2.avatar_smell = "smells like burnt cheese"
p3.completed_personal_1 = True
p3.completed_personal_2 = True
p3.name = "Bloodfang the Unforgiven"
p3.avatar_smell = "faintly of gunpowder"
p2.hand_cards = json.dumps(["2C"])
session.add_all([obs, p2, p3])
session.commit()
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
assert ok
session.refresh(game)
challenge = game.challenges[0]
ok, msg = crud.request_tax(session, challenge.id, p2.id, p3.id)
assert ok, msg
assert challenge.tax_type == "name"
ok, msg = crud.respond_tax(session, challenge.id, p3.id, accept=False)
assert ok
session.refresh(p2)
session.refresh(p3)
# The name string itself is stolen; the refuser is demoted to their smell
assert p2.name == "Bloodfang the Unforgiven"
assert p2.completed_personal_2
assert not p2.needs_name
assert p3.name == "Recruit Gunpowder"
assert not p3.completed_personal_2
# The thief fails the challenge: the name returns and the thief is demoted
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "2C")
assert ok and not res["success"]
ok, msg = crud.resolve_challenge(session, challenge.id, deep.id)
assert ok
session.refresh(p2)
session.refresh(p3)
assert p3.name == "Bloodfang the Unforgiven"
assert p3.completed_personal_2
assert p2.name == "Recruit Burnt cheese"
assert not p2.completed_personal_2
assert p2.tax_banned
def test_name_tax_refuse_success_keeps_stolen_name(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
obs = game.obstacles[0]
obs.current_value = 1
p2.completed_personal_1 = True
p2.avatar_smell = "old rum"
p3.completed_personal_1 = True
p3.completed_personal_2 = True
p3.name = "Captain Calculus"
p3.avatar_smell = "chalk dust"
p2.hand_cards = json.dumps(["10C"])
session.add_all([obs, p2, p3])
session.commit()
ok, _ = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
assert ok
session.refresh(game)
challenge = game.challenges[0]
ok, _ = crud.request_tax(session, challenge.id, p2.id, p3.id)
assert ok
ok, _ = crud.respond_tax(session, challenge.id, p3.id, accept=False)
assert ok
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "10C")
assert ok and res["success"]
ok, _ = crud.resolve_challenge(session, challenge.id, deep.id)
assert ok
session.refresh(p2)
session.refresh(p3)
# Success: the stolen name stays stolen
assert p2.name == "Captain Calculus"
assert p2.completed_personal_2
assert p3.name == "Recruit Chalk dust"
assert not p3.completed_personal_2
def test_recruit_name_helper():
assert crud.recruit_name_from_smell("smells like burnt cheese") == "Recruit Burnt cheese"
assert crud.recruit_name_from_smell("faintly of gunpowder") == "Recruit Gunpowder"
assert crud.recruit_name_from_smell("") == "Recruit"
assert crud.recruit_name_from_smell("", fallback="Tim") == "Recruit Tim"
def test_pvp_challenge(session): def test_pvp_challenge(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
p2.hand_cards = json.dumps(["7C"]) p2.hand_cards = json.dumps(["7C"])