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

@@ -1,8 +1,6 @@
## Major features ## Major features
- [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin. - [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin.
- [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes. Admins can kick other admins. A kicked player's cards go to the discard.
## Words Words Words ## Words Words Words
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing. - [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.

View File

@@ -69,6 +69,26 @@
error = err.message; error = err.message;
} }
} }
// The last remaining Admin can't be kicked, so the table is never locked out.
$: adminCount = data ? data.players.filter((p) => p.is_admin).length : 0;
function canKick(p) {
return !(p.is_admin && adminCount <= 1);
}
async function kickPlayer(p) {
const who = p.player_name || p.name;
if (!confirm(`Kick ${who} from the game? Their cards go to the discard and they'll have to rejoin. This can't be undone.`)) return;
try {
await apiRequest(`/game/${gameId}/admin/kick`, 'POST', {
key: adminKey,
target_player_id: p.id,
});
await loadAdminData();
} catch (err) {
error = err.message;
}
}
</script> </script>
<div class="admin-page"> <div class="admin-page">
@@ -91,6 +111,10 @@
<p><strong>Phase:</strong> {data.game.phase}</p> <p><strong>Phase:</strong> {data.game.phase}</p>
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p> <p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
{#if error}
<div class="alert alert-danger mt-4">{error}</div>
{/if}
<h3 class="mt-8 mb-4">Invite Link</h3> <h3 class="mt-8 mb-4">Invite Link</h3>
<p class="info-text">Share this link to let new players join the crew.</p> <p class="info-text">Share this link to let new players join the crew.</p>
<div class="link-copy-action"> <div class="link-copy-action">
@@ -112,6 +136,7 @@
<th>Rank</th> <th>Rank</th>
<th>Admin</th> <th>Admin</th>
<th>Re-join Link</th> <th>Re-join Link</th>
<th>Kick</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -149,6 +174,13 @@
</button> </button>
</div> </div>
</td> </td>
<td>
{#if canKick(p)}
<button on:click={() => kickPlayer(p)} class="btn btn-danger btn-small">Kick</button>
{:else}
<span class="text-muted"></span>
{/if}
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>

View File

@@ -113,16 +113,7 @@ def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3
# Check if all players have submitted techniques. If so, trigger the swap! # Check if all players have submitted techniques. If so, trigger the swap!
if not game: if not game:
return None return None
maybe_trigger_technique_swap(db, game)
all_submitted = True
for p in swap_participants(game):
techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs):
all_submitted = False
break
if all_submitted:
trigger_technique_swap(db, game)
return None return None
def unsubmit_techniques(db: Session, player_id: str): def unsubmit_techniques(db: Session, player_id: str):
@@ -171,6 +162,18 @@ def swap_participants(game: Game) -> list:
def holds_own_technique(player) -> bool: def holds_own_technique(player) -> bool:
return any(t["creator_id"] == player.id for t in json.loads(player.held_techniques)) return any(t["creator_id"] == player.id for t in json.loads(player.held_techniques))
def maybe_trigger_technique_swap(db: Session, game: Game):
"""Begin the swap phase once every participant has submitted 3 techniques.
Safe to call after a roster change (e.g. a kick) to unblock the phase."""
participants = swap_participants(game)
if not participants:
return
for p in participants:
techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs):
return
trigger_technique_swap(db, game)
def trigger_technique_swap(db: Session, game: Game): def trigger_technique_swap(db: Session, game: Game):
""" """
All techniques are in: everyone enters the swap phase holding their own 3 All techniques are in: everyone enters the swap phase holding their own 3
@@ -325,11 +328,16 @@ def set_swap_ready(db: Session, player_id: str, ready: bool):
db.add(player) db.add(player)
db.commit() db.commit()
participants = swap_participants(game) maybe_finish_technique_swap(db, game)
if ready and participants and all(p.is_ready for p in participants):
finish_technique_swap(db, game)
return True, "Ready!" if ready else "No longer ready." return True, "Ready!" if ready else "No longer ready."
def maybe_finish_technique_swap(db: Session, game: Game):
"""Advance to face-card assignment once everyone has readied up in the swap
phase. Safe to call after a roster change to unblock the phase."""
participants = swap_participants(game)
if participants and all(p.is_ready for p in participants):
finish_technique_swap(db, game)
def finish_technique_swap(db: Session, game: Game): def finish_technique_swap(db: Session, game: Game):
"""Locks in everyone's held techniques and advances to J/Q/K assignment.""" """Locks in everyone's held techniques and advances to J/Q/K assignment."""
for p in swap_participants(game): for p in swap_participants(game):
@@ -417,26 +425,14 @@ def auto_assign_techniques(db: Session, game: Game):
return False, "Auto-assignment couldn't finish; check the event log." return False, "Auto-assignment couldn't finish; check the event log."
return True, "Techniques auto-assigned." return True, "Techniques auto-assigned."
def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str): def maybe_finish_assign_techniques(db: Session, game: Game):
player = get_player(db, player_id) """Once everyone has assigned their J/Q/K, roll starting ranks and move to
if not player: scene setup. Safe to call after a roster change to unblock the phase."""
return False, "Player not found."
game = get_game(db, player.game_id)
if not game or game.phase != "assign_techniques":
return False, "Face-card assignment isn't in progress."
if sorted([jack, queen, king]) != sorted(json.loads(player.swapped_techniques)):
return False, "Assign each of your 3 swapped techniques to exactly one face card."
player.tech_jack = jack
player.tech_queen = queen
player.tech_king = king
player.is_ready = True
db.add(player)
db.commit()
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
if all(p.is_ready for p in swap_participants(game)):
# 1. Randomly assign starting ranks
players_list = swap_participants(game) players_list = swap_participants(game)
if not players_list or not all(p.is_ready for p in players_list):
return
# 1. Randomly assign starting ranks
random.shuffle(players_list) random.shuffle(players_list)
# "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene." # "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene."
@@ -471,6 +467,25 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase") add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
player = get_player(db, player_id)
if not player:
return False, "Player not found."
game = get_game(db, player.game_id)
if not game or game.phase != "assign_techniques":
return False, "Face-card assignment isn't in progress."
if sorted([jack, queen, king]) != sorted(json.loads(player.swapped_techniques)):
return False, "Assign each of your 3 swapped techniques to exactly one face card."
player.tech_jack = jack
player.tech_queen = queen
player.tech_king = king
player.is_ready = True
db.add(player)
db.commit()
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
maybe_finish_assign_techniques(db, game)
return True, "Techniques assigned." return True, "Techniques assigned."
def skip_character_creation(db: Session, game: Game, fills: dict): def skip_character_creation(db: Session, game: Game, fills: dict):
@@ -727,7 +742,77 @@ def finalize_recruit(db: Session, player_id: str, jack: str, queen: str, king: s
draw_cards_for_player(db, game, player, max_size) draw_cards_for_player(db, game, player, max_size)
add_game_event(db, game.id, f"{player.name} has joined the crew as a fresh Rank {player.rank} recruit!", kind="join") add_game_event(db, game.id, f"{player.name} has joined the crew as a fresh Rank {player.rank} recruit!", kind="join")
maybe_finish_recruit_creation(db, game)
return True, "Welcome aboard!"
def maybe_finish_recruit_creation(db: Session, game: Game):
"""Leave recruit creation for scene setup once no pending recruits remain.
Safe to call after a roster change to unblock the phase."""
if not any(p.needs_reroll for p in game.players): if not any(p.needs_reroll for p in game.players):
from .crud_upkeep import begin_next_scene_setup from .crud_upkeep import begin_next_scene_setup
begin_next_scene_setup(db, game) begin_next_scene_setup(db, game)
return True, "Welcome aboard!"
def _unused_recruit_technique(game: Game, recruit_id: str) -> str:
"""A technique name not already in play, for backfilling a recruit slot whose
assigned crewmate is gone (no one left to write it)."""
from .cards import TECHNIQUE_SUGGESTIONS
in_use = techniques_in_use(game, exclude_player_id=recruit_id)
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use] or list(TECHNIQUE_SUGGESTIONS)
return random.choice(available)
def repair_references_to_removed_player(db: Session, game: Game, removed_id: str):
"""Fix every other player's pointers to a player who is being removed, so
nothing dangles or stalls: pending swap offers are dropped, delegated
Like/Hate questions and recruit technique slots are handed to a present
crewmate (or filled from the pool when nobody is left to do them)."""
others = [p for p in game.players if p.id != removed_id]
helpers = [p for p in others if not p.needs_reroll]
def pick_helper(exclude_id):
pool = [h for h in helpers if h.id != exclude_id]
return random.choice(pool) if pool else None
for p in others:
changed = False
# A swap offer aimed at the removed player can never be answered.
if p.swap_offer_to_id == removed_id:
p.swap_offer_to_id = None
p.swap_offer_technique = ""
changed = True
# Delegated Like/Hate the removed player still owed: hand to another
# present crewmate, or drop the question if there's no one left.
if p.other_like_from_player_id == removed_id and not p.other_like:
alt = pick_helper(p.id)
p.other_like_from_player_id = alt.id if alt else None
changed = True
if p.other_hate_from_player_id == removed_id and not p.other_hate:
alt = pick_helper(p.id)
p.other_hate_from_player_id = alt.id if alt else None
changed = True
# Recruit technique slots the removed player was assigned to write.
if p.needs_reroll:
slots = json.loads(p.incoming_techniques)
slots_changed = False
for s in slots:
if s.get("from_id") == removed_id:
alt = pick_helper(p.id)
if alt:
s["from_id"] = alt.id
elif not s.get("text"):
# Nobody left to write it — backfill so the recruit can
# still finalize.
s["from_id"] = None
s["text"] = _unused_recruit_technique(game, p.id)
else:
s["from_id"] = None
slots_changed = True
if slots_changed:
p.incoming_techniques = json.dumps(slots)
changed = True
if changed:
db.add(p)
db.commit()

View File

@@ -1,11 +1,11 @@
import random import random
from typing import List, Tuple from typing import List, Tuple
from sqlmodel import Session, select from sqlmodel import Session, select
from .models import Game, Vote from .models import Game, Player, Vote
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event, get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
calculate_max_hand_size, change_player_rank calculate_max_hand_size, change_player_rank, set_captain
) )
# --- Between Scenes Operations --- # --- Between Scenes Operations ---
@@ -129,6 +129,12 @@ def advance_after_upkeep(db: Session, game: Game):
else: else:
begin_next_scene_setup(db, game) begin_next_scene_setup(db, game)
def maybe_transition_to_deep_upkeep(db: Session, game: Game):
"""In between_scenes, move on once every player has readied up. Safe to call
after a roster change to unblock the phase."""
if game.players and all(p.is_ready for p in game.players):
transition_to_deep_upkeep(db, game.id)
def transition_to_deep_upkeep(db: Session, game_id: str): def transition_to_deep_upkeep(db: Session, game_id: str):
game = get_game(db, game_id) game = get_game(db, game_id)
if not game: if not game:
@@ -190,8 +196,78 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
db.add(player) db.add(player)
db.commit() db.commit()
# Check if all resting deep players are ready # Once every resting Deep player has refreshed their hand, move on.
maybe_advance_after_deep_upkeep(db, game)
def maybe_advance_after_deep_upkeep(db: Session, game: Game):
"""Leave deep upkeep once every resting Deep player has refreshed. With no
resting Deep left (e.g. the last one was kicked) this advances immediately.
Safe to call after a roster change to unblock the phase."""
resting_deep = [p for p in game.players if p.previous_role == "deep"] resting_deep = [p for p in game.players if p.previous_role == "deep"]
if all(p.is_ready for p in resting_deep): if all(p.is_ready for p in resting_deep):
# All resting deep players have refreshed their hands!
advance_after_upkeep(db, game) advance_after_upkeep(db, game)
# --- Kicking a player ---
def recheck_phase_completion(db: Session, game: Game):
"""Re-evaluate the current phase's "everyone is done" gate and advance if it
is now satisfied. Called after the roster changes (a kick) so a vanished
player can't leave the table stuck on a step they'll never complete."""
from . import crud_character as cc
phase = game.phase
if phase == "character_creation":
cc.maybe_trigger_technique_swap(db, game)
elif phase == "swap_techniques":
cc.maybe_finish_technique_swap(db, game)
elif phase == "assign_techniques":
cc.maybe_finish_assign_techniques(db, game)
elif phase == "between_scenes":
maybe_transition_to_deep_upkeep(db, game)
elif phase == "deep_upkeep":
maybe_advance_after_deep_upkeep(db, 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."""
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")
# 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)
for ch in list(game.challenges):
if target_id in (ch.target_player_id, ch.acting_player_id, ch.challenger_player_id, ch.tax_target_id):
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.
recheck_phase_completion(db, game)
return True, f"{name} was kicked from the crew."

View File

@@ -136,3 +136,26 @@ def set_player_admin(
verb = "granted" if is_admin else "revoked" verb = "granted" if is_admin else "revoked"
crud.add_game_event(db, game.id, f"🗝 Admin privileges {verb} for {target.name}.", kind="info") crud.add_game_event(db, game.id, f"🗝 Admin privileges {verb} for {target.name}.", kind="info")
return {"status": "ok", "is_admin": target.is_admin} return {"status": "ok", "is_admin": target.is_admin}
# Kick a player out of the game (admin-key authenticated). Admins can kick
# anyone — including other admins — except the last remaining Admin, so the
# table is never locked out. The kicked player's cards return to the discard.
@router.post("/game/{game_id}/admin/kick")
def kick_player_route(
game_id: str,
key: str = Form(...),
target_player_id: str = Form(...),
db: Session = Depends(get_session)
):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
if game.admin_key != key:
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
target = crud.get_player(db, target_player_id)
if not target or target.game_id != game.id:
raise HTTPException(status_code=404, detail="Player not found")
ok, msg = crud.kick_player(db, game, target)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}

View File

@@ -33,8 +33,8 @@ def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_ses
# Check if all players in the game are ready. If so, transition to deep upkeep! # Check if all players in the game are ready. If so, transition to deep upkeep!
game = crud.get_game(db, game_id) game = crud.get_game(db, game_id)
if game and all(p.is_ready for p in game.players): if game:
crud.transition_to_deep_upkeep(db, game_id) crud.maybe_transition_to_deep_upkeep(db, game)
return {"status": "ok"} return {"status": "ok"}

View File

@@ -1786,3 +1786,184 @@ def test_lobby_start_requires_min_players():
assert game.phase == "character_creation" assert game.phase == "character_creation"
finally: finally:
app.dependency_overrides.clear() 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()