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

@@ -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()