Add configure_logging() (main.py): always logs to stderr, plus a rotating file when PIRATS_LOG_FILE is set; level from PIRATS_LOG_LEVEL (default INFO). Called from both the CLI entrypoint and the app lifespan (idempotent), which also logs startup/migration/shutdown. Targeted server-side events for ops/debugging (not every game event): game created, player joined, player removed (kick/leave), game ended. NixOS service: new logFile (/var/log/pirats/pirats.log) and logLevel options, wired to the env vars; LogsDirectory=pirats makes the path writable under the sandboxed DynamicUser. README documents the env vars/options. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
307 lines
12 KiB
Python
307 lines
12 KiB
Python
import logging
|
|
import random
|
|
from typing import List, Tuple
|
|
from sqlmodel import Session, select
|
|
from .models import Game, Player, Vote
|
|
from .crud_base import (
|
|
get_player, get_game, get_player_hand, set_player_hand,
|
|
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
|
|
calculate_max_hand_size, change_player_rank, set_captain, is_eligible_nominee
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# --- Between Scenes Operations ---
|
|
|
|
def eligible_vote_nominees(game: Game, voter) -> list:
|
|
"""The crewmates `voter` may nominate to rank up. An empty list means the
|
|
voter has no valid pick and skips voting entirely (no deadlock)."""
|
|
return [p for p in game.players if is_eligible_nominee(voter, p)]
|
|
|
|
def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str) -> Tuple[bool, str]:
|
|
"""
|
|
Every Player (including last scene's Deep Players) nominates one *other* Pi-Rat
|
|
Player to Rank up. Deep Players from the previous scene cannot be nominated,
|
|
nor can dead or awaiting-recruit Pi-Rats. A voter whose only options are
|
|
ineligible skips voting (the frontend lets them ready up without a vote).
|
|
"""
|
|
voter = get_player(db, voter_id)
|
|
nominee = get_player(db, nominated_id)
|
|
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
|
|
return False, "Player not found."
|
|
if not is_eligible_nominee(voter, nominee):
|
|
return False, "You can only nominate a living crewmate who isn't this scene's Deep — and not yourself."
|
|
|
|
# Remove any existing vote by this voter for this game
|
|
existing = db.exec(
|
|
select(Vote).where(Vote.game_id == game_id, Vote.voter_player_id == voter_id)
|
|
).all()
|
|
for v in existing:
|
|
db.delete(v)
|
|
db.commit()
|
|
|
|
vote = Vote(
|
|
game_id=game_id,
|
|
voter_player_id=voter_id,
|
|
nominated_player_id=nominated_id
|
|
)
|
|
db.add(vote)
|
|
db.commit()
|
|
return True, "Vote submitted."
|
|
|
|
def unchallenged_pirats(game: Game):
|
|
"""Living Pi-Rats who have not yet been the target of a Deep Challenge this scene."""
|
|
challenged_ids = {c.target_player_id for c in game.challenges if c.challenge_type == "deep"}
|
|
return [p for p in game.players
|
|
if p.role == "pirat" and not p.is_dead and p.id not in challenged_ids]
|
|
|
|
def end_scene_and_transition(db: Session, game_id: str):
|
|
"""Moves game phase to between_scenes.
|
|
|
|
Unless Dev Mode is on, the scene can't end until every living Pi-Rat has
|
|
faced at least one Deep Challenge, or the Obstacle List has been cleared.
|
|
(Whether each Pi-Rat had a chance at their Objective is left to table talk.)
|
|
"""
|
|
game = get_game(db, game_id)
|
|
if not game:
|
|
return False, "Game not found."
|
|
|
|
if not game.dev_mode and game.obstacles:
|
|
waiting = unchallenged_pirats(game)
|
|
if waiting:
|
|
names = ", ".join(p.name for p in waiting)
|
|
return False, (
|
|
f"Every Pi-Rat must face a Challenge before the scene can end "
|
|
f"(still waiting on: {names}). Clear the Obstacle List to end early."
|
|
)
|
|
|
|
game.phase = "between_scenes"
|
|
|
|
# Clear ready flags for players
|
|
for p in game.players:
|
|
p.is_ready = False
|
|
db.add(p)
|
|
db.add(game)
|
|
db.commit()
|
|
|
|
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
|
|
return True, "Scene ended."
|
|
|
|
def process_between_scenes_votes(db: Session, game: Game):
|
|
"""
|
|
Analyzes votes, ranks up the winner, and processes any Rank 3 Objective triggers.
|
|
Then sets players who were Deep to discard and redraw.
|
|
"""
|
|
votes = game.votes
|
|
# Reset the previous scene's winner before tallying this one.
|
|
game.last_rank_up_player_id = None
|
|
db.add(game)
|
|
if not votes:
|
|
db.commit()
|
|
return
|
|
|
|
# Count votes
|
|
counts = {}
|
|
for v in votes:
|
|
counts[v.nominated_player_id] = counts.get(v.nominated_player_id, 0) + 1
|
|
|
|
if counts:
|
|
max_votes = max(counts.values())
|
|
winners = [pid for pid, val in counts.items() if val == max_votes]
|
|
|
|
# If there is a clear winner (or random in case of tie)
|
|
if winners:
|
|
winner_id = random.choice(winners)
|
|
winner = get_player(db, winner_id)
|
|
if winner:
|
|
change_player_rank(db, game, winner, 1)
|
|
game.last_rank_up_player_id = winner.id
|
|
db.add(game)
|
|
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank")
|
|
|
|
# Clear all votes
|
|
for v in votes:
|
|
db.delete(v)
|
|
db.commit()
|
|
|
|
def begin_next_scene_setup(db: Session, game: Game):
|
|
"""Advances to the next scene's setup: bump the scene counter and clear roles."""
|
|
game.current_scene_number += 1
|
|
game.phase = "scene_setup"
|
|
game.last_rank_up_player_id = None # Stale once we leave the post-voting screen
|
|
for p in game.players:
|
|
p.role = None
|
|
p.is_ready = False
|
|
db.add(p)
|
|
db.add(game)
|
|
db.commit()
|
|
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
|
|
|
def advance_after_upkeep(db: Session, game: Game):
|
|
"""After votes (and any resting-Deep hand refresh), detour through recruit
|
|
creation if anyone is waiting on a new Pi-Rat; otherwise straight to setup."""
|
|
if any(p.needs_reroll for p in game.players):
|
|
from .crud_character import start_recruit_creation
|
|
start_recruit_creation(db, game)
|
|
else:
|
|
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):
|
|
game = get_game(db, game_id)
|
|
if not game:
|
|
return
|
|
|
|
# Process votes & rank up winner
|
|
process_between_scenes_votes(db, game)
|
|
|
|
# Reset ready flags
|
|
for p in game.players:
|
|
p.is_ready = False
|
|
db.add(p)
|
|
db.commit()
|
|
|
|
# Check if we have resting Deep players (previous_role was 'deep')
|
|
has_resting_deep = any(p.previous_role == "deep" for p in game.players)
|
|
|
|
if has_resting_deep:
|
|
game.phase = "deep_upkeep"
|
|
db.add(game)
|
|
db.commit()
|
|
add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
|
|
else:
|
|
# No resting Deep players: skip straight past the deep_upkeep phase
|
|
advance_after_upkeep(db, game)
|
|
|
|
def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
|
|
player = get_player(db, player_id)
|
|
if not player:
|
|
return
|
|
game = get_game(db, player.game_id)
|
|
if not game:
|
|
return
|
|
|
|
# 1. Discard cards
|
|
hand = get_player_hand(player)
|
|
deck = get_game_deck(game)
|
|
|
|
for card in discard_cards:
|
|
if card in hand:
|
|
hand.remove(card)
|
|
deck.append(card)
|
|
|
|
random.shuffle(deck)
|
|
set_game_deck(game, deck)
|
|
set_player_hand(player, hand)
|
|
db.add(game)
|
|
db.add(player)
|
|
db.commit()
|
|
|
|
# 2. Draw cards back up to max size (based on Rank, per the hand size table)
|
|
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
|
|
if len(hand) < max_size:
|
|
diff = max_size - len(hand)
|
|
draw_cards_for_player(db, game, player, diff)
|
|
|
|
# Mark as ready
|
|
player.is_ready = True
|
|
db.add(player)
|
|
db.commit()
|
|
|
|
# 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"]
|
|
if all(p.is_ready for p in resting_deep):
|
|
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 _remove_player(db: Session, game: Game, target: Player, event_message: str,
|
|
captain_reason: str, event_kind: str = "warning"):
|
|
"""Shared mechanics for taking a player out of a game (a kick or a voluntary
|
|
leave): drop their captaincy, votes and Challenges, repair everyone else's
|
|
references to them, then delete the row. Their hand and any PvP temp card
|
|
simply stop being "in play", so reshuffle_discard_pile folds them back into
|
|
the deck as discards — no hand clearing needed. Finally re-check the current
|
|
phase's completion gate so a departed blocker can't stall the table."""
|
|
from . import crud_character as cc
|
|
target_id = target.id
|
|
logger.info("Removing player %s (%r) from game %s (%s)", target_id, target.name, game.id, event_kind)
|
|
|
|
if game.captain_player_id == target_id:
|
|
set_captain(db, game, None, reason=captain_reason)
|
|
|
|
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()
|
|
|
|
cc.repair_references_to_removed_player(db, game, target_id)
|
|
|
|
db.delete(target)
|
|
db.commit()
|
|
db.refresh(game)
|
|
|
|
add_game_event(db, game.id, event_message, kind=event_kind)
|
|
recheck_phase_completion(db, game)
|
|
|
|
def kick_player(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
|
|
"""An Admin removes a player mid-game (e.g. one who vanished) so the table
|
|
isn't left waiting on them."""
|
|
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
|
|
_remove_player(db, game, target,
|
|
f"{name} was kicked from the crew. Their cards return to the discard.",
|
|
f"{name} was kicked")
|
|
return True, f"{name} was kicked from the crew."
|
|
|
|
def leave_game(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
|
|
"""A player removes themselves from the game. Same mechanics as a kick; the
|
|
last Admin must hand off Admin first so the table isn't left without one."""
|
|
if target.game_id != game.id:
|
|
return False, "You're not 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're the last Admin — grant someone else Admin before you leave."
|
|
name = target.name
|
|
_remove_player(db, game, target,
|
|
f"{name} left the crew. Their cards return to the discard.",
|
|
f"{name} left", event_kind="join")
|
|
return True, f"{name} left the crew."
|