Compare commits
7 Commits
952b1be872
...
e7db19f27e
| Author | SHA1 | Date | |
|---|---|---|---|
| e7db19f27e | |||
| 428af784e3 | |||
| d04e5013fa | |||
| 345ef4088f | |||
| d68333ec05 | |||
| fb17c632b0 | |||
| e502155af7 |
10
TODO.md
10
TODO.md
@@ -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.
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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'}
|
||||
{#each myNominees as p}
|
||||
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
||||
{/if}
|
||||
{/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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
46
frontend/src/lib/sessions.js
Normal file
46
frontend/src/lib/sessions.js
Normal 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)));
|
||||
}
|
||||
9
frontend/src/lib/title.js
Normal file
9
frontend/src/lib/title.js
Normal 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;
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,26 +425,14 @@ 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 assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
|
||||
player = get_player(db, player_id)
|
||||
if not player:
|
||||
return False, "Player not found."
|
||||
game = get_game(db, player.game_id)
|
||||
if not game or game.phase != "assign_techniques":
|
||||
return False, "Face-card assignment isn't in progress."
|
||||
if sorted([jack, queen, king]) != sorted(json.loads(player.swapped_techniques)):
|
||||
return False, "Assign each of your 3 swapped techniques to exactly one face card."
|
||||
player.tech_jack = jack
|
||||
player.tech_queen = queen
|
||||
player.tech_king = king
|
||||
player.is_ready = True
|
||||
db.add(player)
|
||||
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
|
||||
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."
|
||||
@@ -471,6 +467,25 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s
|
||||
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:
|
||||
return False, "Player not found."
|
||||
game = get_game(db, player.game_id)
|
||||
if not game or game.phase != "assign_techniques":
|
||||
return False, "Face-card assignment isn't in progress."
|
||||
if sorted([jack, queen, king]) != sorted(json.loads(player.swapped_techniques)):
|
||||
return False, "Assign each of your 3 swapped techniques to exactly one face card."
|
||||
player.tech_jack = jack
|
||||
player.tech_queen = queen
|
||||
player.tech_king = king
|
||||
player.is_ready = True
|
||||
db.add(player)
|
||||
db.commit()
|
||||
|
||||
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
|
||||
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()
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user