Compare commits

..

7 Commits

Author SHA1 Message Date
e7db19f27e Let players voluntarily leave a game
Adds a "🚪 Leave Game" option to the corner menu for every player, so someone
who's done can remove themselves instead of waiting for an admin to kick them.

leave_game reuses the kick mechanics (cards to discard, reference cleanup,
phase re-check) via a shared _remove_player helper; it logs a friendlier
"left the crew" event and, like kick, refuses the last Admin so the table
isn't orphaned. The self-service route POST /game/{gid}/player/{pid}/leave
lives next to join and authenticates on the player's own id (no admin key).
The handler confirms, forgets the saved session, and routes home.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:58:07 -07:00
428af784e3 Add player kicking for admins
Admins can now kick players from the Admin panel so a vanished player can't
stall the table. crud.kick_player removes a player mid-game: it drops their
captaincy, deletes their votes and any Challenge they're part of, and repairs
every other player's pointers to them (pending swap offers are cleared;
delegated Like/Hate questions and recruit technique slots are handed to a
present crewmate, or pool-filled when no one is left). The row is then deleted
— a kicked player's hand simply stops being "in play", so it returns to the
discard automatically. Finally recheck_phase_completion advances the phase if
the kicked player was the last thing it was waiting on.

Auth mirrors set-admin (admin-key on POST /api/game/{gid}/admin/kick). The one
guard is that the last remaining Admin can't be kicked, so the table is never
locked out of its own panel; a vanished creator can still be kicked once a
co-admin exists. The Admin panel gains a per-row Kick button (with a confirm),
shown as "—" for the last admin.

To support re-checking phase completion after a kick, each phase's
"everyone's done -> advance" gate was extracted into an idempotent maybe_*
helper, and recheck_phase_completion dispatches the right one per Game.phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:48:03 -07:00
d04e5013fa Streamline dead Pi-Rat rank voting
Dead and awaiting-recruit Pi-Rats can no longer be nominated to rank up
(they'd reset to Rank 1 anyway). submit_rank_vote rejects them, and the
upkeep dropdown only lists living, in-play crewmates via the shared
eligible_vote_nominees helper.

When a voter has no eligible nominees (e.g. a lone survivor whose only
crewmates are the previous Deep and a dead rat) they now skip voting
entirely and can ready up directly, instead of deadlocking — which is
why dead players were previously left nominable. The roster shows such
players as "No vote".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:21:38 -07:00
345ef4088f Save active sessions in browser local storage
Joining a game now records the session (game ID, player ID, plus
player/rat/crew names) in localStorage via the new lib/sessions.js, and
the dashboard keeps it enriched as state loads (so the rat name tracks
earned Names). The home page lists saved sessions in a "Rejoin a Crew"
menu, most-recent first, alongside creating a new game.

Deleting an entry only touches localStorage, never the backend, and is
undoable: the row greys out with an Undo button and is dropped from
storage immediately, so a page refresh finalizes the removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:14:27 -07:00
d68333ec05 Add crew/player/rat name to page title
Each routed page now sets a descriptive document.title so tabs and
browser history entries are distinguishable: the dashboard shows the
Pi-Rat (and human player) name plus crew, the admin panel shows
"Admin · {crew}", the join page is labeled, and home resets to the
app name. New lib/title.js helper composes "{parts} — Rats with Gats".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:09:36 -07:00
fb17c632b0 Add invite link to Admin panel
The Admin panel now shows the crew's join link (origin/#/game/{id}/join)
with Open and Copy buttons, so an admin can invite new players without
returning to the lobby. Matches the existing rejoin-link copy pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:07:28 -07:00
e502155af7 More detailed card tooltips
Card tooltips now show what a card represents *as* an Obstacle (the
rulebook obstacle table entry) in addition to its suit theme, which is
relevant when challenging another Pi-Rat. The obstacle tables are served
from the backend via GET /api/obstacles (single source of truth in
cards.py) and cached client-side in cards.js, with a retry so a transient
first-load failure doesn't silently disable the tooltips.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:05:20 -07:00
20 changed files with 941 additions and 93 deletions

10
TODO.md
View File

@@ -1,16 +1,6 @@
## Major features
- [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin.
- [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes. Admins can kick other admins. A kicked player's cards go to the discard.
## Minor Gameplay Polish
- [ ] **More detailed card tooltips.** Currently cards have a tooltip that show what they represent when played on obstacles, but they don't show what they represent *as* obstacles, which is relevant when issuing challenges to other Pi-Rats.
- [ ] **Add invite link to Admin panel**
- [ ] **Add crew/player/rat name to page title, if available**. This will make it easier to distinguish tabs/browser history entries
- [ ] **Save active sessions in browser local storage**. Whenever you create or join a game, your browser local storage should be updated with the game ID, player ID, crew name, and player/rat names (if available). Going to the home page with saved sessions should provide a menu of games to rejoin in addition to the ability to start a new game. It should be possible to delete entries from this list, which only affects the browser local storage, not the backend database, and is undoable (the entry doesn't disappear immediately, it's just marked as deleted until page refresh).
- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely.
## Words Words Words
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.

View File

@@ -1,5 +1,5 @@
<script>
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip } from '../lib/cards';
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, obstacleTable } from '../lib/cards';
export let card; // card code, e.g. "10D" or "Joker1"
export let size = 'large'; // 'large' | 'medium' | 'mini'
@@ -16,8 +16,9 @@
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
$: techName = techs && !joker ? techs[cardValue(card)] : null;
// Tooltip: explicit title wins; otherwise remind the player of the suit's theme
$: tooltip = title || cardTooltip(card);
// Tooltip: explicit title wins; otherwise the suit's theme plus what the
// card would represent as an Obstacle (recomputes once the table loads)
$: tooltip = title || cardTooltip(card, $obstacleTable);
</script>
{#if size === 'mini'}

View File

@@ -14,6 +14,19 @@
// Helpers
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
// A nominee must be another living, in-play Pi-Rat: not the voter, not a
// previous-scene Deep, and not dead / awaiting-recruit (they'd reset to
// Rank 1 anyway). Mirrors crud_upkeep.is_eligible_nominee.
function eligibleNomineesFor(voter) {
return state.players.filter(p =>
p.id !== voter.id && p.role !== 'deep' && !p.is_dead && !p.needs_reroll
);
}
$: myNominees = eligibleNomineesFor(state.player);
$: hasVoted = !!state.votes.find(v => v.voter_player_id === state.player.id);
// With no eligible crewmates, the voter skips voting entirely (no deadlock).
$: mustVote = myNominees.length > 0;
// Drag & Drop State
let keepCards = [];
let discardCards = [];
@@ -191,19 +204,21 @@
<div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Voting complete! Results tallied.</p>
</div>
{:else if state.votes.find(v => v.voter_player_id === state.player.id)}
{:else if hasVoted}
<div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
</div>
{:else if !mustVote}
<div class="voted-confirmation-box glass-panel">
<p class="info-text">No living crewmates left to nominate — you skip voting this round. Go ahead and ready up.</p>
</div>
{:else}
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
<div class="form-group inline-group">
<select class="select-field" bind:value={selectedVoteId} required>
<option value="">Nominate crewmate...</option>
{#each state.players as p}
{#if p.id !== state.player.id && p.role !== 'deep'}
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/if}
{#each myNominees as p}
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each}
</select>
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button>
@@ -218,9 +233,10 @@
<div class="player-chips list-chips" id="voting-roster">
{#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'}">
{@const skips = eligibleNomineesFor(p).length === 0}
<div class="player-chip {voted || skips ? 'voted-chip' : 'not-voted-chip'}">
<span class="name">{displayName(p)}</span>
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span>
<span class="status-badge">{voted ? 'Done' : skips ? 'No vote' : 'Voting...'}</span>
</div>
{/each}
</div>
@@ -325,7 +341,7 @@
</div>
{/if}
{:else}
{#if !state.votes.find(v => v.voter_player_id === state.player.id)}
{#if mustVote && !hasVoted}
<p class="info-text gold-text" style="margin-bottom:0.5rem;">⚠️ You must nominate a crewmate above before you can ready up.</p>
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
{:else}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
// Tracks the games this browser has joined so the home page can offer a
// "rejoin" menu. Stored in localStorage only — deleting an entry never touches
// the backend. Each entry is keyed by gameId + playerId:
// { gameId, playerId, crewName, playerName, ratName, updatedAt }
const KEY = 'pirats-sessions';
function load() {
try {
const parsed = JSON.parse(localStorage.getItem(KEY));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function persist(list) {
localStorage.setItem(KEY, JSON.stringify(list));
}
function sameSession(a, gameId, playerId) {
return a.gameId === gameId && a.playerId === playerId;
}
// Most-recently-updated first.
export function getSessions() {
return load().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
}
// Insert or merge a session. Only defined fields overwrite existing values, so
// a sparse update (e.g. just a playerName at join time) won't clobber names
// filled in later from the full game state.
export function saveSession(fields) {
const { gameId, playerId } = fields;
if (!gameId || !playerId) return;
const list = load();
const idx = list.findIndex(s => sameSession(s, gameId, playerId));
const incoming = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && v !== null));
const merged = { ...(idx >= 0 ? list[idx] : {}), ...incoming, updatedAt: Date.now() };
if (idx >= 0) list[idx] = merged;
else list.push(merged);
persist(list);
}
export function removeSession(gameId, playerId) {
persist(load().filter(s => !sameSession(s, gameId, playerId)));
}

View File

@@ -0,0 +1,9 @@
// Sets document.title to a " · "-joined set of page specifics followed by the
// app name, so tabs and browser history entries are easy to tell apart.
// Falsy parts are dropped; with no parts the title is just the app name.
const BASE = 'Rats with Gats';
export function setPageTitle(...parts) {
const prefix = parts.filter(Boolean).join(' · ');
document.title = prefix ? `${prefix}${BASE}` : BASE;
}

View File

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

View File

@@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte';
import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title';
export let params = {};
let gameId = params.id;
@@ -10,11 +11,19 @@
let error = '';
let copiedPlayerId = null;
let copiedTimer = null;
let copiedInvite = false;
let copiedInviteTimer = null;
$: setPageTitle('Admin', data?.game?.crew_name);
function rejoinLink(playerId) {
return `${window.location.origin}/#/game/${gameId}/player/${playerId}`;
}
function inviteLink() {
return `${window.location.origin}/#/game/${gameId}/join`;
}
function copyRejoinLink(playerId) {
navigator.clipboard.writeText(rejoinLink(playerId));
copiedPlayerId = playerId;
@@ -22,6 +31,13 @@
copiedTimer = setTimeout(() => { copiedPlayerId = null; }, 2000);
}
function copyInviteLink() {
navigator.clipboard.writeText(inviteLink());
copiedInvite = true;
clearTimeout(copiedInviteTimer);
copiedInviteTimer = setTimeout(() => { copiedInvite = false; }, 2000);
}
onMount(() => {
// Try to load admin_key from local storage
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
@@ -53,6 +69,26 @@
error = err.message;
}
}
// The last remaining Admin can't be kicked, so the table is never locked out.
$: adminCount = data ? data.players.filter((p) => p.is_admin).length : 0;
function canKick(p) {
return !(p.is_admin && adminCount <= 1);
}
async function kickPlayer(p) {
const who = p.player_name || p.name;
if (!confirm(`Kick ${who} from the game? Their cards go to the discard and they'll have to rejoin. This can't be undone.`)) return;
try {
await apiRequest(`/game/${gameId}/admin/kick`, 'POST', {
key: adminKey,
target_player_id: p.id,
});
await loadAdminData();
} catch (err) {
error = err.message;
}
}
</script>
<div class="admin-page">
@@ -75,6 +111,20 @@
<p><strong>Phase:</strong> {data.game.phase}</p>
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
{#if error}
<div class="alert alert-danger mt-4">{error}</div>
{/if}
<h3 class="mt-8 mb-4">Invite Link</h3>
<p class="info-text">Share this link to let new players join the crew.</p>
<div class="link-copy-action">
<input type="text" readonly value={inviteLink()} class="input-field flex-grow" />
<a href={inviteLink()} class="btn btn-secondary btn-small">Open</a>
<button on:click={copyInviteLink} class="btn btn-secondary btn-small">
{copiedInvite ? '✓ Copied' : 'Copy'}
</button>
</div>
<h3 class="mt-8 mb-4">Players</h3>
<table class="admin-table">
<thead>
@@ -86,6 +136,7 @@
<th>Rank</th>
<th>Admin</th>
<th>Re-join Link</th>
<th>Kick</th>
</tr>
</thead>
<tbody>
@@ -123,6 +174,13 @@
</button>
</div>
</td>
<td>
{#if canKick(p)}
<button on:click={() => kickPlayer(p)} class="btn btn-danger btn-small">Kick</button>
{:else}
<span class="text-muted"></span>
{/if}
</td>
</tr>
{/each}
</tbody>

View File

@@ -1,8 +1,12 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api';
import { extraMenuLinks } from '../lib/menu';
import { getSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards';
import { setPageTitle } from '../lib/title';
import { saveSession, removeSession } from '../lib/sessions';
import LobbyPhase from '../components/LobbyPhase.svelte';
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
@@ -103,6 +107,33 @@
}
}
// Voluntarily remove yourself from the game (cards go to the discard)
async function leaveGame() {
if (!confirm("Leave this game? Your cards go to the discard and you'll need a new invite to rejoin. This can't be undone.")) return;
try {
await apiRequest(`/game/${gameId}/player/${playerId}/leave`, 'POST');
removeSession(gameId, playerId);
push('/');
} catch (err) {
error = err.message;
}
}
// Distinguish tabs/history by the Pi-Rat (and crew) this dashboard is for
$: setPageTitle(state ? displayName(state.player) : null, state?.game?.crew_name);
// Keep this browser's saved session up to date so the home page can rejoin
// with current crew/rat names (the rat name changes when a Name is earned)
$: if (state?.game && state?.player) {
saveSession({
gameId,
playerId,
crewName: state.game.crew_name,
playerName: state.player.player_name,
ratName: state.player.name,
});
}
// Admins get the admin key in their state blob; stash it so the Admin page works for them too
$: if (state?.player?.is_admin && state?.game?.admin_key) {
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);
@@ -132,6 +163,13 @@
}
items.push({ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' });
}
if (state?.player) {
items.push({
label: '🚪 Leave Game',
action: leaveGame,
title: 'Remove yourself from this game (your cards go to the discard)',
});
}
extraMenuLinks.set(items);
}

View File

@@ -1,11 +1,43 @@
<script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title';
import { getSessions, saveSession, removeSession } from '../lib/sessions';
let sessions = [];
onMount(() => {
setPageTitle();
sessions = getSessions();
});
let crewName = '';
let error = '';
let creating = false;
function sessionRat(s) {
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
return s.ratName || s.playerName || 'Unknown rat';
}
// Delete is a soft, undoable removal: drop it from localStorage right away
// (so a refresh finalizes it) but keep the greyed row visible with an Undo.
function deleteSession(s) {
removeSession(s.gameId, s.playerId);
s.deleted = true;
sessions = sessions;
}
function undoDelete(s) {
saveSession({
gameId: s.gameId, playerId: s.playerId,
crewName: s.crewName, playerName: s.playerName, ratName: s.ratName,
});
s.deleted = false;
sessions = sessions;
}
async function createGame() {
if (!crewName.trim()) return;
creating = true;
@@ -58,7 +90,80 @@
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
</div>
{#if sessions.length > 0}
<div class="glass-panel creation-card saved-sessions">
<h2>Rejoin a Crew</h2>
<p class="info-text" style="margin-bottom: 1rem;">Games you've joined from this browser. Removing one only forgets it here — it stays in the game.</p>
<ul class="session-list">
{#each sessions as s (s.gameId + s.playerId)}
<li class="session-row {s.deleted ? 'is-deleted' : ''}">
<div class="session-info">
<span class="session-crew">{s.crewName || 'Unnamed Crew'}</span>
<span class="session-rat text-muted">{sessionRat(s)}</span>
</div>
{#if s.deleted}
<div class="session-actions">
<span class="text-muted italic">Removed</span>
<button class="btn btn-secondary btn-small" on:click={() => undoDelete(s)}>Undo</button>
</div>
{:else}
<div class="session-actions">
<a href="#/game/{s.gameId}/player/{s.playerId}" class="btn btn-primary btn-small">Rejoin</a>
<button class="btn btn-secondary btn-small" title="Forget this session" on:click={() => deleteSession(s)}>✕</button>
</div>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
<div class="welcome-links">
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
</div>
</div>
<style>
.session-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.session-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.6rem 0.85rem;
background: var(--well);
border: 1px solid var(--edge-soft);
border-radius: var(--radius-md);
}
.session-row.is-deleted {
opacity: 0.5;
}
.session-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.session-crew {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-rat {
font-size: 0.85rem;
}
.session-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
</style>

View File

@@ -1,7 +1,12 @@
<script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title';
import { saveSession } from '../lib/sessions';
export let params = {};
onMount(() => setPageTitle('Join the Crew'));
let gameId = params.id;
let playerName = '';
let error = '';
@@ -17,6 +22,9 @@
error = '';
try {
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
// Remember this session so the home page can offer a rejoin link;
// the dashboard enriches it with crew/rat names once state loads.
saveSession({ gameId, playerId: data.id, playerName });
push(`/game/${gameId}/player/${data.id}`);
} catch (err) {
error = err.message;

View File

@@ -113,16 +113,7 @@ def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3
# Check if all players have submitted techniques. If so, trigger the swap!
if not game:
return None
all_submitted = True
for p in swap_participants(game):
techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs):
all_submitted = False
break
if all_submitted:
trigger_technique_swap(db, game)
maybe_trigger_technique_swap(db, game)
return None
def unsubmit_techniques(db: Session, player_id: str):
@@ -171,6 +162,18 @@ def swap_participants(game: Game) -> list:
def holds_own_technique(player) -> bool:
return any(t["creator_id"] == player.id for t in json.loads(player.held_techniques))
def maybe_trigger_technique_swap(db: Session, game: Game):
"""Begin the swap phase once every participant has submitted 3 techniques.
Safe to call after a roster change (e.g. a kick) to unblock the phase."""
participants = swap_participants(game)
if not participants:
return
for p in participants:
techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs):
return
trigger_technique_swap(db, game)
def trigger_technique_swap(db: Session, game: Game):
"""
All techniques are in: everyone enters the swap phase holding their own 3
@@ -325,11 +328,16 @@ def set_swap_ready(db: Session, player_id: str, ready: bool):
db.add(player)
db.commit()
participants = swap_participants(game)
if ready and participants and all(p.is_ready for p in participants):
finish_technique_swap(db, game)
maybe_finish_technique_swap(db, game)
return True, "Ready!" if ready else "No longer ready."
def maybe_finish_technique_swap(db: Session, game: Game):
"""Advance to face-card assignment once everyone has readied up in the swap
phase. Safe to call after a roster change to unblock the phase."""
participants = swap_participants(game)
if participants and all(p.is_ready for p in participants):
finish_technique_swap(db, game)
def finish_technique_swap(db: Session, game: Game):
"""Locks in everyone's held techniques and advances to J/Q/K assignment."""
for p in swap_participants(game):
@@ -417,6 +425,49 @@ def auto_assign_techniques(db: Session, game: Game):
return False, "Auto-assignment couldn't finish; check the event log."
return True, "Techniques auto-assigned."
def maybe_finish_assign_techniques(db: Session, game: Game):
"""Once everyone has assigned their J/Q/K, roll starting ranks and move to
scene setup. Safe to call after a roster change to unblock the phase."""
players_list = swap_participants(game)
if not players_list or not all(p.is_ready for p in players_list):
return
# 1. Randomly assign starting ranks
random.shuffle(players_list)
# "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene."
# "Randomly choose one Player to start the game at Rank 1. This Player must play their Pi-Rat during the first scene."
# "All remaining Players start at Rank 2. These Players may choose to play either during the first scene."
if len(players_list) >= 2:
players_list[0].rank = 3
players_list[0].role = "deep"
players_list[0].previous_role = "deep"
players_list[1].rank = 1
players_list[1].role = "pirat"
players_list[1].previous_role = "pirat"
for p in players_list[2:]:
p.rank = 2
p.role = None
p.previous_role = None
else:
# Edge case for 1 player testing
players_list[0].rank = 3
players_list[0].role = "pirat"
players_list[0].previous_role = "pirat"
# Reset ready status for scene setup
for p in game.players:
p.is_ready = False
db.add(p)
game.phase = "scene_setup"
db.add(game)
db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
player = get_player(db, player_id)
if not player:
@@ -434,43 +485,7 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s
db.commit()
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
if all(p.is_ready for p in swap_participants(game)):
# 1. Randomly assign starting ranks
players_list = swap_participants(game)
random.shuffle(players_list)
# "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene."
# "Randomly choose one Player to start the game at Rank 1. This Player must play their Pi-Rat during the first scene."
# "All remaining Players start at Rank 2. These Players may choose to play either during the first scene."
if len(players_list) >= 2:
players_list[0].rank = 3
players_list[0].role = "deep"
players_list[0].previous_role = "deep"
players_list[1].rank = 1
players_list[1].role = "pirat"
players_list[1].previous_role = "pirat"
for p in players_list[2:]:
p.rank = 2
p.role = None
p.previous_role = None
else:
# Edge case for 1 player testing
players_list[0].rank = 3
players_list[0].role = "pirat"
players_list[0].previous_role = "pirat"
# Reset ready status for scene setup
for p in game.players:
p.is_ready = False
db.add(p)
game.phase = "scene_setup"
db.add(game)
db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
maybe_finish_assign_techniques(db, game)
return True, "Techniques assigned."
def skip_character_creation(db: Session, game: Game, fills: dict):
@@ -727,7 +742,77 @@ def finalize_recruit(db: Session, player_id: str, jack: str, queen: str, king: s
draw_cards_for_player(db, game, player, max_size)
add_game_event(db, game.id, f"{player.name} has joined the crew as a fresh Rank {player.rank} recruit!", kind="join")
maybe_finish_recruit_creation(db, game)
return True, "Welcome aboard!"
def maybe_finish_recruit_creation(db: Session, game: Game):
"""Leave recruit creation for scene setup once no pending recruits remain.
Safe to call after a roster change to unblock the phase."""
if not any(p.needs_reroll for p in game.players):
from .crud_upkeep import begin_next_scene_setup
begin_next_scene_setup(db, game)
return True, "Welcome aboard!"
def _unused_recruit_technique(game: Game, recruit_id: str) -> str:
"""A technique name not already in play, for backfilling a recruit slot whose
assigned crewmate is gone (no one left to write it)."""
from .cards import TECHNIQUE_SUGGESTIONS
in_use = techniques_in_use(game, exclude_player_id=recruit_id)
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use] or list(TECHNIQUE_SUGGESTIONS)
return random.choice(available)
def repair_references_to_removed_player(db: Session, game: Game, removed_id: str):
"""Fix every other player's pointers to a player who is being removed, so
nothing dangles or stalls: pending swap offers are dropped, delegated
Like/Hate questions and recruit technique slots are handed to a present
crewmate (or filled from the pool when nobody is left to do them)."""
others = [p for p in game.players if p.id != removed_id]
helpers = [p for p in others if not p.needs_reroll]
def pick_helper(exclude_id):
pool = [h for h in helpers if h.id != exclude_id]
return random.choice(pool) if pool else None
for p in others:
changed = False
# A swap offer aimed at the removed player can never be answered.
if p.swap_offer_to_id == removed_id:
p.swap_offer_to_id = None
p.swap_offer_technique = ""
changed = True
# Delegated Like/Hate the removed player still owed: hand to another
# present crewmate, or drop the question if there's no one left.
if p.other_like_from_player_id == removed_id and not p.other_like:
alt = pick_helper(p.id)
p.other_like_from_player_id = alt.id if alt else None
changed = True
if p.other_hate_from_player_id == removed_id and not p.other_hate:
alt = pick_helper(p.id)
p.other_hate_from_player_id = alt.id if alt else None
changed = True
# Recruit technique slots the removed player was assigned to write.
if p.needs_reroll:
slots = json.loads(p.incoming_techniques)
slots_changed = False
for s in slots:
if s.get("from_id") == removed_id:
alt = pick_helper(p.id)
if alt:
s["from_id"] = alt.id
elif not s.get("text"):
# Nobody left to write it — backfill so the recruit can
# still finalize.
s["from_id"] = None
s["text"] = _unused_recruit_technique(game, p.id)
else:
s["from_id"] = None
slots_changed = True
if slots_changed:
p.incoming_techniques = json.dumps(slots)
changed = True
if changed:
db.add(p)
db.commit()

View File

@@ -1,19 +1,37 @@
import random
from typing import List, Tuple
from sqlmodel import Session, select
from .models import Game, Vote
from .models import Game, Player, Vote
from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
calculate_max_hand_size, change_player_rank
calculate_max_hand_size, change_player_rank, set_captain
)
# --- Between Scenes Operations ---
def is_eligible_nominee(voter, nominee) -> bool:
"""A rank-up nominee must be another living, in-play Pi-Rat. Excludes the
voter, previous-scene Deep players, and dead / awaiting-recruit players
(ranking them up is wasted — recruits reset to Rank 1)."""
return (
nominee.id != voter.id
and nominee.role != "deep"
and not nominee.is_dead
and not nominee.needs_reroll
)
def eligible_vote_nominees(game: Game, voter) -> list:
"""The crewmates `voter` may nominate to rank up. An empty list means the
voter has no valid pick and skips voting entirely (no deadlock)."""
return [p for p in game.players if is_eligible_nominee(voter, p)]
def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str) -> Tuple[bool, str]:
"""
Every Player (including last scene's Deep Players) nominates one *other* Pi-Rat
Player to Rank up. Deep Players from the previous scene cannot be nominated.
Player to Rank up. Deep Players from the previous scene cannot be nominated,
nor can dead or awaiting-recruit Pi-Rats. A voter whose only options are
ineligible skips voting (the frontend lets them ready up without a vote).
"""
voter = get_player(db, voter_id)
nominee = get_player(db, nominated_id)
@@ -23,8 +41,8 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
return False, "You cannot nominate yourself. Nice try."
if nominee.role == "deep":
return False, "Deep Players from the previous scene cannot be nominated."
# Dead/re-rolling players stay nominable on purpose: in small crews they can
# be a voter's only legal pick, and blocking them would deadlock the vote.
if nominee.is_dead or nominee.needs_reroll:
return False, "You can't nominate a Pi-Rat who's out of play. Pick a living crewmate."
# Remove any existing vote by this voter for this game
existing = db.exec(
@@ -111,6 +129,12 @@ def advance_after_upkeep(db: Session, game: Game):
else:
begin_next_scene_setup(db, game)
def maybe_transition_to_deep_upkeep(db: Session, game: Game):
"""In between_scenes, move on once every player has readied up. Safe to call
after a roster change to unblock the phase."""
if game.players and all(p.is_ready for p in game.players):
transition_to_deep_upkeep(db, game.id)
def transition_to_deep_upkeep(db: Session, game_id: str):
game = get_game(db, game_id)
if not game:
@@ -172,8 +196,93 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
db.add(player)
db.commit()
# Check if all resting deep players are ready
# Once every resting Deep player has refreshed their hand, move on.
maybe_advance_after_deep_upkeep(db, game)
def maybe_advance_after_deep_upkeep(db: Session, game: Game):
"""Leave deep upkeep once every resting Deep player has refreshed. With no
resting Deep left (e.g. the last one was kicked) this advances immediately.
Safe to call after a roster change to unblock the phase."""
resting_deep = [p for p in game.players if p.previous_role == "deep"]
if all(p.is_ready for p in resting_deep):
# All resting deep players have refreshed their hands!
advance_after_upkeep(db, game)
# --- Kicking a player ---
def recheck_phase_completion(db: Session, game: Game):
"""Re-evaluate the current phase's "everyone is done" gate and advance if it
is now satisfied. Called after the roster changes (a kick) so a vanished
player can't leave the table stuck on a step they'll never complete."""
from . import crud_character as cc
phase = game.phase
if phase == "character_creation":
cc.maybe_trigger_technique_swap(db, game)
elif phase == "swap_techniques":
cc.maybe_finish_technique_swap(db, game)
elif phase == "assign_techniques":
cc.maybe_finish_assign_techniques(db, game)
elif phase == "between_scenes":
maybe_transition_to_deep_upkeep(db, game)
elif phase == "deep_upkeep":
maybe_advance_after_deep_upkeep(db, game)
elif phase == "recruit_creation":
cc.maybe_finish_recruit_creation(db, game)
def _remove_player(db: Session, game: Game, target: Player, event_message: str,
captain_reason: str, event_kind: str = "warning"):
"""Shared mechanics for taking a player out of a game (a kick or a voluntary
leave): drop their captaincy, votes and Challenges, repair everyone else's
references to them, then delete the row. Their hand and any PvP temp card
simply stop being "in play", so reshuffle_discard_pile folds them back into
the deck as discards — no hand clearing needed. Finally re-check the current
phase's completion gate so a departed blocker can't stall the table."""
from . import crud_character as cc
target_id = target.id
if game.captain_player_id == target_id:
set_captain(db, game, None, reason=captain_reason)
for v in list(game.votes):
if target_id in (v.voter_player_id, v.nominated_player_id):
db.delete(v)
for ch in list(game.challenges):
if target_id in (ch.target_player_id, ch.acting_player_id, ch.challenger_player_id, ch.tax_target_id):
db.delete(ch)
db.commit()
cc.repair_references_to_removed_player(db, game, target_id)
db.delete(target)
db.commit()
db.refresh(game)
add_game_event(db, game.id, event_message, kind=event_kind)
recheck_phase_completion(db, game)
def kick_player(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
"""An Admin removes a player mid-game (e.g. one who vanished) so the table
isn't left waiting on them."""
if target.game_id != game.id:
return False, "That player isn't in this crew."
admin_count = sum(1 for p in game.players if p.is_admin)
if target.is_admin and admin_count <= 1:
return False, "You can't kick the last Admin. Grant someone else Admin first."
name = target.name
_remove_player(db, game, target,
f"{name} was kicked from the crew. Their cards return to the discard.",
f"{name} was kicked")
return True, f"{name} was kicked from the crew."
def leave_game(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
"""A player removes themselves from the game. Same mechanics as a kick; the
last Admin must hand off Admin first so the table isn't left without one."""
if target.game_id != game.id:
return False, "You're not in this crew."
admin_count = sum(1 for p in game.players if p.is_admin)
if target.is_admin and admin_count <= 1:
return False, "You're the last Admin — grant someone else Admin before you leave."
name = target.name
_remove_player(db, game, target,
f"{name} left the crew. Their cards return to the discard.",
f"{name} left", event_kind="join")
return True, f"{name} left the crew."

View File

@@ -1,7 +1,7 @@
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session
import re
@@ -76,6 +76,18 @@ def join_game_submit(
player = crud.add_player(db, game_id, name.strip(), is_creator=is_creator)
return {"id": player.id}
@api.post("/game/{game_id}/player/{player_id}/leave")
def leave_game_route(game_id: str, player_id: str, db: Session = Depends(get_session)):
"""A player voluntarily removes themselves from the game (counterpart to join)."""
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player or player.game_id != game.id:
raise HTTPException(status_code=404, detail="Game or Player not found")
ok, msg = crud.leave_game(db, game, player)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
@api.get("/game/{game_id}/player/{player_id}/state")
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)

View File

@@ -136,3 +136,26 @@ def set_player_admin(
verb = "granted" if is_admin else "revoked"
crud.add_game_event(db, game.id, f"🗝 Admin privileges {verb} for {target.name}.", kind="info")
return {"status": "ok", "is_admin": target.is_admin}
# Kick a player out of the game (admin-key authenticated). Admins can kick
# anyone — including other admins — except the last remaining Admin, so the
# table is never locked out. The kicked player's cards return to the discard.
@router.post("/game/{game_id}/admin/kick")
def kick_player_route(
game_id: str,
key: str = Form(...),
target_player_id: str = Form(...),
db: Session = Depends(get_session)
):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
if game.admin_key != key:
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
target = crud.get_player(db, target_player_id)
if not target or target.game_id != game.id:
raise HTTPException(status_code=404, detail="Player not found")
ok, msg = crud.kick_player(db, game, target)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}

View File

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

View File

@@ -33,8 +33,8 @@ def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_ses
# Check if all players in the game are ready. If so, transition to deep upkeep!
game = crud.get_game(db, game_id)
if game and all(p.is_ready for p in game.players):
crud.transition_to_deep_upkeep(db, game_id)
if game:
crud.maybe_transition_to_deep_upkeep(db, game)
return {"status": "ok"}

View File

@@ -1048,6 +1048,39 @@ def test_vote_validation(session):
ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p2.id)
assert ok
# Dead and awaiting-recruit Pi-Rats can't be nominated
p3.is_dead = True
session.add(p3)
session.commit()
ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p3.id)
assert not ok
p3.is_dead = False
p3.needs_reroll = True
session.add(p3)
session.commit()
ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p3.id)
assert not ok
def test_vote_skips_when_only_dead_options(session):
# 3-player crew, one living Pi-Rat whose only crewmates are the previous
# Deep and a dead rat: they have no eligible nominee and skip voting.
game = crud.create_game(session)
deep = crud.add_player(session, game.id, "Deep")
living = crud.add_player(session, game.id, "Living")
dead = crud.add_player(session, game.id, "Dead")
deep.role = "deep"
living.role = "pirat"
dead.role = "pirat"
dead.is_dead = True
session.add_all([deep, living, dead])
session.commit()
# The living Pi-Rat has no one valid to nominate
assert crud.eligible_vote_nominees(game, living) == []
# The Deep can still nominate the living Pi-Rat
nominees = crud.eligible_vote_nominees(game, deep)
assert [p.id for p in nominees] == [living.id]
def test_rank_up_draws_immediately(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
# p2 is rank 1 (lowest -> max 2), p3 is rank 2 (highest -> max 4)
@@ -1753,3 +1786,254 @@ def test_lobby_start_requires_min_players():
assert game.phase == "character_creation"
finally:
app.dependency_overrides.clear()
def test_kick_unblocks_technique_submission(session):
"""A vanished player who never submitted techniques can be kicked, and the
swap phase then begins for the rest of the crew. Their hand goes to discard."""
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3")
game.phase = "character_creation"
session.add(game)
session.commit()
crud.submit_techniques(session, p1.id, "Alpha", "Beta", "Gamma")
crud.submit_techniques(session, p2.id, "Delta", "Epsilon", "Zeta")
# p3 vanished without submitting; give them a hand so we can track the discard
p3.hand_cards = json.dumps(["2C", "3D", "4H"])
session.add(p3)
session.commit()
session.refresh(game)
assert game.phase == "character_creation" # still blocked on p3
ok, msg = crud.kick_player(session, game, p3)
assert ok, msg
session.refresh(game)
assert game.phase == "swap_techniques" # p1 & p2 submitted -> swap begins
assert crud.get_player(session, p3.id) is None
assert len(game.players) == 2
# p3's cards return to the discard: the next reshuffle pulls them into the deck
crud.reshuffle_discard_pile(session, game)
deck = crud.get_game_deck(game)
assert all(c in deck for c in ["2C", "3D", "4H"])
def test_kick_cleans_up_references(session):
"""Kicking a player drops their captaincy, their votes, and any open
Challenge they're part of."""
from pirats.models import Vote
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
crud.set_captain(session, game, p2.id, reason="test")
session.refresh(game)
assert game.captain_player_id == p2.id
session.add(Vote(game_id=game.id, voter_player_id=p3.id, nominated_player_id=p2.id))
session.commit()
obstacle = game.obstacles[0]
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obstacle.id])
assert ok, msg
assert any(c.target_player_id == p2.id for c in game.challenges)
ok, msg = crud.kick_player(session, game, p2)
assert ok, msg
session.refresh(game)
assert crud.get_player(session, p2.id) is None
assert game.captain_player_id is None
assert game.votes == []
assert len(game.challenges) == 0
def test_kick_reassigns_recruit_obligations(session):
"""If a crewmate who owes a recruit Like/Hate answers or technique slots
vanishes, kicking them hands those duties to a present crewmate so the
recruit can still be finalized."""
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3")
game.phase = "between_scenes"
game.current_scene_number = 2
p1.is_dead = True
session.add_all([game, p1])
session.commit()
ok, msg = crud.choose_reroll(session, p1.id)
assert ok, msg
crud.transition_to_deep_upkeep(session, game.id)
session.refresh(game)
assert game.phase == "recruit_creation"
# Stack every recruit duty onto p2 so kicking them exercises the reassignment
session.refresh(p1)
p1.other_like_from_player_id = p2.id
p1.other_hate_from_player_id = p2.id
p1.other_like = ""
p1.other_hate = ""
slots = json.loads(p1.incoming_techniques)
for s in slots:
s["from_id"] = p2.id
s["text"] = ""
p1.incoming_techniques = json.dumps(slots)
session.add(p1)
session.commit()
ok, msg = crud.kick_player(session, game, p2)
assert ok, msg
session.refresh(game)
session.refresh(p1)
assert game.phase == "recruit_creation" # recruit still pending
assert crud.get_player(session, p2.id) is None
# p2's duties were handed to the only remaining crewmate, p3
assert p1.other_like_from_player_id == p3.id
assert p1.other_hate_from_player_id == p3.id
assert all(s["from_id"] == p3.id for s in json.loads(p1.incoming_techniques))
# p3 can now complete everything and the recruit finalizes
crud.submit_delegated_answer(session, p1.id, "like", "Sharp wit", p3.id)
crud.submit_delegated_answer(session, p1.id, "hate", "Slow walkers", p3.id)
techs = ["Cutlass Cartwheel", "Powder Keg Punt", "Crow's Nest Cry"]
for i, t in enumerate(techs):
ok, msg = crud.contribute_recruit_technique(session, p3.id, p1.id, i, t)
assert ok, msg
p1.avatar_look = "Scar"
p1.avatar_smell = "Brine"
p1.first_words = "Arr"
p1.good_at_math = "Nope"
session.add(p1)
session.commit()
ok, msg = crud.finalize_recruit(session, p1.id, techs[0], techs[1], techs[2])
assert ok, msg
session.refresh(game)
assert game.phase == "scene_setup"
def test_kick_endpoint_and_last_admin_guard():
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, create_engine, Session
from pirats.main import app
from pirats import crud
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
game = crud.create_game(session)
creator = crud.add_player(session, game.id, "Creator", is_creator=True)
mate = crud.add_player(session, game.id, "Mate")
extra = crud.add_player(session, game.id, "Extra")
def get_session_override():
yield session
from pirats.database import get_session
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
try:
# Wrong key can't kick
r = client.post(f"/api/game/{game.id}/admin/kick",
data={"key": "nope", "target_player_id": mate.id})
assert r.status_code == 403
# The sole Admin (creator) can't be kicked — the table would be locked out
r = client.post(f"/api/game/{game.id}/admin/kick",
data={"key": game.admin_key, "target_player_id": creator.id})
assert r.status_code == 400
assert "last Admin" in r.json()["error"]
# A regular player can be kicked
r = client.post(f"/api/game/{game.id}/admin/kick",
data={"key": game.admin_key, "target_player_id": mate.id})
assert r.status_code == 200
assert crud.get_player(session, mate.id) is None
# With a co-admin present, even an admin (the creator) can be kicked
client.post(f"/api/game/{game.id}/admin/set-admin",
data={"key": game.admin_key, "target_player_id": extra.id, "is_admin": True})
r = client.post(f"/api/game/{game.id}/admin/kick",
data={"key": game.admin_key, "target_player_id": creator.id})
assert r.status_code == 200
assert crud.get_player(session, creator.id) is None
finally:
app.dependency_overrides.clear()
def test_leave_game_unblocks_phase(session):
"""A player who leaves on their own is removed like a kick, and the phase
advances if they were the last blocker. The log shows a leave, not a kick."""
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3")
game.phase = "character_creation"
session.add(game)
session.commit()
crud.submit_techniques(session, p1.id, "Alpha", "Beta", "Gamma")
crud.submit_techniques(session, p2.id, "Delta", "Epsilon", "Zeta")
session.refresh(game)
assert game.phase == "character_creation" # still blocked on p3
ok, msg = crud.leave_game(session, game, p3)
assert ok, msg
session.refresh(game)
assert game.phase == "swap_techniques"
assert crud.get_player(session, p3.id) is None
assert any("P3 left the crew" in e.message for e in game.events)
def test_leave_endpoint_and_last_admin_guard():
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, create_engine, Session
from pirats.main import app
from pirats import crud
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
game = crud.create_game(session)
creator = crud.add_player(session, game.id, "Creator", is_creator=True)
mate = crud.add_player(session, game.id, "Mate")
extra = crud.add_player(session, game.id, "Extra")
def get_session_override():
yield session
from pirats.database import get_session
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
try:
# The sole Admin can't abandon the table without handing off Admin first
r = client.post(f"/api/game/{game.id}/player/{creator.id}/leave")
assert r.status_code == 400
assert "last Admin" in r.json()["error"]
# A regular player can leave themselves (no admin key needed)
r = client.post(f"/api/game/{game.id}/player/{mate.id}/leave")
assert r.status_code == 200
assert crud.get_player(session, mate.id) is None
# With a co-admin present, even the creator can leave
client.post(f"/api/game/{game.id}/admin/set-admin",
data={"key": game.admin_key, "target_player_id": extra.id, "is_admin": True})
r = client.post(f"/api/game/{game.id}/player/{creator.id}/leave")
assert r.status_code == 200
assert crud.get_player(session, creator.id) is None
finally:
app.dependency_overrides.clear()