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>
This commit is contained in:
2026-06-12 21:58:07 -07:00
parent 428af784e3
commit e7db19f27e
4 changed files with 141 additions and 24 deletions

View File

@@ -1,11 +1,12 @@
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { extraMenuLinks } from '../lib/menu'; import { extraMenuLinks } from '../lib/menu';
import { getSuggestion } from '../lib/suggestions'; import { getSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards'; import { displayName } from '../lib/cards';
import { setPageTitle } from '../lib/title'; import { setPageTitle } from '../lib/title';
import { saveSession } from '../lib/sessions'; import { saveSession, removeSession } from '../lib/sessions';
import LobbyPhase from '../components/LobbyPhase.svelte'; import LobbyPhase from '../components/LobbyPhase.svelte';
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte'; import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
@@ -106,6 +107,18 @@
} }
} }
// 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 // Distinguish tabs/history by the Pi-Rat (and crew) this dashboard is for
$: setPageTitle(state ? displayName(state.player) : null, state?.game?.crew_name); $: setPageTitle(state ? displayName(state.player) : null, state?.game?.crew_name);
@@ -150,6 +163,13 @@
} }
items.push({ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' }); 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); extraMenuLinks.set(items);
} }

View File

@@ -228,29 +228,20 @@ def recheck_phase_completion(db: Session, game: Game):
elif phase == "recruit_creation": elif phase == "recruit_creation":
cc.maybe_finish_recruit_creation(db, game) cc.maybe_finish_recruit_creation(db, game)
def kick_player(db: Session, game: Game, target: Player) -> Tuple[bool, str]: def _remove_player(db: Session, game: Game, target: Player, event_message: str,
"""Remove a player mid-game (e.g. one who vanished) so the table isn't left captain_reason: str, event_kind: str = "warning"):
waiting on them. Their hand returns to the discard, every reference to them """Shared mechanics for taking a player out of a game (a kick or a voluntary
is cleared or reassigned, and the current phase's completion gate is leave): drop their captaincy, votes and Challenges, repair everyone else's
re-checked so the game can move on.""" 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 from . import crud_character as cc
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
target_id = target.id target_id = target.id
# Hand the captaincy to nobody if the kicked player held it.
if game.captain_player_id == target_id: if game.captain_player_id == target_id:
set_captain(db, game, None, reason=f"{name} was kicked") set_captain(db, game, None, reason=captain_reason)
# Drop their votes (cast or received) and any Challenge they're part of.
# The kicked player's hand and any PvP temp card simply stop being "in play",
# so reshuffle_discard_pile folds them back into the deck as discards.
for v in list(game.votes): for v in list(game.votes):
if target_id in (v.voter_player_id, v.nominated_player_id): if target_id in (v.voter_player_id, v.nominated_player_id):
db.delete(v) db.delete(v)
@@ -259,15 +250,39 @@ def kick_player(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
db.delete(ch) db.delete(ch)
db.commit() db.commit()
# Fix everyone else's pointers to the kicked player before removing them.
cc.repair_references_to_removed_player(db, game, target_id) cc.repair_references_to_removed_player(db, game, target_id)
db.delete(target) db.delete(target)
db.commit() db.commit()
db.refresh(game) db.refresh(game)
add_game_event(db, game.id, f"{name} was kicked from the crew. Their cards return to the discard.", kind="warning") add_game_event(db, game.id, event_message, kind=event_kind)
# The blocker is gone — the current phase may now be able to advance.
recheck_phase_completion(db, game) 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." 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 contextlib import asynccontextmanager
from typing import Optional from typing import Optional
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect 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 fastapi.staticfiles import StaticFiles
from sqlmodel import Session from sqlmodel import Session
import re import re
@@ -76,6 +76,18 @@ def join_game_submit(
player = crud.add_player(db, game_id, name.strip(), is_creator=is_creator) player = crud.add_player(db, game_id, name.strip(), is_creator=is_creator)
return {"id": player.id} 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") @api.get("/game/{game_id}/player/{player_id}/state")
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)): def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id) game = crud.get_game(db, game_id)

View File

@@ -1967,3 +1967,73 @@ def test_kick_endpoint_and_last_admin_guard():
assert crud.get_player(session, creator.id) is None assert crud.get_player(session, creator.id) is None
finally: finally:
app.dependency_overrides.clear() 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()