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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user