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:
@@ -1,11 +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 } from '../lib/sessions';
|
||||
import { saveSession, removeSession } from '../lib/sessions';
|
||||
|
||||
import LobbyPhase from '../components/LobbyPhase.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
|
||||
$: 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' });
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -228,29 +228,20 @@ def recheck_phase_completion(db: Session, game: Game):
|
||||
elif phase == "recruit_creation":
|
||||
cc.maybe_finish_recruit_creation(db, game)
|
||||
|
||||
def kick_player(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
|
||||
"""Remove a player mid-game (e.g. one who vanished) so the table isn't left
|
||||
waiting on them. Their hand returns to the discard, every reference to them
|
||||
is cleared or reassigned, and the current phase's completion gate is
|
||||
re-checked so the game can move on."""
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# Hand the captaincy to nobody if the kicked player held it.
|
||||
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):
|
||||
if target_id in (v.voter_player_id, v.nominated_player_id):
|
||||
db.delete(v)
|
||||
@@ -259,15 +250,39 @@ def kick_player(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
|
||||
db.delete(ch)
|
||||
db.commit()
|
||||
|
||||
# Fix everyone else's pointers to the kicked player before removing them.
|
||||
cc.repair_references_to_removed_player(db, game, target_id)
|
||||
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
db.refresh(game)
|
||||
|
||||
add_game_event(db, game.id, f"{name} was kicked from the crew. Their cards return to the discard.", kind="warning")
|
||||
|
||||
# The blocker is gone — the current phase may now be able to advance.
|
||||
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)
|
||||
|
||||
@@ -1967,3 +1967,73 @@ def test_kick_endpoint_and_last_admin_guard():
|
||||
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