diff --git a/.gitignore b/.gitignore index 003008f..cc71236 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .venv rats_with_gats.db +result +*.pdf diff --git a/SOURCEMAP.md b/SOURCEMAP.md new file mode 100644 index 0000000..4ea2971 --- /dev/null +++ b/SOURCEMAP.md @@ -0,0 +1,112 @@ +# Codebase Source Map + +This document outlines the file layout and architecture of the **PiRats** (Rats with Gats Remote Play) application. + +## Directory Tree + +``` +pirats/ +├── pyproject.toml # Python package configuration (PEP 621) +├── flake.nix # Nix configuration for development and deployment +├── RULEBOOK.md # Core Rats with Gats rules +├── OBSTACLES.md # Obstacle cards list and definitions +├── EXAMPLESCENE.md # Example gameplay scene walk-through +├── SOURCEMAP.md # This file +├── src/ +│ └── pirats/ +│ ├── __init__.py +│ ├── cards.py # Cards, deck shuffling, and score logic +│ ├── database.py # DB connection setup and tables creation +│ ├── models.py # SQLModel schemas (Game, Player, Obstacle, Vote) +│ ├── templates.py # Shared Jinja2 templates setup +│ ├── phase_view.py # Core templates phase-view routing engine +│ ├── crud.py # Facade hub importing all sub-CRUD modules +│ ├── crud_base.py # Base player, game, hand, deck database logic +│ ├── crud_character.py # Character setup, question delegation, techniques swapping CRUD +│ ├── crud_scene.py # Scene start, cards playing, Joker replacement CRUD +│ ├── crud_upkeep.py # Upkeep transitions, rank-up voting, deep rest hand refresh CRUD +│ ├── main.py # Main FastAPI initialization and core routes +│ ├── routes_lobby.py # Lobby status and starting character creation routes +│ ├── routes_character.py # Character basic details saving, delegation, and technique submit routes +│ ├── routes_scene.py # Role choosing, playing cards, Joker replace, objective toggles routes +│ ├── routes_upkeep.py # Voting rank-up, readying, deep upkeep hand refresh routes +│ ├── routes_admin.py # Creator admin dashboard panel routes +│ ├── static/ +│ │ ├── js/ +│ │ │ └── suggestions.js # Suggestions dictionary for character creation +│ │ └── css/ +│ │ ├── style.css # Central stylesheet hub (imports other files) +│ │ ├── core.css # Tokens, base resets, layout, utilities, global animations +│ │ ├── components.css # Panels, cards, buttons, input fields +│ │ ├── card.css # Mini, medium, large card styles, dragging styles +│ │ ├── welcome.css # Home/welcome screen styles +│ │ ├── lobby.css # Game lobby & waiting room player chip styles +│ │ ├── character.css # Character sheet grid & tactile technique drag-and-drop slots +│ │ ├── scene-setup.css # Role selection screen button & badge styles +│ │ ├── scene-play.css # Gameplay board, obstacles, challenges, deep controls +│ │ ├── upkeep.css # Upkeep rest cards, voting roster, upkeep drag/drop +│ │ └── admin.css # Admin dashboard list row layouts +│ └── templates/ # Jinja2 HTML templates +│ ├── admin.html # Session creator admin control panel +│ ├── base.html # Main base layout structure +│ ├── between_scenes_partial.html # Upkeep voting & rank ledgers view +│ ├── character_creation_partial.html # Character sheet & techniques builder view +│ ├── crew_status_snippet.html # Lobby status polling snippet +│ ├── dashboard.html # Dashboard hub containing HTMX phase swap targets +│ ├── deep_upkeep_partial.html # Deep rest upkeep card swap view +│ ├── delegation_snippet.html # Task list/inbox items for character creation +│ ├── header_status_snippet.html # Header pills showing role/phase +│ ├── inbox_snippet.html # Inboxes for likes/hates task answers +│ ├── index.html # Welcome home screen +│ ├── join_form.html # Join game form +│ ├── lobby_partial.html # Lobby view +│ ├── lobby_players_snippet.html # Lobby active players list snippet +│ ├── scene_obstacles_snippet.html # Playboard obstacles snippet +│ ├── scene_partial.html # Playboard gameplay view +│ ├── scene_roster_snippet.html # Scene roster snippet +│ ├── scene_setup_partial.html # Scene setup role chooser view +│ ├── techniques_snippet.html # Technique assignments display snippet +│ └── voting_roster_snippet.html # Voting roster display snippet +└── tests/ + └── test_game.py # pytest backend logic and game loop tests +``` + +--- + +## Component Details + +### 1. Backend Python Modules (`src/pirats/`) + +#### Core Configuration & Setup +- **[database.py](file:///Users/ttm/Projects/pirats/src/pirats/database.py)**: Configures the SQLite database engine, sets up the session dependency `get_session`, and executes migrations for adding new table columns dynamically. +- **[models.py](file:///Users/ttm/Projects/pirats/src/pirats/models.py)**: Defines database models using SQLModel for `Game` (phase, current scene, extra obstacles), `Player` (rank, role, objectives, cards), `Obstacle` (active scene hazards, successes, played cards), and `Vote` (rank-up nominations). +- **[cards.py](file:///Users/ttm/Projects/pirats/src/pirats/cards.py)**: Holds metadata for all 54 deck cards (suits, values, Joker representations), parses individual card codes, and loads static descriptions for Obstacles. +- **[templates.py](file:///Users/ttm/Projects/pirats/src/pirats/templates.py)**: Shared Jinja2 template initialization and path configurations to avoid circular module imports. + +#### Modular Business Logic (CRUD) +- **[crud.py](file:///Users/ttm/Projects/pirats/src/pirats/crud.py)**: A facade module that imports all sub-CRUD helper functions, preserving existing API endpoints for routes and tests. +- **[crud_base.py](file:///Users/ttm/Projects/pirats/src/pirats/crud_base.py)**: Base utility functions for accessing hands, drawing cards, reshuffling, checking captain privileges, and fetching core game and player entities. +- **[crud_character.py](file:///Users/ttm/Projects/pirats/src/pirats/crud_character.py)**: Handles delegating and answering character questions, submitting techniques, and distributing technique swaps. +- **[crud_scene.py](file:///Users/ttm/Projects/pirats/src/pirats/crud_scene.py)**: Scene starts checks, playing normal/Joker cards onto obstacles, and checking objective checkbox completion. +- **[crud_upkeep.py](file:///Users/ttm/Projects/pirats/src/pirats/crud_upkeep.py)**: Handles rank votes submissions/resolution, upkeep state transitions, and Deep upkeep hand discards/refreshes. + +#### Modular Router Endpoints (Routes) +- **[main.py](file:///Users/ttm/Projects/pirats/src/pirats/main.py)**: Launches the FastAPI application, mounts static assets, serves home landing, game joins, dashboard layout renders, phase polling checks, and mounts all phase APIRouters. +- **[phase_view.py](file:///Users/ttm/Projects/pirats/src/pirats/phase_view.py)**: Houses `/game/{game_id}/player/{player_id}/view` which determines the template responses to return for each phase layout. +- **[routes_lobby.py](file:///Users/ttm/Projects/pirats/src/pirats/routes_lobby.py)**: Endpoints for lobby listings and starting game creation. +- **[routes_character.py](file:///Users/ttm/Projects/pirats/src/pirats/routes_character.py)**: Endpoints for character sheets details saving, question delegations, revocations, and technique configurations. +- **[routes_scene.py](file:///Users/ttm/Projects/pirats/src/pirats/routes_scene.py)**: Endpoints for role assignments, scene starts, card playing, objective changes, and scene endings. +- **[routes_upkeep.py](file:///Users/ttm/Projects/pirats/src/pirats/routes_upkeep.py)**: Endpoints for between-scene rank votes, player readiness triggers, and deep card confirmations. +- **[routes_admin.py](file:///Users/ttm/Projects/pirats/src/pirats/routes_admin.py)**: Endpoints for host administrator console panels. + +--- + +### 2. Frontend Stylesheets (`src/pirats/static/css/`) +- **[style.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/style.css)**: Central hub referencing all other modular files using `@import`. +- **[core.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/core.css)**: Variable definitions, colors, font styling, animations, structural body, headers/footers, loading indicators, and alerts. +- **[components.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/components.css)**: Forms input designs, buttons variants, and glassmorphic panels. +- **[card.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/card.css)**: Custom-rendered CSS playing card widgets (mini list cards, hand cards, large face cards) and dragging animation states. +- **[welcome.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/welcome.css)** / **[lobby.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/lobby.css)**: Screen styles for the landing page and multiplayer join lobbies. +- **[character.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/character.css)** / **[scene-setup.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/scene-setup.css)**: Style configurations for the character sheet setup and scene role choose slots. +- **[scene-play.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/scene-play.css)** / **[upkeep.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/upkeep.css)**: Styles for active obstacles, card tables, and voting rank ledgers. +- **[admin.css](file:///Users/ttm/Projects/pirats/src/pirats/static/css/admin.css)**: Styles for session game hosts admin controls. diff --git a/src/pirats/crud.py b/src/pirats/crud.py index 26c473c..d4318b9 100644 --- a/src/pirats/crud.py +++ b/src/pirats/crud.py @@ -1,840 +1,5 @@ -import json -import random -from typing import List, Dict, Any, Optional, Tuple -from sqlmodel import Session, select -from .models import Game, Player, Obstacle, Vote -from . import cards - -# --- Card and Hand Utilities --- - -def get_player_hand(player: Player) -> List[str]: - return json.loads(player.hand_cards) - -def set_player_hand(player: Player, hand: List[str]): - player.hand_cards = json.dumps(hand) - -def get_game_deck(game: Game) -> List[str]: - return json.loads(game.deck_cards) - -def set_game_deck(game: Game, deck: List[str]): - game.deck_cards = json.dumps(deck) - -def calculate_max_hand_size(player: Player, players_in_scene: List[Player]) -> int: - """ - Calculates a player's maximum hand size based on their Rank relative to others in the scene, - and Captain privileges (+1). - Rank Hand sizes: - - Highest Rank Pi-Rat(s): max 4 cards - - Middle Rank Pi-Rat(s): max 3 cards - - Lowest Rank Pi-Rat(s): max 2 cards - - Captain gets +1 - If all players have the same Rank, they are all 'middle' and get 3 cards. - """ - if player.role == "deep": - # Deep players still have hands (for when they transition), let's say they have size based on Rank - # but don't participate in the Pi-Rat ranking calculations. - # Deep player max hand size is 3 by default, or equal to Rank. Let's make it equal to Rank + 1. - return player.rank + 1 - - pi_rats = [p for p in players_in_scene if p.role != "deep"] - if not pi_rats: - return 3 - - ranks = [p.rank for p in pi_rats] - max_rank = max(ranks) - min_rank = min(ranks) - - # Base size calculation - if len(set(ranks)) == 1: - # All have the same rank - base_size = 3 - else: - if player.rank == max_rank: - base_size = 4 - elif player.rank == min_rank: - base_size = 2 - else: - base_size = 3 - - # Captain check (highest rank Pi-Rat is de facto Captain) - # The rulebook: 'Initially, the highest-Ranked Pi-Rat becomes the de facto Captain. - # Captain Privileges: The Captain has their Hand size increased by 1, regardless of their Rank.' - # If there's a tie for highest rank, we'll designate the first one in alphabetical/ID order. - highest_ranked_pirats = [p for p in pi_rats if p.rank == max_rank] - highest_ranked_pirats.sort(key=lambda p: p.id) - is_captain = len(highest_ranked_pirats) > 0 and highest_ranked_pirats[0].id == player.id - - if is_captain: - return base_size + 1 - return base_size - -def is_player_captain(player: Player, players_in_scene: List[Player]) -> bool: - """Helper to check if a player is currently the captain in the scene.""" - if player.role == "deep": - return False - pi_rats = [p for p in players_in_scene if p.role != "deep"] - if not pi_rats: - return False - max_rank = max(p.rank for p in pi_rats) - highest_ranked = [p for p in pi_rats if p.rank == max_rank] - highest_ranked.sort(key=lambda p: p.id) - return len(highest_ranked) > 0 and highest_ranked[0].id == player.id - -def reshuffle_discard_pile(db: Session, game: Game): - """ - Gathers all cards not currently in any player's hand and not active on the table, - adds them back to the deck, and shuffles them. - """ - # 1. Get all cards in players' hands - all_hand_cards = set() - for player in game.players: - all_hand_cards.update(get_player_hand(player)) - - # 2. Get active cards on the table - # We must keep the original_card of active obstacles, and the top card in their played_cards list - active_table_cards = set() - for obs in game.obstacles: - active_table_cards.add(obs.original_card) - played = json.loads(obs.played_cards) - if played: - active_table_cards.add(played[-1]["card"]) - - # 3. The full deck of 54 cards - full_deck = [] - for suit in cards.SUITS.keys(): - for val in cards.VALUES: - full_deck.append(f"{val}{suit}") - full_deck.extend(["Joker1", "Joker2"]) - - # 4. Remaining cards to shuffle - remaining_cards = [c for c in full_deck if c not in all_hand_cards and c not in active_table_cards] - random.shuffle(remaining_cards) - - set_game_deck(game, remaining_cards) - db.add(game) - db.commit() - -def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -> List[str]: - """Draws count cards from the deck for a player, reshuffling if necessary.""" - deck = get_game_deck(game) - hand = get_player_hand(player) - drawn = [] - - for _ in range(count): - if not deck: - # Reshuffle discard pile - reshuffle_discard_pile(db, game) - deck = get_game_deck(game) - if not deck: - # If still empty (all 54 cards are in hands or active), we can't draw - break - card = deck.pop(0) - hand.append(card) - drawn.append(card) - - set_game_deck(game, deck) - set_player_hand(player, hand) - db.add(game) - db.add(player) - db.commit() - return drawn - -# --- CRUD Operations --- - -def create_game(db: Session, crew_name: Optional[str] = None) -> Game: - deck = cards.get_fresh_deck() - game = Game( - deck_cards=json.dumps(deck), - phase="lobby", - current_scene_number=1, - extra_obstacles=0, - crew_name=crew_name - ) - db.add(game) - db.commit() - db.refresh(game) - return game - -def get_game(db: Session, game_id: str) -> Optional[Game]: - return db.get(Game, game_id) - -def get_player(db: Session, player_id: str) -> Optional[Player]: - return db.get(Player, player_id) - -def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player: - game = db.get(Game, game_id) - role = None - if game and game.phase == "scene": - role = "pirat" - - player = Player( - game_id=game_id, - name=name, - is_creator=is_creator, - rank=2, # default starting rank (will be set properly when starting character creation) - hand_cards="[]", - is_ready=False, - role=role - ) - db.add(player) - db.commit() - db.refresh(player) - return player - -# --- Character Creation Operations --- - -def delegate_question(db: Session, player_id: str, question_type: str, target_player_id: str): - player = get_player(db, player_id) - if not player: - return - if question_type == "like": - player.other_like_from_player_id = target_player_id - player.other_like = "" # Clear old answer - elif question_type == "hate": - player.other_hate_from_player_id = target_player_id - player.other_hate = "" # Clear old answer - db.add(player) - db.commit() - -def submit_delegated_answer(db: Session, target_player_id: str, question_type: str, answer: str, from_player_id: str): - player = get_player(db, target_player_id) - if not player: - return - if question_type == "like" and player.other_like_from_player_id == from_player_id: - player.other_like = answer - elif question_type == "hate" and player.other_hate_from_player_id == from_player_id: - player.other_hate = answer - db.add(player) - db.commit() - -def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3: str): - player = get_player(db, player_id) - if not player: - return - player.created_techniques = json.dumps([tech1, tech2, tech3]) - db.add(player) - db.commit() - - # Check if all players have submitted techniques. If so, trigger the swap! - game = get_game(db, player.game_id) - if not game: - return - - all_submitted = True - for p in game.players: - 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) - -def trigger_technique_swap(db: Session, game: Game): - """ - Shuffles and distributes 3 techniques created by OTHER players to each player. - """ - players = game.players - if not players: - return - - # Build a pool of all techniques with their creators - pool = [] - for p in players: - techs = json.loads(p.created_techniques) - for t in techs: - pool.append({"text": t, "creator_id": p.id}) - - # Swap algorithm with simple retry backtracking - # We want to assign 3 techniques from the pool to each player, ensuring creator_id != player.id. - # For a small number of players, random shuffling with retries is highly efficient. - success = False - for attempt in range(200): - random.shuffle(pool) - assignments = {p.id: [] for p in players} - temp_pool = pool.copy() - - # Greedy assignment - failed = False - for p in players: - valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id] - if len(valid_for_p) < 3: - # If only 1-2 players are playing, it might be impossible to assign 3 non-owned techniques. - # In that case, let's allow assigning their own if there are no other options (e.g. 1 player). - if len(players) < 3: - valid_for_p = temp_pool.copy() - else: - failed = True - break - - # Take 3 - chosen = valid_for_p[:3] - for c in chosen: - assignments[p.id].append(c["text"]) - temp_pool.remove(c) - - if not failed: - # Successfully matched! Save assignments. - for p in players: - p.swapped_techniques = json.dumps(assignments[p.id]) - p.is_ready = False # Reset ready flag for J/Q/K assignment step - db.add(p) - game.phase = "swap_techniques" - db.add(game) - db.commit() - success = True - break - - if not success: - # Fallback in case of failure (e.g., edge cases, single player testing): just distribute whatever - for p in players: - # Just give them the first 3 from the pool - p.swapped_techniques = json.dumps([t["text"] for t in pool[:3]]) - p.is_ready = False - db.add(p) - game.phase = "swap_techniques" - db.add(game) - db.commit() - -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 - 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! - game = get_game(db, player.game_id) - if not game: - return - - if all(p.is_ready for p in game.players): - # 1. Randomly assign starting ranks - players_list = list(game.players) - 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 1. This Player must play their Pi-Rat during the first scene." - # "All remaining Players start at Rank 2. These Players may choose to play either during the first scene." - - if len(players_list) >= 2: - players_list[0].rank = 3 - players_list[0].role = "deep" - players_list[0].previous_role = "deep" - - players_list[1].rank = 1 - players_list[1].role = "pirat" - players_list[1].previous_role = "pirat" - - for p in players_list[2:]: - p.rank = 2 - p.role = None - p.previous_role = None - else: - # Edge case for 1 player testing - players_list[0].rank = 3 - players_list[0].role = "pirat" - players_list[0].previous_role = "pirat" - - # Reset ready status for scene setup - for p in game.players: - p.is_ready = False - db.add(p) - - game.phase = "scene_setup" - db.add(game) - db.commit() - -# --- Scene Setup Operations --- - -def update_scene_role(db: Session, player_id: str, role: str): - player = get_player(db, player_id) - if not player: - return - player.role = role - db.add(player) - db.commit() - -def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: - """ - Validates that: - 1. At least 1 Pi-Rat and 1 Deep player are selected. - 2. Any player who was Deep in the previous scene must play Pi-Rat in this scene. - If valid, starts the scene: shuffles deck, draws obstacles, draws starting hands, resets ready flags. - """ - game = get_game(db, game_id) - if not game: - return False, "Game not found." - - players = game.players - - # Default any unselected roles to 'pirat' - for p in players: - if p.role is None: - p.role = "pirat" - db.add(p) - db.commit() - - # 1. Check previous Deep players - # "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene." - # Note: Only enforce if this is scene 2+, and we have enough players to make a rotation. - if game.current_scene_number > 1 and len(players) >= 3: - for p in players: - if p.previous_role == "deep" and p.role == "deep": - return False, f"{p.name} was Deep in the last scene and must play a Pi-Rat in this scene." - - # 2. Check single eligible Deep player - # If only one player is eligible to play the Deep, they must play the Deep. - if game.current_scene_number > 1: - eligible_deeps = [p for p in players if p.previous_role != "deep"] - if len(eligible_deeps) == 1: - must_be_deep_player = eligible_deeps[0] - if must_be_deep_player.role != "deep": - return False, f"{must_be_deep_player.name} is the only player eligible to play the Deep and must play the Deep in this scene." - - # 3. Check counts - pirats_count = sum(1 for p in players if p.role == "pirat") - deeps_count = sum(1 for p in players if p.role == "deep") - - if pirats_count < 1: - return False, "You need at least one Pi-Rat player to play." - if deeps_count < 1: - return False, "You need at least one Deep player." - - # Everything is valid! Start the scene. - # A. Reset obstacles - for obs in game.obstacles: - db.delete(obs) - for vote in game.votes: - db.delete(vote) - db.commit() - - # B. Set previous_role to current role, reset is_ready - for p in players: - p.previous_role = p.role - p.is_ready = False - db.add(p) - - # C. Shuffle remaining deck (cards not in player hands) - all_hand_cards = set() - for p in players: - all_hand_cards.update(get_player_hand(p)) - - full_deck = [] - for suit in cards.SUITS.keys(): - for val in cards.VALUES: - full_deck.append(f"{val}{suit}") - full_deck.extend(["Joker1", "Joker2"]) - - deck = [c for c in full_deck if c not in all_hand_cards] - random.shuffle(deck) - - # D. Draw Obstacles: At least D + 1 obstacles, plus any extra_obstacles from Jokers - num_obstacles_to_draw = deeps_count + 1 + game.extra_obstacles - # Reset extra_obstacles for the next scene - game.extra_obstacles = 0 - - # Draw obstacles, handling Jokers - drawn_obstacles = [] - while len(drawn_obstacles) < num_obstacles_to_draw: - if not deck: - break - card = deck.pop(0) - parsed = cards.parse_card(card) - - if parsed["is_joker"]: - # Joker drawn as Obstacle: - # "Immediately discard Jokers when they are drawn this way, then increase the total number of Obstacles the Players must have on the Obstacle list during the following Scene by one" - game.extra_obstacles += 1 - # Joker is discarded, we don't add it to the active obstacles, and we draw again. - continue - - info = cards.get_obstacle_info(card) - obstacle = Obstacle( - game_id=game.id, - original_card=card, - suit=info["suit"], - title=info["title"], - description=info["description"], - current_value=info["initial_value"], - played_cards="[]" - ) - db.add(obstacle) - drawn_obstacles.append(obstacle) - - # E. Top up player hands to their maximum size - for p in players: - max_size = calculate_max_hand_size(p, players) - current_hand = get_player_hand(p) - if len(current_hand) < max_size: - diff = max_size - len(current_hand) - # Draw diff cards - for _ in range(diff): - if not deck: - break - card = deck.pop(0) - current_hand.append(card) - p.hand_cards = json.dumps(current_hand) - db.add(p) - - game.deck_cards = json.dumps(deck) - game.phase = "scene" - db.add(game) - db.commit() - - return True, "Scene started successfully!" - -# --- Scene Gameplay Operations --- - -def play_card_on_obstacle( - db: Session, - player_id: str, - obstacle_id: str, - card_code: str -) -> Tuple[bool, str, Dict[str, Any]]: - """ - Plays a card from player's hand against an active obstacle. - Updates the obstacle value, checks matching suit color to draw back, - and returns resolution results. - """ - player = get_player(db, player_id) - obstacle = db.get(Obstacle, obstacle_id) - game = get_game(db, player.game_id) - - if not player or not obstacle or not game: - return False, "Game entities not found.", {} - - hand = get_player_hand(player) - if card_code not in hand: - return False, "Card not in hand.", {} - - # Remove from hand - hand.remove(card_code) - set_player_hand(player, hand) - - # Parse card and obstacle - played_parsed = cards.parse_card(card_code) - orig_obs_parsed = cards.parse_card(obstacle.original_card) - - # 1. Determine Success/Failure - success = False - details = "" - is_technique = False - tech_name = "" - - # Value rules: - # A. Face Cards (Jack, Queen, King) played by Pi-Rat are Secret Pirate Techniques: Automatic Success! - if played_parsed["value"] in ["J", "Q", "K"] and player.role == "pirat": - success = True - is_technique = True - if played_parsed["value"] == "J": - tech_name = player.tech_jack or "Jack Secret Technique" - elif played_parsed["value"] == "Q": - tech_name = player.tech_queen or "Queen Secret Technique" - elif played_parsed["value"] == "K": - tech_name = player.tech_king or "King Secret Technique" - details = f"Used Secret Pirate Technique: '{tech_name}'!" - - # B. Jokers played by Pi-Rat: - # "Discard one Obstacle from the Obstacle List entirely... and draw a new Obstacle card from the deck" - elif played_parsed["is_joker"]: - success = True - details = "Played a Joker! This obstacle is discarded, and a new one is drawn." - # We will handle the actual obstacle replacement below! - - else: - # Regular card play vs Obstacle value. - # What is the Obstacle's current value? - # If the obstacle is J, Q, K: its value is the Rank of the challenged player. - # Otherwise it is the obstacle's current_value (numerical). - obs_val_card = cards.parse_card(obstacle.original_card) - played_on_obs = json.loads(obstacle.played_cards) - if played_on_obs: - obs_val_card = cards.parse_card(played_on_obs[-1]["card"]) - - if obs_val_card["value"] in ["J", "Q", "K"]: - target_value = player.rank - obs_detail = f"Rank {player.rank} (Face Card Obstacle)" - else: - target_value = obstacle.current_value - obs_detail = str(target_value) - - # What is the played card's value? - played_val = played_parsed["numeric_value"] - - # Apply privileges: - # Gat Privileges: +1 to Black cards (Clubs/Spades) if player has completed Personal Objective 1 - # Name Privileges: +1 to Red cards (Hearts/Diamonds) if player has completed Personal Objective 2 - privilege_bonus = 0 - if player.completed_personal_1 and played_parsed["suit"] in ["C", "S"]: - privilege_bonus = 1 - if player.completed_personal_2 and played_parsed["suit"] in ["H", "D"]: - privilege_bonus = 1 - - final_played_value = played_val + privilege_bonus - - # Compare - if final_played_value > target_value: - success = True - details = f"Success! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}." - else: - success = False - details = f"Failure! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}." - - # 2. Card Draw (Step 6 of Resolving Challenges) - # "draw 1 card and add it to their Hand for each card they played that matched the suit color (red or black) of the original Obstacle card" - drew_card = None - if not played_parsed["is_joker"]: - if played_parsed["color"] == orig_obs_parsed["color"]: - # Match! Draw 1 card - drawn = draw_cards_for_player(db, game, player, 1) - if drawn: - drew_card = drawn[0] - details += f" (Drew back {cards.parse_card(drew_card)['display']} due to matching suit colors!)" - - # 3. Update Obstacle Table & Played Card list - played_list = json.loads(obstacle.played_cards) - played_list.append({ - "card": card_code, - "player_id": player.id, - "player_name": player.name, - "success": success, - "details": details - }) - obstacle.played_cards = json.dumps(played_list) - - # The last played card (if not Joker) becomes the obstacle's new value - if not played_parsed["is_joker"]: - obstacle.current_value = played_parsed["numeric_value"] - db.add(obstacle) - else: - # It's a Joker! We replace the obstacle. - # Discard this obstacle and draw a new one - db.delete(obstacle) - db.commit() - - # Draw replacement obstacle from deck - deck = get_game_deck(game) - new_obs = None - while deck: - new_card = deck.pop(0) - new_parsed = cards.parse_card(new_card) - if new_parsed["is_joker"]: - # Joker drawn: increment next scene's obstacles and discard it - game.extra_obstacles += 1 - continue - - info = cards.get_obstacle_info(new_card) - new_obs = Obstacle( - game_id=game.id, - original_card=new_card, - suit=info["suit"], - title=info["title"], - description=info["description"], - current_value=info["initial_value"], - played_cards="[]" - ) - db.add(new_obs) - break - game.deck_cards = json.dumps(deck) - db.add(game) - db.commit() - - db.add(player) - db.commit() - - res_dict = { - "success": success, - "details": details, - "is_technique": is_technique, - "tech_name": tech_name, - "drew_card": drew_card, - "is_joker": played_parsed["is_joker"] - } - - return True, "Card played successfully.", res_dict - -def toggle_objective(db: Session, player_id: str, obj_type: str, status: bool): - """Toggles Personal or Crew objectives and adjusts Ranks accordingly.""" - player = get_player(db, player_id) - if not player: - return - - # Check if a rank modification is needed - # Completing Personal Objective 1 (Get a Gat): Gain 1 Rank - # Completing Personal Objective 2 (Earn a Name): Gain 1 Rank - # Completing Personal Objective 3: Rank up another player (handled manually or via choices) - rank_diff = 0 - if obj_type == "personal_1": - if status and not player.completed_personal_1: - rank_diff = 1 - elif not status and player.completed_personal_1: - rank_diff = -1 - player.completed_personal_1 = status - - elif obj_type == "personal_2": - if status and not player.completed_personal_2: - rank_diff = 1 - elif not status and player.completed_personal_2: - rank_diff = -1 - player.completed_personal_2 = status - - elif obj_type == "personal_3": - player.completed_personal_3 = status - # Note: rulebook says completing 3rd Personal Objective lets you 'Choose another Pi-Rat to gain 1 Rank'. - # We can handle this through a specific UI trigger in the "Between Scenes" phase! - - player.rank += rank_diff - # Rank floor/ceiling logic (usually rank ranges from 1 to 5, let's keep it 1 to 10) - player.rank = max(1, player.rank) - db.add(player) - db.commit() - -# --- Between Scenes Operations --- - -def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str): - # 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() - -def end_scene_and_transition(db: Session, game_id: str): - """Moves game phase to between_scenes.""" - game = get_game(db, game_id) - if not game: - return - 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() - -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 - if not votes: - 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: - winner.rank += 1 - db.add(winner) - - # Clear all votes - for v in votes: - db.delete(v) - db.commit() - -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) - - # 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" - else: - # Fallback in case there were no Deep players - # Just advance directly to scene_setup - game.current_scene_number += 1 - game.phase = "scene_setup" - for p in game.players: - p.role = None - p.is_ready = False - db.add(p) - - db.add(game) - db.commit() - -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 - # Max size for deep player is Rank + 1 - max_size = player.rank + 1 - 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() - - # Check if all resting deep players are ready - resting_deep = [p for p in game.players if p.previous_role == "deep"] - if all(p.is_ready for p in resting_deep): - # All resting deep players have refreshed their hands! - # Transition to scene_setup! - game.current_scene_number += 1 - game.phase = "scene_setup" - for p in game.players: - p.is_ready = False - p.role = None - db.add(p) - db.add(game) - db.commit() +# Facade for backward compatibility and centralized access +from .crud_base import * +from .crud_character import * +from .crud_scene import * +from .crud_upkeep import * diff --git a/src/pirats/crud_base.py b/src/pirats/crud_base.py new file mode 100644 index 0000000..9c1ef71 --- /dev/null +++ b/src/pirats/crud_base.py @@ -0,0 +1,182 @@ +import json +import random +from typing import List, Optional +from sqlmodel import Session +from .models import Game, Player, Obstacle +from . import cards + +# --- Card and Hand Utilities --- + +def get_player_hand(player: Player) -> List[str]: + return json.loads(player.hand_cards) + +def set_player_hand(player: Player, hand: List[str]): + player.hand_cards = json.dumps(hand) + +def get_game_deck(game: Game) -> List[str]: + return json.loads(game.deck_cards) + +def set_game_deck(game: Game, deck: List[str]): + game.deck_cards = json.dumps(deck) + +def calculate_max_hand_size(player: Player, players_in_scene: List[Player]) -> int: + """ + Calculates a player's maximum hand size based on their Rank relative to others in the scene, + and Captain privileges (+1). + Rank Hand sizes: + - Highest Rank Pi-Rat(s): max 4 cards + - Middle Rank Pi-Rat(s): max 3 cards + - Lowest Rank Pi-Rat(s): max 2 cards + - Captain gets +1 + If all players have the same Rank, they are all 'middle' and get 3 cards. + """ + if player.role == "deep": + # Deep players still have hands (for when they transition), let's say they have size based on Rank + # but don't participate in the Pi-Rat ranking calculations. + # Deep player max hand size is 3 by default, or equal to Rank. Let's make it equal to Rank + 1. + return player.rank + 1 + + pi_rats = [p for p in players_in_scene if p.role != "deep"] + if not pi_rats: + return 3 + + ranks = [p.rank for p in pi_rats] + max_rank = max(ranks) + min_rank = min(ranks) + + # Base size calculation + if len(set(ranks)) == 1: + # All have the same rank + base_size = 3 + else: + if player.rank == max_rank: + base_size = 4 + elif player.rank == min_rank: + base_size = 2 + else: + base_size = 3 + + # Captain check (highest rank Pi-Rat is de facto Captain) + # The rulebook: 'Initially, the highest-Ranked Pi-Rat becomes the de facto Captain. + # Captain Privileges: The Captain has their Hand size increased by 1, regardless of their Rank.' + # If there's a tie for highest rank, we'll designate the first one in alphabetical/ID order. + highest_ranked_pirats = [p for p in pi_rats if p.rank == max_rank] + highest_ranked_pirats.sort(key=lambda p: p.id) + is_captain = len(highest_ranked_pirats) > 0 and highest_ranked_pirats[0].id == player.id + + if is_captain: + return base_size + 1 + return base_size + +def is_player_captain(player: Player, players_in_scene: List[Player]) -> bool: + """Helper to check if a player is currently the captain in the scene.""" + if player.role == "deep": + return False + pi_rats = [p for p in players_in_scene if p.role != "deep"] + if not pi_rats: + return False + max_rank = max(p.rank for p in pi_rats) + highest_ranked = [p for p in pi_rats if p.rank == max_rank] + highest_ranked.sort(key=lambda p: p.id) + return len(highest_ranked) > 0 and highest_ranked[0].id == player.id + +def reshuffle_discard_pile(db: Session, game: Game): + """ + Gathers all cards not currently in any player's hand and not active on the table, + adds them back to the deck, and shuffles them. + """ + # 1. Get all cards in players' hands + all_hand_cards = set() + for player in game.players: + all_hand_cards.update(get_player_hand(player)) + + # 2. Get active cards on the table + # We must keep the original_card of active obstacles, and the top card in their played_cards list + active_table_cards = set() + for obs in game.obstacles: + active_table_cards.add(obs.original_card) + played = json.loads(obs.played_cards) + if played: + active_table_cards.add(played[-1]["card"]) + + # 3. The full deck of 54 cards + full_deck = [] + for suit in cards.SUITS.keys(): + for val in cards.VALUES: + full_deck.append(f"{val}{suit}") + full_deck.extend(["Joker1", "Joker2"]) + + # 4. Remaining cards to shuffle + remaining_cards = [c for c in full_deck if c not in all_hand_cards and c not in active_table_cards] + random.shuffle(remaining_cards) + + set_game_deck(game, remaining_cards) + db.add(game) + db.commit() + +def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -> List[str]: + """Draws count cards from the deck for a player, reshuffling if necessary.""" + deck = get_game_deck(game) + hand = get_player_hand(player) + drawn = [] + + for _ in range(count): + if not deck: + # Reshuffle discard pile + reshuffle_discard_pile(db, game) + deck = get_game_deck(game) + if not deck: + # If still empty (all 54 cards are in hands or active), we can't draw + break + card = deck.pop(0) + hand.append(card) + drawn.append(card) + + set_game_deck(game, deck) + set_player_hand(player, hand) + db.add(game) + db.add(player) + db.commit() + return drawn + +# --- CRUD Operations --- + +def create_game(db: Session, crew_name: Optional[str] = None) -> Game: + deck = cards.get_fresh_deck() + game = Game( + deck_cards=json.dumps(deck), + phase="lobby", + current_scene_number=1, + extra_obstacles=0, + crew_name=crew_name + ) + db.add(game) + db.commit() + db.refresh(game) + return game + +def get_game(db: Session, game_id: str) -> Optional[Game]: + return db.get(Game, game_id) + +def get_player(db: Session, player_id: str) -> Optional[Player]: + return db.get(Player, player_id) + +def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player: + game = db.get(Game, game_id) + role = None + if game and game.phase == "scene": + role = "pirat" + + player = Player( + game_id=game_id, + name=name, + is_creator=is_creator, + rank=2, # default starting rank (will be set properly when starting character creation) + hand_cards="[]", + is_ready=False, + role=role + ) + db.add(player) + db.commit() + db.refresh(player) + return player diff --git a/src/pirats/crud_character.py b/src/pirats/crud_character.py new file mode 100644 index 0000000..afab53f --- /dev/null +++ b/src/pirats/crud_character.py @@ -0,0 +1,173 @@ +import json +import random +from sqlmodel import Session +from .models import Game, Player +from .crud_base import get_player, get_game + +# --- Character Creation Operations --- + +def delegate_question(db: Session, player_id: str, question_type: str, target_player_id: str): + player = get_player(db, player_id) + if not player: + return + if question_type == "like": + player.other_like_from_player_id = target_player_id + player.other_like = "" # Clear old answer + elif question_type == "hate": + player.other_hate_from_player_id = target_player_id + player.other_hate = "" # Clear old answer + db.add(player) + db.commit() + +def submit_delegated_answer(db: Session, target_player_id: str, question_type: str, answer: str, from_player_id: str): + player = get_player(db, target_player_id) + if not player: + return + if question_type == "like" and player.other_like_from_player_id == from_player_id: + player.other_like = answer + elif question_type == "hate" and player.other_hate_from_player_id == from_player_id: + player.other_hate = answer + db.add(player) + db.commit() + +def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3: str): + player = get_player(db, player_id) + if not player: + return + player.created_techniques = json.dumps([tech1, tech2, tech3]) + db.add(player) + db.commit() + + # Check if all players have submitted techniques. If so, trigger the swap! + game = get_game(db, player.game_id) + if not game: + return + + all_submitted = True + for p in game.players: + 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) + +def trigger_technique_swap(db: Session, game: Game): + """ + Shuffles and distributes 3 techniques created by OTHER players to each player. + """ + players = game.players + if not players: + return + + # Build a pool of all techniques with their creators + pool = [] + for p in players: + techs = json.loads(p.created_techniques) + for t in techs: + pool.append({"text": t, "creator_id": p.id}) + + # Swap algorithm with simple retry backtracking + # We want to assign 3 techniques from the pool to each player, ensuring creator_id != player.id. + # For a small number of players, random shuffling with retries is highly efficient. + success = False + for attempt in range(200): + random.shuffle(pool) + assignments = {p.id: [] for p in players} + temp_pool = pool.copy() + + # Greedy assignment + failed = False + for p in players: + valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id] + if len(valid_for_p) < 3: + # If only 1-2 players are playing, it might be impossible to assign 3 non-owned techniques. + # In that case, let's allow assigning their own if there are no other options (e.g. 1 player). + if len(players) < 3: + valid_for_p = temp_pool.copy() + else: + failed = True + break + + # Take 3 + chosen = valid_for_p[:3] + for c in chosen: + assignments[p.id].append(c["text"]) + temp_pool.remove(c) + + if not failed: + # Successfully matched! Save assignments. + for p in players: + p.swapped_techniques = json.dumps(assignments[p.id]) + p.is_ready = False # Reset ready flag for J/Q/K assignment step + db.add(p) + game.phase = "swap_techniques" + db.add(game) + db.commit() + success = True + break + + if not success: + # Fallback in case of failure (e.g., edge cases, single player testing): just distribute whatever + for p in players: + # Just give them the first 3 from the pool + p.swapped_techniques = json.dumps([t["text"] for t in pool[:3]]) + p.is_ready = False + db.add(p) + game.phase = "swap_techniques" + db.add(game) + db.commit() + +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 + 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! + game = get_game(db, player.game_id) + if not game: + return + + if all(p.is_ready for p in game.players): + # 1. Randomly assign starting ranks + players_list = list(game.players) + 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 1. This Player must play their Pi-Rat during the first scene." + # "All remaining Players start at Rank 2. These Players may choose to play either during the first scene." + + if len(players_list) >= 2: + players_list[0].rank = 3 + players_list[0].role = "deep" + players_list[0].previous_role = "deep" + + players_list[1].rank = 1 + players_list[1].role = "pirat" + players_list[1].previous_role = "pirat" + + for p in players_list[2:]: + p.rank = 2 + p.role = None + p.previous_role = None + else: + # Edge case for 1 player testing + players_list[0].rank = 3 + players_list[0].role = "pirat" + players_list[0].previous_role = "pirat" + + # Reset ready status for scene setup + for p in game.players: + p.is_ready = False + db.add(p) + + game.phase = "scene_setup" + db.add(game) + db.commit() diff --git a/src/pirats/crud_scene.py b/src/pirats/crud_scene.py new file mode 100644 index 0000000..49d33de --- /dev/null +++ b/src/pirats/crud_scene.py @@ -0,0 +1,337 @@ +import json +import random +from typing import Tuple, Dict, Any +from sqlmodel import Session +from .models import Game, Player, Obstacle +from . import cards +from .crud_base import ( + get_player, get_game, get_player_hand, set_player_hand, + get_game_deck, set_game_deck, calculate_max_hand_size, draw_cards_for_player +) + +# --- Scene Setup Operations --- + +def update_scene_role(db: Session, player_id: str, role: str): + player = get_player(db, player_id) + if not player: + return + player.role = role + db.add(player) + db.commit() + +def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: + """ + Validates that: + 1. At least 1 Pi-Rat and 1 Deep player are selected. + 2. Any player who was Deep in the previous scene must play Pi-Rat in this scene. + If valid, starts the scene: shuffles deck, draws obstacles, draws starting hands, resets ready flags. + """ + game = get_game(db, game_id) + if not game: + return False, "Game not found." + + players = game.players + + # Default any unselected roles to 'pirat' + for p in players: + if p.role is None: + p.role = "pirat" + db.add(p) + db.commit() + + # 1. Check previous Deep players + # "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene." + # Note: Only enforce if this is scene 2+, and we have enough players to make a rotation. + if game.current_scene_number > 1 and len(players) >= 3: + for p in players: + if p.previous_role == "deep" and p.role == "deep": + return False, f"{p.name} was Deep in the last scene and must play a Pi-Rat in this scene." + + # 2. Check single eligible Deep player + # If only one player is eligible to play the Deep, they must play the Deep. + if game.current_scene_number > 1: + eligible_deeps = [p for p in players if p.previous_role != "deep"] + if len(eligible_deeps) == 1: + must_be_deep_player = eligible_deeps[0] + if must_be_deep_player.role != "deep": + return False, f"{must_be_deep_player.name} is the only player eligible to play the Deep and must play the Deep in this scene." + + # 3. Check counts + pirats_count = sum(1 for p in players if p.role == "pirat") + deeps_count = sum(1 for p in players if p.role == "deep") + + if pirats_count < 1: + return False, "You need at least one Pi-Rat player to play." + if deeps_count < 1: + return False, "You need at least one Deep player." + + # Everything is valid! Start the scene. + # A. Reset obstacles + for obs in game.obstacles: + db.delete(obs) + for vote in game.votes: + db.delete(vote) + db.commit() + + # B. Set previous_role to current role, reset is_ready + for p in players: + p.previous_role = p.role + p.is_ready = False + db.add(p) + + # C. Shuffle remaining deck (cards not in player hands) + all_hand_cards = set() + for p in players: + all_hand_cards.update(get_player_hand(p)) + + full_deck = [] + for suit in cards.SUITS.keys(): + for val in cards.VALUES: + full_deck.append(f"{val}{suit}") + full_deck.extend(["Joker1", "Joker2"]) + + deck = [c for c in full_deck if c not in all_hand_cards] + random.shuffle(deck) + + # D. Draw Obstacles: At least D + 1 obstacles, plus any extra_obstacles from Jokers + num_obstacles_to_draw = deeps_count + 1 + game.extra_obstacles + # Reset extra_obstacles for the next scene + game.extra_obstacles = 0 + + # Draw obstacles, handling Jokers + drawn_obstacles = [] + while len(drawn_obstacles) < num_obstacles_to_draw: + if not deck: + break + card = deck.pop(0) + parsed = cards.parse_card(card) + + if parsed["is_joker"]: + # Joker drawn as Obstacle: + # Immediately discard Jokers when they are drawn this way, and increase the total obstacles + game.extra_obstacles += 1 + continue + + info = cards.get_obstacle_info(card) + obstacle = Obstacle( + game_id=game.id, + original_card=card, + suit=info["suit"], + title=info["title"], + description=info["description"], + current_value=info["initial_value"], + played_cards="[]" + ) + db.add(obstacle) + drawn_obstacles.append(obstacle) + + # E. Top up player hands to their maximum size + for p in players: + max_size = calculate_max_hand_size(p, players) + current_hand = get_player_hand(p) + if len(current_hand) < max_size: + diff = max_size - len(current_hand) + # Draw diff cards + for _ in range(diff): + if not deck: + break + card = deck.pop(0) + current_hand.append(card) + p.hand_cards = json.dumps(current_hand) + db.add(p) + + game.deck_cards = json.dumps(deck) + game.phase = "scene" + db.add(game) + db.commit() + + return True, "Scene started successfully!" + +# --- Scene Gameplay Operations --- + +def play_card_on_obstacle( + db: Session, + player_id: str, + obstacle_id: str, + card_code: str +) -> Tuple[bool, str, Dict[str, Any]]: + """ + Plays a card from player's hand against an active obstacle. + Updates the obstacle value, checks matching suit color to draw back, + and returns resolution results. + """ + player = get_player(db, player_id) + obstacle = db.get(Obstacle, obstacle_id) + game = get_game(db, player.game_id) + + if not player or not obstacle or not game: + return False, "Game entities not found.", {} + + hand = get_player_hand(player) + if card_code not in hand: + return False, "Card not in hand.", {} + + # Remove from hand + hand.remove(card_code) + set_player_hand(player, hand) + + # Parse card and obstacle + played_parsed = cards.parse_card(card_code) + orig_obs_parsed = cards.parse_card(obstacle.original_card) + + # 1. Determine Success/Failure + success = False + details = "" + is_technique = False + tech_name = "" + + # Value rules: + # A. Face Cards (Jack, Queen, King) played by Pi-Rat are Secret Pirate Techniques: Automatic Success! + if played_parsed["value"] in ["J", "Q", "K"] and player.role == "pirat": + success = True + is_technique = True + if played_parsed["value"] == "J": + tech_name = player.tech_jack or "Jack Secret Technique" + elif played_parsed["value"] == "Q": + tech_name = player.tech_queen or "Queen Secret Technique" + elif played_parsed["value"] == "K": + tech_name = player.tech_king or "King Secret Technique" + details = f"Used Secret Pirate Technique: '{tech_name}'!" + + # B. Jokers played by Pi-Rat: + elif played_parsed["is_joker"]: + success = True + details = "Played a Joker! This obstacle is discarded, and a new one is drawn." + + else: + # Regular card play vs Obstacle value. + obs_val_card = cards.parse_card(obstacle.original_card) + played_on_obs = json.loads(obstacle.played_cards) + if played_on_obs: + obs_val_card = cards.parse_card(played_on_obs[-1]["card"]) + + if obs_val_card["value"] in ["J", "Q", "K"]: + target_value = player.rank + obs_detail = f"Rank {player.rank} (Face Card Obstacle)" + else: + target_value = obstacle.current_value + obs_detail = str(target_value) + + # What is the played card's value? + played_val = played_parsed["numeric_value"] + + # Apply privileges: + privilege_bonus = 0 + if player.completed_personal_1 and played_parsed["suit"] in ["C", "S"]: + privilege_bonus = 1 + if player.completed_personal_2 and played_parsed["suit"] in ["H", "D"]: + privilege_bonus = 1 + + final_played_value = played_val + privilege_bonus + + # Compare + if final_played_value > target_value: + success = True + details = f"Success! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}." + else: + success = False + details = f"Failure! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}." + + # 2. Card Draw (Step 6 of Resolving Challenges) + drew_card = None + if not played_parsed["is_joker"]: + if played_parsed["color"] == orig_obs_parsed["color"]: + # Match! Draw 1 card + drawn = draw_cards_for_player(db, game, player, 1) + if drawn: + drew_card = drawn[0] + details += f" (Drew back {cards.parse_card(drew_card)['display']} due to matching suit colors!)" + + # 3. Update Obstacle Table & Played Card list + played_list = json.loads(obstacle.played_cards) + played_list.append({ + "card": card_code, + "player_id": player.id, + "player_name": player.name, + "success": success, + "details": details + }) + obstacle.played_cards = json.dumps(played_list) + + # The last played card (if not Joker) becomes the obstacle's new value + if not played_parsed["is_joker"]: + obstacle.current_value = played_parsed["numeric_value"] + db.add(obstacle) + else: + # It's a Joker! We replace the obstacle. + db.delete(obstacle) + db.commit() + + # Draw replacement obstacle from deck + deck = get_game_deck(game) + new_obs = None + while deck: + new_card = deck.pop(0) + new_parsed = cards.parse_card(new_card) + if new_parsed["is_joker"]: + game.extra_obstacles += 1 + continue + + info = cards.get_obstacle_info(new_card) + new_obs = Obstacle( + game_id=game.id, + original_card=new_card, + suit=info["suit"], + title=info["title"], + description=info["description"], + current_value=info["initial_value"], + played_cards="[]" + ) + db.add(new_obs) + break + game.deck_cards = json.dumps(deck) + db.add(game) + db.commit() + + db.add(player) + db.commit() + + res_dict = { + "success": success, + "details": details, + "is_technique": is_technique, + "tech_name": tech_name, + "drew_card": drew_card, + "is_joker": played_parsed["is_joker"] + } + + return True, "Card played successfully.", res_dict + +def toggle_objective(db: Session, player_id: str, obj_type: str, status: bool): + """Toggles Personal or Crew objectives and adjusts Ranks accordingly.""" + player = get_player(db, player_id) + if not player: + return + + rank_diff = 0 + if obj_type == "personal_1": + if status and not player.completed_personal_1: + rank_diff = 1 + elif not status and player.completed_personal_1: + rank_diff = -1 + player.completed_personal_1 = status + + elif obj_type == "personal_2": + if status and not player.completed_personal_2: + rank_diff = 1 + elif not status and player.completed_personal_2: + rank_diff = -1 + player.completed_personal_2 = status + + elif obj_type == "personal_3": + player.completed_personal_3 = status + + player.rank += rank_diff + player.rank = max(1, player.rank) + db.add(player) + db.commit() diff --git a/src/pirats/crud_upkeep.py b/src/pirats/crud_upkeep.py new file mode 100644 index 0000000..0a5e8fe --- /dev/null +++ b/src/pirats/crud_upkeep.py @@ -0,0 +1,154 @@ +import json +import random +from typing import List +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 +) + +# --- Between Scenes Operations --- + +def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str): + # 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() + +def end_scene_and_transition(db: Session, game_id: str): + """Moves game phase to between_scenes.""" + game = get_game(db, game_id) + if not game: + return + 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() + +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 + if not votes: + 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: + winner.rank += 1 + db.add(winner) + + # Clear all votes + for v in votes: + db.delete(v) + db.commit() + +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) + + # 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" + else: + # Fallback in case there were no Deep players + # Just advance directly to scene_setup + game.current_scene_number += 1 + game.phase = "scene_setup" + for p in game.players: + p.role = None + p.is_ready = False + db.add(p) + + db.add(game) + db.commit() + +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 + # Max size for deep player is Rank + 1 + max_size = player.rank + 1 + 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() + + # Check if all resting deep players are ready + resting_deep = [p for p in game.players if p.previous_role == "deep"] + if all(p.is_ready for p in resting_deep): + # All resting deep players have refreshed their hands! + # Transition to scene_setup! + game.current_scene_number += 1 + game.phase = "scene_setup" + for p in game.players: + p.is_ready = False + p.role = None + db.add(p) + db.add(game) + db.commit() diff --git a/src/pirats/main.py b/src/pirats/main.py index cdd116c..6a3449e 100644 --- a/src/pirats/main.py +++ b/src/pirats/main.py @@ -1,35 +1,26 @@ -import json -from typing import List, Optional +from typing import Optional from fastapi import FastAPI, Request, Form, Depends, HTTPException, status from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates from sqlmodel import Session import uvicorn from pathlib import Path from . import crud -from . import cards from .database import create_db_and_tables, get_session -from .models import Game, Player, Obstacle, Vote +from .templates import templates, BASE_DIR app = FastAPI(title="Rats with Gats Remote Play") -# Get the directory of this module -BASE_DIR = Path(__file__).resolve().parent - # Mount static files app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") -# Templates setup -templates = Jinja2Templates(directory=BASE_DIR / "templates") - # Register startup event to create tables @app.on_event("startup") def on_startup(): create_db_and_tables() -# --- HTTP Route Handlers --- +# --- HTTP Core Route Handlers --- @app.get("/", response_class=HTMLResponse) def home(request: Request): @@ -80,23 +71,7 @@ def player_dashboard(request: Request, game_id: str, player_id: str, db: Session "player": player }) -# Creator Admin Panel -@app.get("/game/{game_id}/admin", response_class=HTMLResponse) -def creator_admin_panel(request: Request, game_id: str, key: str, 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") - - base_url = str(request.base_url).rstrip("/") - return templates.TemplateResponse("admin.html", { - "request": request, - "game": game, - "base_url": base_url - }) - -# --- HTMX Snippet and Phase Polling Routes --- +# --- HTMX Core Polling Route --- @app.get("/game/{game_id}/player/{player_id}/phase-check") def phase_check_route(game_id: str, player_id: str, current: str, db: Session = Depends(get_session)): @@ -108,559 +83,21 @@ def phase_check_route(game_id: str, player_id: str, current: str, db: Session = return HTMLResponse(status_code=200, headers={"HX-Trigger": "phase-changed"}) return HTMLResponse(status_code=204) # No content, no reload -@app.get("/game/{game_id}/lobby/players", response_class=HTMLResponse) -def lobby_players_snippet(request: Request, game_id: str, player_id: Optional[str] = None, db: Session = Depends(get_session)): - game = crud.get_game(db, game_id) - if not game: - return HTMLResponse("") - return templates.TemplateResponse("lobby_players_snippet.html", { - "request": request, - "game": game, - "player_id": player_id - }) +# --- Register Modular Phase Routers --- -@app.get("/game/{game_id}/scene/roster", response_class=HTMLResponse) -def scene_roster_snippet(request: Request, game_id: str, db: Session = Depends(get_session)): - game = crud.get_game(db, game_id) - if not game: - return HTMLResponse("") - return templates.TemplateResponse("scene_roster_snippet.html", { - "request": request, - "game": game, - "is_captain": crud.is_player_captain - }) +from .phase_view import router as phase_view_router +from .routes_lobby import router as lobby_router +from .routes_character import router as character_router +from .routes_scene import router as scene_router +from .routes_upkeep import router as upkeep_router +from .routes_admin import router as admin_router -@app.get("/game/{game_id}/scene/obstacles", response_class=HTMLResponse) -def scene_obstacles_snippet(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)): - game = crud.get_game(db, game_id) - player = crud.get_player(db, player_id) - if not game or not player: - return HTMLResponse("") - hand = crud.get_player_hand(player) - return templates.TemplateResponse("scene_obstacles_snippet.html", { - "request": request, - "game": game, - "player": player, - "hand": hand, - "parse_card": cards.parse_card, - "json_loads": json.loads, - "is_captain": crud.is_player_captain - }) - -@app.get("/game/{game_id}/between/roster", response_class=HTMLResponse) -def between_scenes_roster_snippet(request: Request, game_id: str, db: Session = Depends(get_session)): - game = crud.get_game(db, game_id) - if not game: - return HTMLResponse("") - return templates.TemplateResponse("voting_roster_snippet.html", { - "request": request, - "game": game - }) - -# --- HTMX View Polling Render Route --- - -@app.get("/game/{game_id}/player/{player_id}/view", response_class=HTMLResponse) -def get_game_phase_view( - request: Request, - game_id: str, - player_id: str, - edit: bool = False, - db: Session = Depends(get_session) -): - game = crud.get_game(db, game_id) - player = crud.get_player(db, player_id) - if not game or not player: - return HTMLResponse("
Session disconnected or expired.
") - - base_url = str(request.base_url).rstrip("/") - - # Context helper functions to run inside Jinja templates - def get_player_name_by_id(*args) -> str: - pid = args[1] if len(args) == 2 else (args[0] if len(args) == 1 else None) - if not pid: - return "None" - p = db.get(Player, pid) - return p.name if p else "Unknown" - - def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]: - return db.get(Obstacle, obs_id) - - # Core variables - hand = crud.get_player_hand(player) - max_hand_size = crud.calculate_max_hand_size(player, game.players) - - # Compute inbox tasks for current player - inbox_tasks = [] - for p in game.players: - if p.id != player.id: - if p.other_like_from_player_id == player.id and not p.other_like: - inbox_tasks.append({"player": p, "type": "like"}) - if p.other_hate_from_player_id == player.id and not p.other_hate: - inbox_tasks.append({"player": p, "type": "hate"}) - - # Freshly fetch votes to avoid cache stale - from sqlmodel import select - votes = db.exec(select(Vote).where(Vote.game_id == game_id)).all() - - # Calculate eligible deep players - eligible_deeps = [p for p in game.players if p.previous_role != "deep"] - - context = { - "request": request, - "game": game, - "player": player, - "hand": hand, - "max_hand_size": max_hand_size, - "base_url": base_url, - "db": db, - "get_player_name": get_player_name_by_id, - "get_obstacle_by_id": get_obstacle_by_id, - "parse_card": cards.parse_card, - "is_captain": crud.is_player_captain, - "json_loads": json.loads, - "deck_count": len(crud.get_game_deck(game)), - "inbox_tasks": inbox_tasks, - "votes": votes, - "edit_mode": edit, - "eligible_deeps": eligible_deeps - } - - # Select template based on current game phase - if game.phase == "lobby": - return templates.TemplateResponse("lobby_partial.html", context) - elif game.phase in ["character_creation", "swap_techniques"]: - return templates.TemplateResponse("character_creation_partial.html", context) - elif game.phase == "scene_setup": - return templates.TemplateResponse("scene_setup_partial.html", context) - elif game.phase == "scene": - return templates.TemplateResponse("scene_partial.html", context) - elif game.phase == "between_scenes": - return templates.TemplateResponse("between_scenes_partial.html", context) - elif game.phase == "deep_upkeep": - return templates.TemplateResponse("deep_upkeep_partial.html", context) - - return HTMLResponse("Unknown game state.
") - -# --- HTMX Action Handlers --- - -# 1. Start character creation from lobby -@app.post("/game/{game_id}/lobby/start") -def lobby_start_character_creation(game_id: str, db: Session = Depends(get_session)): - game = crud.get_game(db, game_id) - if not game: - raise HTTPException(status_code=404, detail="Game not found") - game.phase = "character_creation" - db.add(game) - db.commit() - return HTMLResponse(status_code=204) # Return No Content, polling redirects - -# 2. Save basic character details -@app.post("/game/{game_id}/player/{player_id}/save-basic") -def player_save_basic_details( - game_id: str, - player_id: str, - avatar_look: str = Form(...), - avatar_smell: str = Form(...), - first_words: str = Form(...), - good_at_math: str = Form(...), - db: Session = Depends(get_session) -): - player = crud.get_player(db, player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - player.avatar_look = avatar_look.strip() - player.avatar_smell = avatar_smell.strip() - player.first_words = first_words.strip() - player.good_at_math = good_at_math - db.add(player) - db.commit() - - return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) - -# 3. Edit basic character details (reset values to trigger form view) -@app.post("/game/{game_id}/player/{player_id}/edit-basic") -def player_edit_basic_details( - game_id: str, - player_id: str, - db: Session = Depends(get_session) -): - # Trigger HTMX reload with edit mode active - return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view?edit=true", status_code=status.HTTP_303_SEE_OTHER) - -# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates -@app.post("/game/{game_id}/player/{player_id}/delegate/auto") -def player_auto_delegate( - game_id: str, - player_id: str, - db: Session = Depends(get_session) -): - import random - game = crud.get_game(db, game_id) - player = crud.get_player(db, player_id) - if not game or not player: - raise HTTPException(status_code=404, detail="Not found") - - other_players = [p for p in game.players if p.id != player.id] - if other_players: - # Assign 'like' if not assigned - if not player.other_like_from_player_id: - like_target = random.choice(other_players) - player.other_like_from_player_id = like_target.id - player.other_like = "" - - # Assign 'hate' if not assigned - if not player.other_hate_from_player_id: - if len(other_players) >= 2: - # Try to pick a different target than the like target - remaining = [op for op in other_players if op.id != player.other_like_from_player_id] - hate_target = random.choice(remaining) - else: - hate_target = random.choice(other_players) - player.other_hate_from_player_id = hate_target.id - player.other_hate = "" - - db.add(player) - db.commit() - - return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) - -# Get Crew Creation Status (polling endpoint) -@app.get("/game/{game_id}/player/{player_id}/crew-status", response_class=HTMLResponse) -def player_crew_status( - request: Request, - game_id: str, - player_id: str, - db: Session = Depends(get_session) -): - game = crud.get_game(db, game_id) - player = crud.get_player(db, player_id) - if not game or not player: - raise HTTPException(status_code=404, detail="Not found") - - def get_player_name_by_id(pid: Optional[str]) -> str: - if not pid: - return "None" - p = db.get(Player, pid) - return p.name if p else "Unknown" - - return templates.TemplateResponse("crew_status_snippet.html", { - "request": request, - "game": game, - "player": player, - "json_loads": json.loads, - "get_player_name": get_player_name_by_id - }) - -# 4. Delegate question 5/6 to another player -@app.post("/game/{game_id}/player/{player_id}/delegate/{question_type}", response_class=HTMLResponse) -def player_delegate_question( - request: Request, - game_id: str, - player_id: str, - question_type: str, # "like" or "hate" - target_player_id: str = Form(...), - db: Session = Depends(get_session) -): - crud.delegate_question(db, player_id, question_type, target_player_id) - game = crud.get_game(db, game_id) - player = crud.get_player(db, player_id) - if not game or not player: - raise HTTPException(status_code=404, detail="Not found") - - def get_player_name_by_id(pid: Optional[str]) -> str: - if not pid: - return "None" - p = db.get(Player, pid) - return p.name if p else "Unknown" - - return templates.TemplateResponse("delegation_snippet.html", { - "request": request, - "game": game, - "player": player, - "question_type": question_type, - "get_player_name": get_player_name_by_id - }) - -# Revoke a delegated question task -@app.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}", response_class=HTMLResponse) -def player_revoke_delegation( - request: Request, - game_id: str, - player_id: str, - question_type: str, # "like" or "hate" - db: Session = Depends(get_session) -): - player = crud.get_player(db, player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - if question_type == "like": - player.other_like_from_player_id = None - player.other_like = "" - elif question_type == "hate": - player.other_hate_from_player_id = None - player.other_hate = "" - db.add(player) - db.commit() - - game = crud.get_game(db, game_id) - if not game: - raise HTTPException(status_code=404, detail="Game not found") - - def get_player_name_by_id(pid: Optional[str]) -> str: - if not pid: - return "None" - p = db.get(Player, pid) - return p.name if p else "Unknown" - - return templates.TemplateResponse("delegation_snippet.html", { - "request": request, - "game": game, - "player": player, - "question_type": question_type, - "get_player_name": get_player_name_by_id - }) - -# Fetch the delegation card status (for background polling when waiting) -@app.get("/game/{game_id}/player/{player_id}/delegation/{question_type}", response_class=HTMLResponse) -def player_get_delegation_status( - request: Request, - game_id: str, - player_id: str, - question_type: str, # "like" or "hate" - db: Session = Depends(get_session) -): - game = crud.get_game(db, game_id) - player = crud.get_player(db, player_id) - if not game or not player: - raise HTTPException(status_code=404, detail="Not found") - - def get_player_name_by_id(pid: Optional[str]) -> str: - if not pid: - return "None" - p = db.get(Player, pid) - return p.name if p else "Unknown" - - return templates.TemplateResponse("delegation_snippet.html", { - "request": request, - "game": game, - "player": player, - "question_type": question_type, - "get_player_name": get_player_name_by_id - }) - -# 5. Answer a delegated question for another player -@app.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}") -def player_submit_delegated_answer( - game_id: str, - player_id: str, # The answering player - target_player_id: str, # The player whose sheet we are filling out - question_type: str, # "like" or "hate" - answer: str = Form(...), - db: Session = Depends(get_session) -): - crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id) - return HTMLResponse("") - -# 6. Submit 3 Secret Pirate Techniques -@app.post("/game/{game_id}/player/{player_id}/submit-techniques") -def player_submit_techniques( - request: Request, - game_id: str, - player_id: str, - tech1: str = Form(...), - tech2: str = Form(...), - tech3: str = Form(...), - db: Session = Depends(get_session) -): - crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip()) - - game = crud.get_game(db, game_id) - player = crud.get_player(db, player_id) - if not game or not player: - raise HTTPException(status_code=404, detail="Not found") - - return templates.TemplateResponse("techniques_snippet.html", { - "request": request, - "game": game, - "player": player, - "json_loads": json.loads - }) - -# 7. Assign Swapped Techniques to Face Cards (Jack, Queen, King) -@app.post("/game/{game_id}/player/{player_id}/assign-face-techniques") -def player_assign_face_techniques( - request: Request, - game_id: str, - player_id: str, - jack: str = Form(...), - queen: str = Form(...), - king: str = Form(...), - db: Session = Depends(get_session) -): - if len({jack, queen, king}) < 3: - # Prevent assigning the same technique to multiple face cards - return HTMLResponse("Error: Each card must be assigned a unique technique.
", status_code=400) - - crud.assign_face_card_techniques(db, player_id, jack, queen, king) - - game = crud.get_game(db, game_id) - player = crud.get_player(db, player_id) - if not game or not player: - raise HTTPException(status_code=404, detail="Not found") - - return templates.TemplateResponse("techniques_snippet.html", { - "request": request, - "game": game, - "player": player, - "json_loads": json.loads - }) - -# 8. Set Role during Scene Setup -@app.post("/game/{game_id}/player/{player_id}/set-role", response_class=HTMLResponse) -def player_set_role( - request: Request, - game_id: str, - player_id: str, - role: str, - db: Session = Depends(get_session) -): - crud.update_scene_role(db, player_id, role) - return get_game_phase_view(request, game_id, player_id, db=db) - -# 9. Start Scene (Verify Setup & Shuffle & Draw Obstacles) -@app.post("/game/{game_id}/scene/start") -def start_scene_route(request: Request, game_id: str, db: Session = Depends(get_session)): - success, msg = crud.confirm_scene_setup(db, game_id) - if not success: - # Render the setup partial again with the error message - game = crud.get_game(db, game_id) - # We need a player to render the partial, let's grab any player or the one who requested it. - # But we don't have the player_id in this path. Let's make sure we pass the error message, - # or we just return an HTTP 400 with the error text so HTMX displays it! - return HTMLResponse(f"{msg}
", status_code=400) - - # Return a success notification and HX-Trigger header to refresh the hand. - color_prefix = "🟩" if res["success"] else "🟥" - drew_text = f" (Drew card: {res['drew_card']})" if res["drew_card"] else "" - drew_attr = "true" if res["drew_card"] else "false" - html = ( - f"{color_prefix} {res['details']}{drew_text}
" - f"Click to dismiss" - f"{msg}
", status_code=400) - - html = ( - f"🃏 Play Joker: Discarded obstacle and drew a new replacement!
" - f"Click to dismiss" - f"Session disconnected or expired.
") + + base_url = str(request.base_url).rstrip("/") + + # Context helper functions to run inside Jinja templates + def get_player_name_by_id(*args) -> str: + pid = args[1] if len(args) == 2 else (args[0] if len(args) == 1 else None) + if not pid: + return "None" + p = db.get(Player, pid) + return p.name if p else "Unknown" + + def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]: + return db.get(Obstacle, obs_id) + + # Core variables + hand = crud.get_player_hand(player) + max_hand_size = crud.calculate_max_hand_size(player, game.players) + + # Compute inbox tasks for current player + inbox_tasks = [] + for p in game.players: + if p.id != player.id: + if p.other_like_from_player_id == player.id and not p.other_like: + inbox_tasks.append({"player": p, "type": "like"}) + if p.other_hate_from_player_id == player.id and not p.other_hate: + inbox_tasks.append({"player": p, "type": "hate"}) + + # Freshly fetch votes to avoid cache stale + from sqlmodel import select + votes = db.exec(select(Vote).where(Vote.game_id == game_id)).all() + + # Calculate eligible deep players + eligible_deeps = [p for p in game.players if p.previous_role != "deep"] + + context = { + "request": request, + "game": game, + "player": player, + "hand": hand, + "max_hand_size": max_hand_size, + "base_url": base_url, + "db": db, + "get_player_name": get_player_name_by_id, + "get_obstacle_by_id": get_obstacle_by_id, + "parse_card": cards.parse_card, + "is_captain": crud.is_player_captain, + "json_loads": json.loads, + "deck_count": len(crud.get_game_deck(game)), + "inbox_tasks": inbox_tasks, + "votes": votes, + "edit_mode": edit, + "eligible_deeps": eligible_deeps + } + + # Select template based on current game phase + if game.phase == "lobby": + return templates.TemplateResponse("lobby_partial.html", context) + elif game.phase in ["character_creation", "swap_techniques"]: + return templates.TemplateResponse("character_creation_partial.html", context) + elif game.phase == "scene_setup": + return templates.TemplateResponse("scene_setup_partial.html", context) + elif game.phase == "scene": + return templates.TemplateResponse("scene_partial.html", context) + elif game.phase == "between_scenes": + return templates.TemplateResponse("between_scenes_partial.html", context) + elif game.phase == "deep_upkeep": + return templates.TemplateResponse("deep_upkeep_partial.html", context) + + return HTMLResponse("Unknown game state.
") diff --git a/src/pirats/routes_admin.py b/src/pirats/routes_admin.py new file mode 100644 index 0000000..154b65a --- /dev/null +++ b/src/pirats/routes_admin.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Request, Depends, HTTPException +from fastapi.responses import HTMLResponse +from sqlmodel import Session +from .database import get_session +from . import crud +from .templates import templates + +router = APIRouter() + +# Creator Admin Panel +@router.get("/game/{game_id}/admin", response_class=HTMLResponse) +def creator_admin_panel(request: Request, game_id: str, key: str, 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") + + base_url = str(request.base_url).rstrip("/") + return templates.TemplateResponse("admin.html", { + "request": request, + "game": game, + "base_url": base_url + }) diff --git a/src/pirats/routes_character.py b/src/pirats/routes_character.py new file mode 100644 index 0000000..82f120d --- /dev/null +++ b/src/pirats/routes_character.py @@ -0,0 +1,273 @@ +import json +from typing import Optional +from fastapi import APIRouter, Request, Form, Depends, HTTPException, status +from fastapi.responses import RedirectResponse, HTMLResponse +from sqlmodel import Session +from .database import get_session +from . import crud +from .models import Player +from .templates import templates + +router = APIRouter() + +# Save basic character details +@router.post("/game/{game_id}/player/{player_id}/save-basic") +def player_save_basic_details( + game_id: str, + player_id: str, + avatar_look: str = Form(...), + avatar_smell: str = Form(...), + first_words: str = Form(...), + good_at_math: str = Form(...), + db: Session = Depends(get_session) +): + player = crud.get_player(db, player_id) + if not player: + raise HTTPException(status_code=404, detail="Player not found") + + player.avatar_look = avatar_look.strip() + player.avatar_smell = avatar_smell.strip() + player.first_words = first_words.strip() + player.good_at_math = good_at_math + db.add(player) + db.commit() + + return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) + +# Edit basic character details (reset values to trigger form view) +@router.post("/game/{game_id}/player/{player_id}/edit-basic") +def player_edit_basic_details( + game_id: str, + player_id: str, + db: Session = Depends(get_session) +): + # Trigger HTMX reload with edit mode active + return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view?edit=true", status_code=status.HTTP_303_SEE_OTHER) + +# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates +@router.post("/game/{game_id}/player/{player_id}/delegate/auto") +def player_auto_delegate( + game_id: str, + player_id: str, + db: Session = Depends(get_session) +): + import random + game = crud.get_game(db, game_id) + player = crud.get_player(db, player_id) + if not game or not player: + raise HTTPException(status_code=404, detail="Not found") + + other_players = [p for p in game.players if p.id != player.id] + if other_players: + # Assign 'like' if not assigned + if not player.other_like_from_player_id: + like_target = random.choice(other_players) + player.other_like_from_player_id = like_target.id + player.other_like = "" + + # Assign 'hate' if not assigned + if not player.other_hate_from_player_id: + if len(other_players) >= 2: + # Try to pick a different target than the like target + remaining = [op for op in other_players if op.id != player.other_like_from_player_id] + hate_target = random.choice(remaining) + else: + hate_target = random.choice(other_players) + player.other_hate_from_player_id = hate_target.id + player.other_hate = "" + + db.add(player) + db.commit() + + return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) + +# Get Crew Creation Status (polling endpoint) +@router.get("/game/{game_id}/player/{player_id}/crew-status", response_class=HTMLResponse) +def player_crew_status( + request: Request, + game_id: str, + player_id: str, + db: Session = Depends(get_session) +): + game = crud.get_game(db, game_id) + player = crud.get_player(db, player_id) + if not game or not player: + raise HTTPException(status_code=404, detail="Not found") + + def get_player_name_by_id(pid: Optional[str]) -> str: + if not pid: + return "None" + p = db.get(Player, pid) + return p.name if p else "Unknown" + + return templates.TemplateResponse("crew_status_snippet.html", { + "request": request, + "game": game, + "player": player, + "json_loads": json.loads, + "get_player_name": get_player_name_by_id + }) + +# Delegate question 5/6 to another player +@router.post("/game/{game_id}/player/{player_id}/delegate/{question_type}", response_class=HTMLResponse) +def player_delegate_question( + request: Request, + game_id: str, + player_id: str, + question_type: str, # "like" or "hate" + target_player_id: str = Form(...), + db: Session = Depends(get_session) +): + crud.delegate_question(db, player_id, question_type, target_player_id) + game = crud.get_game(db, game_id) + player = crud.get_player(db, player_id) + if not game or not player: + raise HTTPException(status_code=404, detail="Not found") + + def get_player_name_by_id(pid: Optional[str]) -> str: + if not pid: + return "None" + p = db.get(Player, pid) + return p.name if p else "Unknown" + + return templates.TemplateResponse("delegation_snippet.html", { + "request": request, + "game": game, + "player": player, + "question_type": question_type, + "get_player_name": get_player_name_by_id + }) + +# Revoke a delegated question task +@router.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}", response_class=HTMLResponse) +def player_revoke_delegation( + request: Request, + game_id: str, + player_id: str, + question_type: str, # "like" or "hate" + db: Session = Depends(get_session) +): + player = crud.get_player(db, player_id) + if not player: + raise HTTPException(status_code=404, detail="Player not found") + if question_type == "like": + player.other_like_from_player_id = None + player.other_like = "" + elif question_type == "hate": + player.other_hate_from_player_id = None + player.other_hate = "" + db.add(player) + db.commit() + + game = crud.get_game(db, game_id) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + + def get_player_name_by_id(pid: Optional[str]) -> str: + if not pid: + return "None" + p = db.get(Player, pid) + return p.name if p else "Unknown" + + return templates.TemplateResponse("delegation_snippet.html", { + "request": request, + "game": game, + "player": player, + "question_type": question_type, + "get_player_name": get_player_name_by_id + }) + +# Fetch the delegation card status (for background polling when waiting) +@router.get("/game/{game_id}/player/{player_id}/delegation/{question_type}", response_class=HTMLResponse) +def player_get_delegation_status( + request: Request, + game_id: str, + player_id: str, + question_type: str, # "like" or "hate" + db: Session = Depends(get_session) +): + game = crud.get_game(db, game_id) + player = crud.get_player(db, player_id) + if not game or not player: + raise HTTPException(status_code=404, detail="Not found") + + def get_player_name_by_id(pid: Optional[str]) -> str: + if not pid: + return "None" + p = db.get(Player, pid) + return p.name if p else "Unknown" + + return templates.TemplateResponse("delegation_snippet.html", { + "request": request, + "game": game, + "player": player, + "question_type": question_type, + "get_player_name": get_player_name_by_id + }) + +# Answer a delegated question for another player +@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}") +def player_submit_delegated_answer( + game_id: str, + player_id: str, # The answering player + target_player_id: str, # The player whose sheet we are filling out + question_type: str, # "like" or "hate" + answer: str = Form(...), + db: Session = Depends(get_session) +): + crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id) + return HTMLResponse("") + +# Submit 3 Secret Pirate Techniques +@router.post("/game/{game_id}/player/{player_id}/submit-techniques") +def player_submit_techniques( + request: Request, + game_id: str, + player_id: str, + tech1: str = Form(...), + tech2: str = Form(...), + tech3: str = Form(...), + db: Session = Depends(get_session) +): + crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip()) + + game = crud.get_game(db, game_id) + player = crud.get_player(db, player_id) + if not game or not player: + raise HTTPException(status_code=404, detail="Not found") + + return templates.TemplateResponse("techniques_snippet.html", { + "request": request, + "game": game, + "player": player, + "json_loads": json.loads + }) + +# Assign Swapped Techniques to Face Cards (Jack, Queen, King) +@router.post("/game/{game_id}/player/{player_id}/assign-face-techniques") +def player_assign_face_techniques( + request: Request, + game_id: str, + player_id: str, + jack: str = Form(...), + queen: str = Form(...), + king: str = Form(...), + db: Session = Depends(get_session) +): + if len({jack, queen, king}) < 3: + # Prevent assigning the same technique to multiple face cards + return HTMLResponse("Error: Each card must be assigned a unique technique.
", status_code=400) + + crud.assign_face_card_techniques(db, player_id, jack, queen, king) + + game = crud.get_game(db, game_id) + player = crud.get_player(db, player_id) + if not game or not player: + raise HTTPException(status_code=404, detail="Not found") + + return templates.TemplateResponse("techniques_snippet.html", { + "request": request, + "game": game, + "player": player, + "json_loads": json.loads + }) diff --git a/src/pirats/routes_lobby.py b/src/pirats/routes_lobby.py new file mode 100644 index 0000000..c59767d --- /dev/null +++ b/src/pirats/routes_lobby.py @@ -0,0 +1,30 @@ +from typing import Optional +from fastapi import APIRouter, Request, Depends, HTTPException +from fastapi.responses import HTMLResponse +from sqlmodel import Session +from .database import get_session +from . import crud +from .templates import templates + +router = APIRouter() + +@router.post("/game/{game_id}/lobby/start") +def lobby_start_character_creation(game_id: str, db: Session = Depends(get_session)): + game = crud.get_game(db, game_id) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + game.phase = "character_creation" + db.add(game) + db.commit() + return HTMLResponse(status_code=204) # Return No Content, polling redirects + +@router.get("/game/{game_id}/lobby/players", response_class=HTMLResponse) +def lobby_players_snippet(request: Request, game_id: str, player_id: Optional[str] = None, db: Session = Depends(get_session)): + game = crud.get_game(db, game_id) + if not game: + return HTMLResponse("") + return templates.TemplateResponse("lobby_players_snippet.html", { + "request": request, + "game": game, + "player_id": player_id + }) diff --git a/src/pirats/routes_scene.py b/src/pirats/routes_scene.py new file mode 100644 index 0000000..97c7385 --- /dev/null +++ b/src/pirats/routes_scene.py @@ -0,0 +1,136 @@ +import json +from typing import Optional +from fastapi import APIRouter, Request, Form, Depends, HTTPException +from fastapi.responses import HTMLResponse +from sqlmodel import Session +from .database import get_session +from . import crud +from . import cards +from .templates import templates +from .phase_view import get_game_phase_view + +router = APIRouter() + +# Set Role during Scene Setup +@router.post("/game/{game_id}/player/{player_id}/set-role", response_class=HTMLResponse) +def player_set_role( + request: Request, + game_id: str, + player_id: str, + role: str, + db: Session = Depends(get_session) +): + crud.update_scene_role(db, player_id, role) + return get_game_phase_view(request, game_id, player_id, db=db) + +# Start Scene (Verify Setup & Shuffle & Draw Obstacles) +@router.post("/game/{game_id}/scene/start") +def start_scene_route(request: Request, game_id: str, db: Session = Depends(get_session)): + success, msg = crud.confirm_scene_setup(db, game_id) + if not success: + return HTMLResponse(f"{msg}
", status_code=400) + + color_prefix = "🟩" if res["success"] else "🟥" + drew_text = f" (Drew card: {res['drew_card']})" if res["drew_card"] else "" + drew_attr = "true" if res["drew_card"] else "false" + html = ( + f"{color_prefix} {res['details']}{drew_text}
" + f"Click to dismiss" + f"{msg}
", status_code=400) + + html = ( + f"🃏 Play Joker: Discarded obstacle and drew a new replacement!
" + f"Click to dismiss" + f"