Files
pirats/src/pirats/crud_upkeep.py
Tim McCarthy 5e9f9bfb13 Backend logging: stderr + configurable rotating file
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>
2026-06-15 15:42:06 -07:00

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."