Fable will fix it! Pt 3
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { displayName } from '../lib/cards';
|
||||
|
||||
export let state;
|
||||
|
||||
@@ -49,7 +50,19 @@
|
||||
<div class="scene-setup-view text-center">
|
||||
<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'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} — 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;">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="[]",
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -31,15 +31,22 @@ 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'"),
|
||||
]
|
||||
|
||||
|
||||
for table, col, def_type in columns:
|
||||
try:
|
||||
session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}"))
|
||||
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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,9 +31,12 @@ 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()
|
||||
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user