More detailed card tooltips

Card tooltips now show what a card represents *as* an Obstacle (the
rulebook obstacle table entry) in addition to its suit theme, which is
relevant when challenging another Pi-Rat. The obstacle tables are served
from the backend via GET /api/obstacles (single source of truth in
cards.py) and cached client-side in cards.js, with a retry so a transient
first-load failure doesn't silently disable the tooltips.

Enriched everywhere cards appear in the challenge flow: hand/board cards
(Card.svelte), PvP defend buttons and the temporary-obstacle line
(ChallengePanel), and the duel card selector, which also shows an
inline description of what the chosen card becomes as an Obstacle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:05:20 -07:00
parent 952b1be872
commit e502155af7
7 changed files with 77 additions and 13 deletions

View File

@@ -5,7 +5,6 @@
## Minor Gameplay Polish ## Minor Gameplay Polish
- [ ] **More detailed card tooltips.** Currently cards have a tooltip that show what they represent when played on obstacles, but they don't show what they represent *as* obstacles, which is relevant when issuing challenges to other Pi-Rats.
- [ ] **Add invite link to Admin panel** - [ ] **Add invite link to Admin panel**
- [ ] **Add crew/player/rat name to page title, if available**. This will make it easier to distinguish tabs/browser history entries - [ ] **Add crew/player/rat name to page title, if available**. This will make it easier to distinguish tabs/browser history entries
- [ ] **Save active sessions in browser local storage**. Whenever you create or join a game, your browser local storage should be updated with the game ID, player ID, crew name, and player/rat names (if available). Going to the home page with saved sessions should provide a menu of games to rejoin in addition to the ability to start a new game. It should be possible to delete entries from this list, which only affects the browser local storage, not the backend database, and is undoable (the entry doesn't disappear immediately, it's just marked as deleted until page refresh). - [ ] **Save active sessions in browser local storage**. Whenever you create or join a game, your browser local storage should be updated with the game ID, player ID, crew name, and player/rat names (if available). Going to the home page with saved sessions should provide a menu of games to rejoin in addition to the ability to start a new game. It should be possible to delete entries from this list, which only affects the browser local storage, not the backend database, and is undoable (the entry doesn't disappear immediately, it's just marked as deleted until page refresh).

View File

@@ -1,5 +1,5 @@
<script> <script>
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip } from '../lib/cards'; import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, obstacleTable } 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,8 +16,9 @@
$: 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: explicit title wins; otherwise the suit's theme plus what the
$: tooltip = title || cardTooltip(card); // card would represent as an Obstacle (recomputes once the table loads)
$: tooltip = title || cardTooltip(card, $obstacleTable);
</script> </script>
{#if size === 'mini'} {#if size === 'mini'}

View File

@@ -1,6 +1,6 @@
<script> <script>
import { apiRequest } from '../../lib/api'; import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName, playerName as lookupName } from '../../lib/cards'; import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltip, obstacleTable } from '../../lib/cards';
export let state; export let state;
@@ -83,7 +83,7 @@
{/if} {/if}
{#if ch.challenge_type === 'pvp'} {#if ch.challenge_type === 'pvp'}
<p class="info-text" style="margin: 0.5rem 0 0 0;"> <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! Temporary Obstacle: <strong title={cardTooltip(ch.temp_card, $obstacleTable)}>{getCardDisplay(ch.temp_card)}</strong>{playerName(ch.acting_player_id)} must answer it!
</p> </p>
{:else} {:else}
<p class="info-text" style="margin: 0.5rem 0 0 0;"> <p class="info-text" style="margin: 0.5rem 0 0 0;">
@@ -134,7 +134,7 @@
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p> <p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
<div class="challenge-actions"> <div class="challenge-actions">
{#each hand.filter(c => !isJoker(c)) as card} {#each hand.filter(c => !isJoker(c)) as card}
<button class="btn btn-secondary" on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button> <button class="btn btn-secondary" title={cardTooltip(card, $obstacleTable)} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
{/each} {/each}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
<script> <script>
import { apiRequest } from '../../lib/api'; import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName } from '../../lib/cards'; import { getCardDisplay, isJoker, displayName, cardObstacleInfo, obstacleTable } from '../../lib/cards';
export let state; export let state;
@@ -12,6 +12,8 @@
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; $: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: scenePirats = state.players.filter(p => p.role === 'pirat'); $: scenePirats = state.players.filter(p => p.role === 'pirat');
$: duelTargets = scenePirats.filter(p => p.id !== state.player.id && !p.is_dead); $: duelTargets = scenePirats.filter(p => p.id !== state.player.id && !p.is_dead);
// What the chosen duel card becomes as a temporary Obstacle for the defender
$: pvpCardObstacle = cardObstacleInfo(pvpCard, $obstacleTable);
async function submitNewName() { async function submitNewName() {
if (!newNameInput.trim()) return; if (!newNameInput.trim()) return;
@@ -122,9 +124,14 @@
<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;">
<option value="">Throw which card?</option> <option value="">Throw which card?</option>
{#each hand.filter(c => !isJoker(c)) as card} {#each hand.filter(c => !isJoker(c)) as card}
<option value={card}>{getCardDisplay(card)}</option> <option value={card} title={cardObstacleInfo(card, $obstacleTable) ? `${cardObstacleInfo(card, $obstacleTable).title} ${cardObstacleInfo(card, $obstacleTable).description}` : ''}>{getCardDisplay(card)}</option>
{/each} {/each}
</select> </select>
{#if pvpCardObstacle}
<p class="info-text" style="font-size: 0.8rem; margin: -0.25rem 0 0.5rem 0;">
As an Obstacle, {getCardDisplay(pvpCard)} is <strong>{pvpCardObstacle.title}</strong>: {pvpCardObstacle.description}
</p>
{/if}
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button> <button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
</div> </div>
{/if} {/if}

View File

@@ -1,5 +1,8 @@
// Card-code helpers shared across components. // Card-code helpers shared across components.
// Codes look like "10D", "KH", "Joker1" (black) / "Joker2" (red). // Codes look like "10D", "KH", "Joker1" (black) / "Joker2" (red).
import { writable } from 'svelte/store';
import { apiRequest } from './api';
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. // Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
@@ -10,11 +13,49 @@ export const SUIT_THEMES = {
H: "♥ Hearts — Shoutin' and Singin': social skills, performance, or charisma", H: "♥ Hearts — Shoutin' and Singin': social skills, performance, or charisma",
}; };
// Default tooltip for a card: its suit theme (or what a Joker does). // The rulebook's obstacle tables (what each card represents when it surfaces
export function cardTooltip(code) { // as an Obstacle), served by the backend so there's a single source of truth.
// Loaded once at startup; until it arrives tooltips fall back to suit themes.
let OBSTACLE_TABLE = null;
export const obstacleTable = writable(null);
export async function loadObstacleTable(retries = 5) {
// Static rulebook data; retry on transient failures so a single blip on
// first load doesn't silently disable obstacle tooltips until a refresh.
while (!OBSTACLE_TABLE && retries-- > 0) {
try {
OBSTACLE_TABLE = (await apiRequest('/obstacles')).obstacles;
obstacleTable.set(OBSTACLE_TABLE);
} catch (e) {
if (retries <= 0) {
console.error('Failed to load obstacle table', e);
} else {
await new Promise(r => setTimeout(r, 1000));
}
}
}
return OBSTACLE_TABLE;
}
// What a card represents when it surfaces as an Obstacle, or null if unknown
// (Jokers / table not loaded). Pass the obstacleTable store value as `table`.
export function cardObstacleInfo(code, table = OBSTACLE_TABLE) {
if (!code || isJoker(code)) return null;
return table?.[cardSuit(code)]?.[cardValue(code)] || null;
}
// Default tooltip for a card: its suit theme when played, plus what it
// represents as an Obstacle (relevant when challenging another Pi-Rat).
// Pass the obstacleTable store's value as `table` to recompute reactively.
export function cardTooltip(code, table = OBSTACLE_TABLE) {
if (!code) return ''; if (!code) return '';
if (isJoker(code)) return '🃏 Joker — discards an Obstacle outright and draws a replacement'; if (isJoker(code)) {
return SUIT_THEMES[cardSuit(code)] || ''; return '🃏 Joker — played: discards an Obstacle (and its column) outright and draws a replacement.\n'
+ 'As an Obstacle: discarded, but future scenes permanently require one more Obstacle.';
}
const theme = SUIT_THEMES[cardSuit(code)] || '';
const obstacle = table?.[cardSuit(code)]?.[cardValue(code)];
if (!obstacle) return theme;
return `${theme}\nAs an Obstacle: ${obstacle.title}${obstacle.description}`;
} }
export function isJoker(code) { export function isJoker(code) {

View File

@@ -10,6 +10,10 @@ import '@fontsource/alegreya-sans/700.css'
import './app.css' import './app.css'
import App from './App.svelte' import App from './App.svelte'
import { loadObstacleTable } from './lib/cards'
// Warm the obstacle-table cache so card tooltips can describe cards as Obstacles
loadObstacleTable()
const app = mount(App, { const app = mount(App, {
target: document.getElementById('app'), target: document.getElementById('app'),

View File

@@ -3,9 +3,21 @@ from fastapi.responses import JSONResponse
from sqlmodel import Session from sqlmodel import Session
from .database import get_session from .database import get_session
from . import crud from . import crud
from .cards import OBSTACLES_DATA
router = APIRouter() router = APIRouter()
# The rulebook's obstacle tables: what every card represents when drawn as an
# Obstacle (used by the frontend for card tooltips)
@router.get("/obstacles")
def obstacle_table():
return {
"obstacles": {
suit: {val: {"title": t, "description": d} for val, (t, d) in table.items()}
for suit, table in OBSTACLES_DATA.items()
}
}
# Set Role during Scene Setup # Set Role during Scene Setup
@router.post("/game/{game_id}/player/{player_id}/set-role") @router.post("/game/{game_id}/player/{player_id}/set-role")
def player_set_role( def player_set_role(