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:
2
TODO.md
2
TODO.md
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user