Compare commits
2 Commits
a074ff77b1
...
3a00774b50
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a00774b50 | |||
| 6dcb2a9ae9 |
23
TODO.md
Normal file
23
TODO.md
Normal 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.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);">
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} — 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;">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="[]",
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user