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>
This commit is contained in:
2026-06-12 21:48:03 -07:00
parent d04e5013fa
commit 428af784e3
7 changed files with 456 additions and 61 deletions

View File

@@ -1786,3 +1786,184 @@ 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()