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

@@ -1,5 +1,5 @@
<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 size = 'large'; // 'large' | 'medium' | 'mini'
@@ -16,8 +16,9 @@
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
$: techName = techs && !joker ? techs[cardValue(card)] : null;
// Tooltip: explicit title wins; otherwise remind the player of the suit's theme
$: tooltip = title || cardTooltip(card);
// Tooltip: explicit title wins; otherwise the suit's theme plus what the
// card would represent as an Obstacle (recomputes once the table loads)
$: tooltip = title || cardTooltip(card, $obstacleTable);
</script>
{#if size === 'mini'}

View File

@@ -1,6 +1,6 @@
<script>
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;
@@ -83,7 +83,7 @@
{/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!
Temporary Obstacle: <strong title={cardTooltip(ch.temp_card, $obstacleTable)}>{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;">
@@ -134,7 +134,7 @@
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
<div class="challenge-actions">
{#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}
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script>
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName } from '../../lib/cards';
import { getCardDisplay, isJoker, displayName, cardObstacleInfo, obstacleTable } from '../../lib/cards';
export let state;
@@ -12,6 +12,8 @@
$: 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);
// What the chosen duel card becomes as a temporary Obstacle for the defender
$: pvpCardObstacle = cardObstacleInfo(pvpCard, $obstacleTable);
async function submitNewName() {
if (!newNameInput.trim()) return;
@@ -122,9 +124,14 @@
<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>
<option value={card} title={cardObstacleInfo(card, $obstacleTable) ? `${cardObstacleInfo(card, $obstacleTable).title} ${cardObstacleInfo(card, $obstacleTable).description}` : ''}>{getCardDisplay(card)}</option>
{/each}
</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>
</div>
{/if}

View File

@@ -1,5 +1,8 @@
// Card-code helpers shared across components.
// 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: '♦️' };
// 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",
};
// Default tooltip for a card: its suit theme (or what a Joker does).
export function cardTooltip(code) {
// The rulebook's obstacle tables (what each card represents when it surfaces
// 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 (isJoker(code)) return '🃏 Joker — discards an Obstacle outright and draws a replacement';
return SUIT_THEMES[cardSuit(code)] || '';
if (isJoker(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) {

View File

@@ -10,6 +10,10 @@ import '@fontsource/alegreya-sans/700.css'
import './app.css'
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, {
target: document.getElementById('app'),