Compare commits

...

2 Commits

Author SHA1 Message Date
3a00774b50 Fable will fix it! Pt 3 2026-06-12 01:13:11 -07:00
6dcb2a9ae9 Commit todo file 2026-06-11 22:46:28 -07:00
22 changed files with 282 additions and 56 deletions

23
TODO.md Normal file
View File

@@ -0,0 +1,23 @@
## Major Improvements
- [ ] **Replace polling in the frontend with a websocket connection** I believe the 2s polling interval is an artifact of the previous HTMX-based frontend implementation, and the app might feel more responsive if it were websocket-based or otherwise event driven.
- [ ] **Allow Deep players to roll back game events.** This may require some updates to how game state is handled. Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current architecture at all.
## Missing Features
- [ ] **Smell-based temporary nicknames at game start.** When joining, players type an arbitrary lobby name; only re-rolled recruits get "Recruit {smell}". We should keep the lobby names, but be clear that these refer to the players, not the Pi-Rats, who should be identified by their smell until they earn a Name.
- [ ] **Tooltip reminders of card suit themes**. When you mouse over a card, there should be reminders that e.g. hearts are thematically social skills. Check the manual for the other suits.
- [ ] **Name string transfer on a refused Name Tax.** Mechanically the Objective (and Rank) transfer and the winner gets a "claim your name" prompt; this should be updated so that the stealer literally takes the other Pi-Rat's name, leaving them demoted to "Recruit {smell}".
- [ ] **First-scene framing requirement** ("must establish the Pi-Rats are on a ship they can steal; don't start far from water") is not surfaced anywhere in the scene-setup UI. We could add an interstitial phase that prompts the Deep to explain the scene before we move to the scene UI with the objectives.
- [ ] **Dead Pi-Rat re-roll flow.** I suspect this is badly broken, as it was implemented in a rush by an older model. In particular, other players will need to contribute likes/hates and techniques, which I believe are not currently implemented. This flow should also be used for new players who join after initial character creation, and should take place as a between-scenes phase after Deep Upkeep.
- [ ] **Event log visibility**. The event log should be visible in all phases, not just during a scene.
- [ ] **Admin page link**. The admin/creator player should always have a link to the admin page available to them.
## Worth Double-Checking / Minor
- [ ] **Dev/prod server flows.** Investigate where assets are served from in both development and production workflows. Make sure we're not committing any compiled svelte code. In production, the app should be used through the Nix flake, which should handle the compile step when the flake is built.
- [ ] **Refactor the CSS.** Make it easy to re-theme by keeping the definitions of primary/background/highlight/etc colors confined to a few specific styles. If this requires adopting some kind of CSS framework/preprocessing, so be it.
- [ ] **More formal database migration procedures.**
- [ ] **Rules endpoint** Make a nicely formatted HTML version of the rulebook. It should probably also be linked from a help menu somewhere that's always available, rather than only being surfaced on the splash page of the site (which joining players don't even see)
- [ ] **Re-styling**. The entire app could use a face lift, but in particular the splash page is ugly.

View File

@@ -1,5 +1,5 @@
<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 size = 'large'; // 'large' | 'medium' | 'mini'
@@ -16,17 +16,19 @@
$: 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);
</script>
{#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="suit">{suit}</span>
{#if owner}<span class="owner">{owner}</span>{/if}
</div>
{:else}
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
{title} {draggable} {style}
title={tooltip} {draggable} {style}
on:dragstart on:dragend on:click>
<div class="card-corner top-left">
<span class="val">{val}</span>

View File

@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { apiRequest } from '../lib/api';
import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions';
import { displayName } from '../lib/cards';
export let state;
@@ -42,7 +43,7 @@
function getPlayerName(id) {
if (!id) return "None";
const p = state.players.find(x => x.id === id);
return p ? p.name : "Unknown";
return p ? displayName(p) : "Unknown";
}
// Techniques
@@ -274,7 +275,7 @@
{#each state.players as p}
{#if p.other_like_from_player_id === state.player.id && !p.other_like}
<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">
<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>
@@ -284,7 +285,7 @@
{/if}
{#if p.other_hate_from_player_id === state.player.id && !p.other_hate}
<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">
<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>
@@ -512,7 +513,7 @@
<ul class="space-y-2 mt-4">
{#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'}">
<span>{p.name}</span>
<span>{displayName(p)}</span>
<span class="text-sm">
{#if p.tech_jack}
<span class="text-green-400">Ready</span>

View File

@@ -1,4 +1,6 @@
<script>
import { displayName } from '../lib/cards';
export let state;
$: 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;">
<span>
{#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}
</span>
<span style="font-size: 0.85rem; color: var(--text-muted);">

View File

@@ -1,10 +1,10 @@
<script>
import Card from './Card.svelte';
import { displayName } from '../lib/cards';
import ChallengePanel from './scene/ChallengePanel.svelte';
import ObstacleBoard from './scene/ObstacleBoard.svelte';
import DeepControlPanel from './scene/DeepControlPanel.svelte';
import CharacterSheet from './scene/CharacterSheet.svelte';
import EventLog from './EventLog.svelte';
export let state;
@@ -33,7 +33,7 @@
<div>
{#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;">
🏴‍☠️ Captain: {captain.name} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
🏴‍☠️ Captain: {displayName(captain)} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
</span>
{: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;">
@@ -48,11 +48,11 @@
{#each state.players as p}
{#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;">
🌊 {p.name} (Deep)
🌊 {displayName(p)} (Deep)
</span>
{: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);' : ''}">
{#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>
{/if}
{/each}
@@ -99,5 +99,3 @@
{/if}
</div>
</div>
<EventLog {state} />

View File

@@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte';
import { apiRequest } from '../lib/api';
import { displayName } from '../lib/cards';
export let state;
@@ -50,6 +51,18 @@
<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>
{#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">
<!-- Role Selection Card -->
<div class="card glass-panel role-selection-card">
@@ -93,7 +106,7 @@
<div class="roster-list" id="scene-roster-list">
{#each state.players as p}
<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'}
<span class="role-badge pirat">Pi-Rat</span>
{:else if p.role === 'deep'}

View File

@@ -1,6 +1,7 @@
<script>
import { apiRequest } from '../lib/api';
import { getSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards';
import Card from './Card.svelte';
export let state;
@@ -270,7 +271,7 @@
<option value="">Nominate crewmate...</option>
{#each state.players as p}
{#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}
{/each}
</select>
@@ -287,7 +288,7 @@
{#each state.players as p}
{@const voted = state.votes.some(v => v.voter_player_id === p.id)}
<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>
</div>
{/each}
@@ -372,7 +373,7 @@
<ul class="ranks-ledger">
{#each state.players as p}
<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}
</li>
{/each}

View File

@@ -1,6 +1,6 @@
<script>
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;
@@ -121,7 +121,7 @@
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
<option value="">Pick a crewmate...</option>
{#each eligibleTaxTargets() as p}
<option value={p.id}>{p.name}</option>
<option value={p.id}>{displayName(p)}</option>
{/each}
</select>
<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>
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker } from '../../lib/cards';
import { getCardDisplay, isJoker, displayName } from '../../lib/cards';
export let state;
@@ -43,6 +43,11 @@
<div class="card glass-panel sheet-card">
<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">
{#if error}
@@ -104,7 +109,7 @@
<select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge whom?</option>
{#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}
</select>
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">

View File

@@ -1,5 +1,6 @@
<script>
import { apiRequest } from '../../lib/api';
import { displayName } from '../../lib/cards';
export let state;
@@ -75,7 +76,7 @@
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge which Pi-Rat?</option>
{#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}
</select>
<div style="margin-bottom: 0.5rem;">
@@ -102,7 +103,7 @@
<select class="select-field" bind:value={captainSelectId} style="flex: 1;">
<option value="">No Captain</option>
{#each scenePirats.filter(p => !p.is_dead) as p}
<option value={p.id}>{p.name}</option>
<option value={p.id}>{displayName(p)}</option>
{/each}
</select>
<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>
{#each scenePirats as p}
<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;">
<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>

View File

@@ -2,6 +2,21 @@
// Codes look like "10D", "KH", "Joker1" (black) / "Joker2" (red).
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) {
return !!code && code.startsWith('Joker');
}
@@ -22,6 +37,14 @@ export function getCardDisplay(code) {
return `${cardValue(code)}${SUIT_EMOJI[cardSuit(code)] || cardSuit(code)}`;
}
export function playerName(players, id) {
return players.find(p => p.id === id)?.name || '???';
// "Recruit Cheese (Tim)" — the Pi-Rat's identity with the human player's lobby
// 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">
<thead>
<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">Status</th>
<th class="p-3">Rank</th>
@@ -63,6 +64,7 @@
{#each data.players as p}
<tr class="border-b border-gray-800">
<td class="p-3">{p.name}</td>
<td class="p-3">{p.player_name || p.name}</td>
<td class="p-3">
{#if p.role === 'deep'} 🌊 Deep
{:else if p.role === 'pirat'} 🐀 Pi-Rat

View File

@@ -8,6 +8,7 @@
import ScenePhase from '../components/ScenePhase.svelte';
import UpkeepPhase from '../components/UpkeepPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.svelte';
import EventLog from '../components/EventLog.svelte';
export let params = {};
let gameId = params.id;
@@ -62,6 +63,12 @@
<div class="card p-4">Unknown phase: {state.game.phase}</div>
{/if}
</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}
<div class="loading-container">
<p>Loading game state...</p>
@@ -69,6 +76,24 @@
{/if}
<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 {
display: flex;
justify-content: center;

View File

@@ -31,7 +31,11 @@
</div>
<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}>
<div class="form-group">
<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(
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,
rank=2, # default starting rank (will be set properly when starting character creation)
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
)
from .crud_scene import resolve_card_against_obstacle
from .crud_character import recruit_name
# --- 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"
if refuser:
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:
if challenge.tax_type == "gat":
acting.completed_personal_1 = False
refuser.completed_personal_1 = True
else:
# Return the stolen name string; the failed thief reverts to
# their smell-based recruit identity.
acting.completed_personal_2 = False
acting.needs_name = False
refuser.completed_personal_2 = True
refuser.name = acting.name
acting.name = recruit_name(acting)
acting.tax_banned = True
db.add(refuser)
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":
requester.completed_personal_1 = True
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:
# 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.needs_name = True
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"
db.add(requester)
db.add(responder)
db.add(challenge)
db.commit()
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!"
# --- 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 ---
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):
"""
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.good_at_math = good_at_math.strip()
# Scent/Name is "Recruit [Smell]" — strip lead-ins like "smells like" or
# "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}"
player.name = recruit_name(player)
# 3. Randomly assign 3 new secret techniques, avoiding any technique already
# 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_ghost", "BOOLEAN DEFAULT FALSE"),
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
("player", "player_name", "VARCHAR DEFAULT ''"),
("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
]
@@ -40,6 +41,12 @@ def create_db_and_tables():
except Exception:
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()
def get_session():

View File

@@ -25,7 +25,8 @@ class Game(SQLModel, table=True):
class Player(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=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)
rank: int = Field(default=1)
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({
"id": p.id,
"name": p.name,
"player_name": p.player_name,
"rank": p.rank,
"is_dead": p.is_dead,
"is_ghost": p.is_ghost,

View File

@@ -31,6 +31,9 @@ def player_save_basic_details(
player.avatar_smell = avatar_smell.strip()
player.first_words = first_words.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.commit()

View File

@@ -756,6 +756,97 @@ def test_gat_tax_refuse_and_fail(session):
assert p2.rank == p2_start_rank
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):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
p2.hand_cards = json.dumps(["7C"])