From 2ddcb86cc052ee865bf862b701c735988de4b8cc Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Wed, 10 Jun 2026 15:29:03 -0700 Subject: [PATCH] Refactor for smaller context --- .gitignore | 2 + SOURCEMAP.md | 112 ++ src/pirats/crud.py | 845 +----------- src/pirats/crud_base.py | 182 +++ src/pirats/crud_character.py | 173 +++ src/pirats/crud_scene.py | 337 +++++ src/pirats/crud_upkeep.py | 154 +++ src/pirats/main.py | 597 +-------- src/pirats/phase_view.py | 95 ++ src/pirats/routes_admin.py | 24 + src/pirats/routes_character.py | 273 ++++ src/pirats/routes_lobby.py | 30 + src/pirats/routes_scene.py | 136 ++ src/pirats/routes_upkeep.py | 64 + src/pirats/static/css/admin.css | 28 + src/pirats/static/css/card.css | 244 ++++ src/pirats/static/css/character.css | 442 ++++++ src/pirats/static/css/components.css | 195 +++ src/pirats/static/css/core.css | 231 ++++ src/pirats/static/css/lobby.css | 106 ++ src/pirats/static/css/scene-play.css | 270 ++++ src/pirats/static/css/scene-setup.css | 83 ++ src/pirats/static/css/style.css | 1780 +------------------------ src/pirats/static/css/upkeep.css | 100 ++ src/pirats/static/css/welcome.css | 50 + src/pirats/templates.py | 8 + 26 files changed, 3373 insertions(+), 3188 deletions(-) create mode 100644 SOURCEMAP.md create mode 100644 src/pirats/crud_base.py create mode 100644 src/pirats/crud_character.py create mode 100644 src/pirats/crud_scene.py create mode 100644 src/pirats/crud_upkeep.py create mode 100644 src/pirats/phase_view.py create mode 100644 src/pirats/routes_admin.py create mode 100644 src/pirats/routes_character.py create mode 100644 src/pirats/routes_lobby.py create mode 100644 src/pirats/routes_scene.py create mode 100644 src/pirats/routes_upkeep.py create mode 100644 src/pirats/static/css/admin.css create mode 100644 src/pirats/static/css/card.css create mode 100644 src/pirats/static/css/character.css create mode 100644 src/pirats/static/css/components.css create mode 100644 src/pirats/static/css/core.css create mode 100644 src/pirats/static/css/lobby.css create mode 100644 src/pirats/static/css/scene-play.css create mode 100644 src/pirats/static/css/scene-setup.css create mode 100644 src/pirats/static/css/upkeep.css create mode 100644 src/pirats/static/css/welcome.css create mode 100644 src/pirats/templates.py 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 HTMLResponse(status_code=204) - -# 11. Pi-Rat plays card against an obstacle -@app.post("/game/{game_id}/player/{player_id}/play-card") -def play_card_route( - game_id: str, - player_id: str, - obstacle_id: str = Form(...), - card_code: str = Form(...), - db: Session = Depends(get_session) -): - ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code) - if not ok: - 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"
" - f"

{color_prefix} {res['details']}{drew_text}

" - f"Click to dismiss" - f"
" - ) - return HTMLResponse(html, headers={"HX-Trigger": "hand-changed"}) - -# 12. Pi-Rat plays a Joker to replace an obstacle -@app.post("/game/{game_id}/player/{player_id}/play-joker") -def play_joker_route( - game_id: str, - player_id: str, - card_code: str = Form(...), - obstacle_id: str = Form(...), - db: Session = Depends(get_session) -): - ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code) - if not ok: - return HTMLResponse(f"

{msg}

", status_code=400) - - html = ( - f"
" - f"

🃏 Play Joker: Discarded obstacle and drew a new replacement!

" - f"Click to dismiss" - f"
" - ) - return HTMLResponse(html, headers={"HX-Trigger": "hand-changed"}) - -# 13. Toggle Objective Completion Checklist -@app.post("/game/{game_id}/player/{player_id}/objective/toggle") -def toggle_objective_route( - game_id: str, - player_id: str, - type: str, # "personal_1", "personal_2", "personal_3" - db: Session = Depends(get_session) -): - player = crud.get_player(db, player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Toggle logic - current_status = False - if type == "personal_1": - current_status = player.completed_personal_1 - elif type == "personal_2": - current_status = player.completed_personal_2 - elif type == "personal_3": - current_status = player.completed_personal_3 - - crud.toggle_objective(db, player_id, type, not current_status) - return HTMLResponse(status_code=204) - -# 14. Deep ends the scene -@app.post("/game/{game_id}/scene/end") -def end_scene_route(game_id: str, db: Session = Depends(get_session)): - crud.end_scene_and_transition(db, game_id) - return HTMLResponse(status_code=204) - -# 15. Cast vote to Rank Up a player -@app.post("/game/{game_id}/player/{player_id}/submit-vote") -def submit_vote_route( - game_id: str, - player_id: str, - nominated_id: str = Form(...), - db: Session = Depends(get_session) -): - crud.submit_rank_vote(db, game_id, player_id, nominated_id) - return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) - -# 16. Player Ready for Next Scene -@app.post("/game/{game_id}/player/{player_id}/ready-next") -def ready_next_route(game_id: str, player_id: str, 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.is_ready = True - db.add(player) - db.commit() - - # Check if all players in the game are ready. If so, transition to deep upkeep! - game = crud.get_game(db, game_id) - if game and all(p.is_ready for p in game.players): - crud.transition_to_deep_upkeep(db, game_id) - - return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) - -# 17. Confirm Hand Refresh for Deep player -@app.post("/game/{game_id}/player/{player_id}/confirm-refresh") -def confirm_deep_refresh_route( - game_id: str, - player_id: str, - discard_cards: str = Form("[]"), - db: Session = Depends(get_session) -): - try: - discards = json.loads(discard_cards) - except Exception: - discards = [] - crud.confirm_deep_refresh(db, player_id, discards) - return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) +app.include_router(phase_view_router) +app.include_router(lobby_router) +app.include_router(character_router) +app.include_router(scene_router) +app.include_router(upkeep_router) +app.include_router(admin_router) def main(): import argparse diff --git a/src/pirats/phase_view.py b/src/pirats/phase_view.py new file mode 100644 index 0000000..af474d2 --- /dev/null +++ b/src/pirats/phase_view.py @@ -0,0 +1,95 @@ +import json +from typing import Optional +from fastapi import Request, Depends, APIRouter +from fastapi.responses import HTMLResponse +from sqlmodel import Session + +from .database import get_session +from .models import Game, Player, Obstacle, Vote +from . import crud +from . import cards +from .templates import templates + +router = APIRouter() + +@router.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.

") 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) + return HTMLResponse(status_code=204) + +# Get Scene Roster Snippet +@router.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 + }) + +# Get Scene Obstacles Snippet +@router.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 + }) + +# Pi-Rat plays card against an obstacle +@router.post("/game/{game_id}/player/{player_id}/play-card") +def play_card_route( + game_id: str, + player_id: str, + obstacle_id: str = Form(...), + card_code: str = Form(...), + db: Session = Depends(get_session) +): + ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code) + if not ok: + 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"
" + f"

{color_prefix} {res['details']}{drew_text}

" + f"Click to dismiss" + f"
" + ) + return HTMLResponse(html, headers={"HX-Trigger": "hand-changed"}) + +# Pi-Rat plays a Joker to replace an obstacle +@router.post("/game/{game_id}/player/{player_id}/play-joker") +def play_joker_route( + game_id: str, + player_id: str, + card_code: str = Form(...), + obstacle_id: str = Form(...), + db: Session = Depends(get_session) +): + ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code) + if not ok: + return HTMLResponse(f"

{msg}

", status_code=400) + + html = ( + f"
" + f"

🃏 Play Joker: Discarded obstacle and drew a new replacement!

" + f"Click to dismiss" + f"
" + ) + return HTMLResponse(html, headers={"HX-Trigger": "hand-changed"}) + +# Toggle Objective Completion Checklist +@router.post("/game/{game_id}/player/{player_id}/objective/toggle") +def toggle_objective_route( + game_id: str, + player_id: str, + type: str, # "personal_1", "personal_2", "personal_3" + db: Session = Depends(get_session) +): + player = crud.get_player(db, player_id) + if not player: + raise HTTPException(status_code=404, detail="Player not found") + + current_status = False + if type == "personal_1": + current_status = player.completed_personal_1 + elif type == "personal_2": + current_status = player.completed_personal_2 + elif type == "personal_3": + current_status = player.completed_personal_3 + + crud.toggle_objective(db, player_id, type, not current_status) + return HTMLResponse(status_code=204) + +# Deep ends the scene +@router.post("/game/{game_id}/scene/end") +def end_scene_route(game_id: str, db: Session = Depends(get_session)): + crud.end_scene_and_transition(db, game_id) + return HTMLResponse(status_code=204) diff --git a/src/pirats/routes_upkeep.py b/src/pirats/routes_upkeep.py new file mode 100644 index 0000000..ba62d64 --- /dev/null +++ b/src/pirats/routes_upkeep.py @@ -0,0 +1,64 @@ +import json +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 .templates import templates + +router = APIRouter() + +# Get Between Scenes Voting Roster Snippet +@router.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 + }) + +# Cast vote to Rank Up a player +@router.post("/game/{game_id}/player/{player_id}/submit-vote") +def submit_vote_route( + game_id: str, + player_id: str, + nominated_id: str = Form(...), + db: Session = Depends(get_session) +): + crud.submit_rank_vote(db, game_id, player_id, nominated_id) + return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) + +# Player Ready for Next Scene +@router.post("/game/{game_id}/player/{player_id}/ready-next") +def ready_next_route(game_id: str, player_id: str, 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.is_ready = True + db.add(player) + db.commit() + + # Check if all players in the game are ready. If so, transition to deep upkeep! + game = crud.get_game(db, game_id) + if game and all(p.is_ready for p in game.players): + crud.transition_to_deep_upkeep(db, game_id) + + return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) + +# Confirm Hand Refresh for Deep player +@router.post("/game/{game_id}/player/{player_id}/confirm-refresh") +def confirm_deep_refresh_route( + game_id: str, + player_id: str, + discard_cards: str = Form("[]"), + db: Session = Depends(get_session) +): + try: + discards = json.loads(discard_cards) + except Exception: + discards = [] + crud.confirm_deep_refresh(db, player_id, discards) + return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) diff --git a/src/pirats/static/css/admin.css b/src/pirats/static/css/admin.css new file mode 100644 index 0000000..b5a9568 --- /dev/null +++ b/src/pirats/static/css/admin.css @@ -0,0 +1,28 @@ +/* --- Admin Panel --- */ +.admin-players-list { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1rem; +} + +.admin-player-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + gap: 1rem; + flex-wrap: wrap; +} + +.player-info-basic { + font-size: 1.05rem; +} + +.link-copy-action { + display: flex; + gap: 0.5rem; + align-items: center; + flex: 1; + max-width: 500px; +} diff --git a/src/pirats/static/css/card.css b/src/pirats/static/css/card.css new file mode 100644 index 0000000..4eb3161 --- /dev/null +++ b/src/pirats/static/css/card.css @@ -0,0 +1,244 @@ +/* Card Widget (Mini) */ +.card-mini { + width: 32px; + height: 48px; + border-radius: 4px; + border: 1px solid var(--text-muted); + background: var(--bg-dark); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 2px 4px; + font-size: 0.7rem; + font-weight: 900; +} + +.card-mini.base-card { + border-color: var(--gold); + color: var(--gold); + background: rgba(212,175,55,0.1); +} + +.card-mini.rotated { + transform: rotate(90deg); + margin: 0 4px; +} + +.card-mini.success { + border-color: var(--neon-emerald); + color: var(--neon-emerald); +} + +.card-mini.failure { + border-color: var(--red-suit); + color: var(--red-suit); +} + +.card-mini .val { + align-self: flex-start; +} + +.card-mini .suit { + align-self: flex-end; +} + +.card-mini .owner { + font-size: 0.5rem; + text-transform: uppercase; + position: absolute; + bottom: -1px; + left: 1px; + color: var(--text-muted); +} + +/* Card Widget (Medium) */ +.card-medium { + width: 75px; + height: 112px; + border-radius: 6px; + border: 1.5px solid var(--glass-border); + background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%); + box-shadow: 0 3px 10px rgba(0,0,0,0.4); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0.3rem; + position: relative; + font-size: 0.85rem; +} + +.card-medium .card-corner { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1; +} + +.card-medium .card-corner .val { + font-weight: 900; + font-size: 0.9rem; +} + +.card-medium .card-corner .suit { + font-size: 0.75rem; +} + +.card-medium.suit-c, .card-medium.suit-s { + border-color: rgba(226, 232, 240, 0.15); +} + +.card-medium.suit-h, .card-medium.suit-d { + border-color: rgba(255, 42, 95, 0.15); +} + +.card-medium.joker-card { + border-color: var(--gold); + background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%); +} + +.card-medium.suit-c .val, .card-medium.suit-c .suit, +.card-medium.suit-s .val, .card-medium.suit-s .suit { + color: var(--black-suit); +} + +.card-medium.suit-h .val, .card-medium.suit-h .suit, +.card-medium.suit-d .val, .card-medium.suit-d .suit { + color: var(--red-suit); +} + +.card-medium .card-center { + font-size: 1.8rem; + text-align: center; + align-self: center; + margin: auto 0; +} + +.card-medium.suit-c .card-center, .card-medium.suit-s .card-center { color: var(--black-suit); } +.card-medium.suit-h .card-center, .card-medium.suit-d .card-center { color: var(--red-suit); } + +/* Card Widget (Large) */ +.card-large { + width: 110px; + height: 165px; + border-radius: 8px; + border: 2px solid var(--glass-border); + background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%); + box-shadow: 0 4px 15px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0.5rem; + position: relative; + transition: var(--transition-smooth); +} + +.card-large:hover { + transform: translateY(-8px) scale(1.05); + border-color: var(--gold); + box-shadow: 0 10px 25px rgba(212, 175, 55, 0.2); +} + +.card-large.suit-c, .card-large.suit-s { + border-color: rgba(226, 232, 240, 0.15); +} +.card-large.suit-c:hover, .card-large.suit-s:hover { + border-color: var(--black-suit); + box-shadow: 0 10px 25px var(--black-glow); +} + +.card-large.suit-h, .card-large.suit-d { + border-color: rgba(255, 42, 95, 0.15); +} +.card-large.suit-h:hover, .card-large.suit-d:hover { + border-color: var(--red-suit); + box-shadow: 0 10px 25px var(--red-glow); +} + +.card-large.joker-card { + border-color: var(--gold); + background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%); +} + +.card-large .card-corner { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.1; +} + +.card-large .card-corner .val { + font-size: 1.2rem; + font-weight: 900; +} + +.card-large .card-corner .suit { + font-size: 1rem; +} + +.card-large.suit-c .val, .card-large.suit-c .suit, +.card-large.suit-s .val, .card-large.suit-s .suit { + color: var(--black-suit); +} + +.card-large.suit-h .val, .card-large.suit-h .suit, +.card-large.suit-d .val, .card-large.suit-d .suit { + color: var(--red-suit); +} + +.card-large .card-center { + font-size: 2.8rem; + text-align: center; + align-self: center; + margin: auto 0; +} + +.card-large.suit-c .card-center, .card-large.suit-s .card-center { color: var(--black-suit); } +.card-large.suit-h .card-center, .card-large.suit-d .card-center { color: var(--red-suit); } + +.card-large .card-tech-overlay { + position: absolute; + bottom: 0.5rem; + left: 0.5rem; + right: 0.5rem; + text-align: center; +} + +.tech-tag { + background: rgba(0, 0, 0, 0.7); + border: 1px solid var(--gold); + color: var(--gold); + font-size: 0.65rem; + padding: 0.15rem 0.3rem; + border-radius: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.joker-action { + display: flex; + gap: 2px; +} + +/* Drag and Drop Interface Styles */ +.card-large[draggable="true"] { + cursor: grab; +} + +.card-large[draggable="true"]:active { + cursor: grabbing; +} + +.card-large.dragging { + opacity: 0.4; + border-style: dashed; + transform: scale(0.95); +} + +.obstacle-item.drag-over { + border-color: var(--gold) !important; + background: rgba(212, 175, 55, 0.1) !important; + box-shadow: 0 0 20px rgba(212, 175, 55, 0.2) !important; + transform: translateY(-2px) scale(1.01); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/src/pirats/static/css/character.css b/src/pirats/static/css/character.css new file mode 100644 index 0000000..13ea2f4 --- /dev/null +++ b/src/pirats/static/css/character.css @@ -0,0 +1,442 @@ +/* --- Character Creation & Sheet Grid --- */ +.creation-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-top: 2rem; +} + +@media (max-width: 900px) { + .creation-grid { + grid-template-columns: 1fr; + } +} + +.section-desc { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.record-line { + background: rgba(0,0,0,0.2); + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 0.75rem; + border-left: 3px solid var(--gold); +} + +.delegation-box { + margin-bottom: 1.25rem; + padding: 1.25rem; + text-align: left; +} + +.delegation-box h4 { + font-size: 0.95rem; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.answer-box { + font-style: italic; + color: var(--neon-cyan); + font-size: 1.05rem; +} + +.author-label { + font-size: 0.8rem; + color: var(--text-muted); + font-style: normal; +} + +.waiting-box { + font-size: 0.9rem; + color: var(--gold); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.inbox-list:has(.inbox-item) .inbox-empty-message { + display: none; +} + +.inbox-item { + padding: 1rem; + margin-bottom: 0.75rem; + border-left: 3px solid var(--neon-cyan); + background: rgba(0, 242, 254, 0.02); +} + +.inbox-item label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.submitted-techniques .tech-chip { + background: rgba(212,175,55,0.05); + border: 1px solid var(--gold); + color: var(--gold); + padding: 0.5rem; + border-radius: 6px; + margin-bottom: 0.5rem; + font-family: var(--font-heading); +} + +.tech-chip { + padding: 0.6rem 1rem; + font-size: 0.95rem; +} + +.tech-assignment-pill { + background: rgba(0, 242, 254, 0.05); + border: 1px solid var(--neon-cyan); + color: var(--text-primary); + padding: 0.6rem 1rem; + border-radius: 6px; + margin-bottom: 0.5rem; + text-align: left; + display: flex; + align-items: center; + gap: 1rem; +} + +.card-rank { + background: var(--neon-cyan); + color: var(--bg-dark); + font-weight: 900; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.85rem; +} + +/* --- Character Sheet layout --- */ +.character-sheet-details { + text-align: left; +} + +.sheet-group { + background: rgba(0,0,0,0.15); + padding: 1rem; + border-radius: 8px; + border-left: 2px solid var(--gold-dark); +} + +.sheet-group h4 { + color: var(--gold); + font-size: 0.95rem; + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.footnote { + font-size: 0.8rem; + color: var(--text-muted); +} + +.techniques-list-sheet { + list-style: none; +} + +.techniques-list-sheet li { + margin-bottom: 0.25rem; + font-size: 0.95rem; +} + +.footnote-desc { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.15rem; + margin-left: 1.5rem; +} + +.margin-top-small { + margin-top: 0.5rem; +} + +/* --- Visual & Tactile Technique Assignment --- */ +.face-tech-drag-form { + margin-top: 1.5rem; +} + +.available-techniques-container { + background: rgba(255, 255, 255, 0.02); + border: 1px dashed var(--glass-border); + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 2rem; + box-shadow: inset 0 0 15px rgba(0,0,0,0.2); +} + +.available-techniques-container h4 { + font-family: var(--font-heading); + color: var(--gold); + font-size: 1.1rem; + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.instruction-help { + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.techniques-drag-pool { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + min-height: 50px; + align-items: center; +} + +.draggable-tech-chip { + background: linear-gradient(135deg, rgba(22, 36, 59, 0.9) 0%, rgba(13, 23, 38, 0.9) 100%); + border: 1px solid var(--gold); + color: var(--text-primary); + padding: 0.6rem 1rem; + border-radius: 8px; + cursor: grab; + user-select: none; + font-family: var(--font-body); + font-size: 0.95rem; + font-weight: 500; + transition: var(--transition-smooth); + box-shadow: 0 4px 10px rgba(0,0,0,0.3), 0 0 5px rgba(212, 175, 55, 0.1); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.draggable-tech-chip:hover { + transform: translateY(-2px); + border-color: var(--neon-cyan); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4), 0 0 10px rgba(0, 242, 254, 0.3); +} + +.draggable-tech-chip:active { + cursor: grabbing; +} + +.draggable-tech-chip.dragging { + opacity: 0.4; + border-style: dashed; +} + +.draggable-tech-chip.selected { + border-color: var(--neon-cyan); + box-shadow: 0 0 15px rgba(0, 242, 254, 0.5); + background: rgba(0, 242, 254, 0.1); +} + +.draggable-tech-chip.assigned-hidden { + opacity: 0.2; + pointer-events: none; + cursor: not-allowed; + border-color: var(--text-muted); +} + +.drag-icon { + color: var(--gold); + font-size: 0.8rem; + opacity: 0.7; +} + +/* Slots Grid */ +.face-cards-slots-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-bottom: 2rem; +} + +@media (max-width: 768px) { + .face-cards-slots-grid { + grid-template-columns: 1fr; + } +} + +.face-card-slot-wrapper { + display: flex; + justify-content: center; +} + +.face-card-slot { + position: relative; + width: 100%; + max-width: 220px; + aspect-ratio: 2 / 3; + min-height: 280px; + background: linear-gradient(135deg, rgba(13, 23, 38, 0.8) 0%, rgba(7, 11, 18, 0.9) 100%); + border: 2px dashed rgba(212, 175, 55, 0.3); + border-radius: 12px; + padding: 1rem; + display: flex; + flex-direction: column; + justify-content: space-between; + transition: var(--transition-smooth); + overflow: hidden; + cursor: pointer; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4); +} + +.face-card-slot:hover { + border-color: rgba(212, 175, 55, 0.7); + box-shadow: 0 12px 30px rgba(212, 175, 55, 0.15); + transform: translateY(-4px); +} + +.face-card-slot.drag-over { + border-color: var(--neon-cyan); + border-style: solid; + background: rgba(0, 242, 254, 0.05); + box-shadow: 0 0 20px rgba(0, 242, 254, 0.2); + transform: scale(1.02); +} + +.face-card-slot.has-assignment { + border-style: solid; + border-color: var(--gold); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 15px rgba(212, 175, 55, 0.15); +} + +.card-bg-letter { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-family: var(--font-heading); + font-size: 8rem; + font-weight: 900; + color: rgba(255, 255, 255, 0.025); + line-height: 1; + pointer-events: none; + user-select: none; + transition: var(--transition-smooth); +} + +.face-card-slot.has-assignment .card-bg-letter { + color: rgba(212, 175, 55, 0.04); +} + +.card-slot-header, .card-slot-footer { + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--font-heading); + font-weight: 700; + font-size: 1.1rem; + color: var(--text-muted); + pointer-events: none; + user-select: none; +} + +.face-card-slot.has-assignment .card-slot-header, +.face-card-slot.has-assignment .card-slot-footer { + color: var(--gold); +} + +.card-slot-body { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + z-index: 2; + padding: 0.5rem 0; +} + +.card-title { + font-family: var(--font-heading); + font-size: 1.2rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 0.75rem; + pointer-events: none; +} + +.face-card-slot.has-assignment .card-title { + color: var(--gold); +} + +.drop-zone-placeholder { + font-size: 0.8rem; + color: var(--text-muted); + border: 1px dashed rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + padding: 0.5rem 0.75rem; + transition: var(--transition-smooth); + pointer-events: none; +} + +.face-card-slot:hover .drop-zone-placeholder { + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.2); +} + +.assigned-tech-content { + width: 100%; + display: none; +} + +.face-card-slot.has-assignment .drop-zone-placeholder { + display: none; +} + +.face-card-slot.has-assignment .assigned-tech-content { + display: block; +} + +.assigned-tech-chip { + background: rgba(212, 175, 55, 0.08); + border: 1px solid var(--gold); + color: var(--text-primary); + padding: 0.5rem; + border-radius: 8px; + font-size: 0.85rem; + position: relative; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + word-break: break-word; + animation: fadeIn 0.3s ease; +} + +.remove-tech-btn { + position: absolute; + top: -8px; + right: -8px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #c0392b; + border: 1px solid #e74c3c; + color: white; + font-size: 10px; + font-weight: 900; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.5); + transition: var(--transition-smooth); +} + +.remove-tech-btn:hover { + background: #e74c3c; + transform: scale(1.1); +} + +@keyframes fadeIn { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} diff --git a/src/pirats/static/css/components.css b/src/pirats/static/css/components.css new file mode 100644 index 0000000..8e58749 --- /dev/null +++ b/src/pirats/static/css/components.css @@ -0,0 +1,195 @@ +/* --- UI Panels & Glassmorphism --- */ +.glass-panel { + background: var(--glass-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + transition: var(--transition-smooth); +} + +.glass-panel:hover { + border-color: rgba(212, 175, 55, 0.35); + box-shadow: 0 8px 32px rgba(212, 175, 55, 0.05); +} + +.card { + background: var(--bg-ocean-light); + border: 1px solid var(--glass-border); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: 0 4px 15px rgba(0,0,0,0.3); + transition: var(--transition-smooth); +} + +.card h3 { + font-family: var(--font-heading); + color: var(--gold); + margin-bottom: 1rem; + border-bottom: 1px solid rgba(212, 175, 55, 0.2); + padding-bottom: 0.5rem; +} + +/* --- Forms & Controls --- */ +.form-group { + margin-bottom: 1.25rem; + text-align: left; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.95rem; + color: var(--text-muted); + font-weight: 500; +} + +.input-field { + width: 100%; + padding: 0.75rem 1rem; + background: rgba(7, 11, 18, 0.8); + border: 1px solid var(--glass-border); + border-radius: 8px; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 1rem; + transition: var(--transition-smooth); +} + +.input-field:focus { + outline: none; + border-color: var(--neon-cyan); + box-shadow: 0 0 10px rgba(0, 242, 254, 0.2); +} + +.select-field { + width: 100%; + padding: 0.75rem 1rem; + background: rgba(7, 11, 18, 0.8); + border: 1px solid var(--glass-border); + border-radius: 8px; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 1rem; + transition: var(--transition-smooth); + cursor: pointer; +} + +.select-field:focus { + outline: none; + border-color: var(--neon-cyan); +} + +.input-row { + display: flex; + gap: 0.5rem; +} + +.input-row .input-field { + flex: 1; +} + +/* --- Buttons --- */ +.btn { + display: inline-block; + padding: 0.75rem 1.5rem; + font-family: var(--font-body); + font-weight: 700; + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 1px; + border: none; + border-radius: 8px; + cursor: pointer; + transition: var(--transition-smooth); + text-decoration: none; +} + +.btn-primary { + background: linear-gradient(135deg, var(--gold) 0%, var(--gold-dark) 100%); + color: var(--text-dark); + box-shadow: 0 4px 15px var(--gold-glow); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(212, 175, 55, 0.6); +} + +.btn-secondary { + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.15); + color: var(--text-primary); +} + +.btn-secondary:hover:not(:disabled) { + background: rgba(255,255,255,0.15); + transform: translateY(-2px); +} + +.btn-deep { + background: linear-gradient(135deg, #152238 0%, #0d1726 100%); + border: 1px solid var(--neon-cyan); + color: var(--neon-cyan); + box-shadow: 0 4px 15px rgba(0, 242, 254, 0.1); +} + +.btn-deep:hover:not(:disabled) { + background: var(--neon-cyan); + color: var(--text-dark); + box-shadow: 0 4px 20px rgba(0, 242, 254, 0.4); + transform: translateY(-2px); +} + +.btn-danger { + background: linear-gradient(135deg, #7a1c1c 0%, #c0392b 100%); + color: var(--text-primary); + box-shadow: 0 4px 15px rgba(192, 57, 43, 0.2); +} + +.btn-danger:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(192, 57, 43, 0.5); +} + +.btn-gold { + background: transparent; + border: 2px solid var(--gold); + color: var(--gold); + box-shadow: 0 0 10px rgba(212,175,55,0.1); +} + +.btn-gold:hover:not(:disabled) { + background: var(--gold); + color: var(--bg-dark); + box-shadow: 0 0 20px var(--gold-glow); + transform: translateY(-2px); +} + +.btn-full { + width: 100%; +} + +.btn-small { + padding: 0.4rem 0.8rem; + font-size: 0.8rem; +} + +.btn-large { + padding: 1rem 2rem; + font-size: 1.1rem; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + +.glow-effect:hover { + filter: brightness(1.2); +} diff --git a/src/pirats/static/css/core.css b/src/pirats/static/css/core.css new file mode 100644 index 0000000..52c46e8 --- /dev/null +++ b/src/pirats/static/css/core.css @@ -0,0 +1,231 @@ +/* --- Variables & Tokens --- */ +:root { + --bg-dark: #070b12; + --bg-ocean: #0d1726; + --bg-ocean-light: #16243b; + + --gold: #d4af37; + --gold-glow: rgba(212, 175, 55, 0.4); + --gold-dark: #aa841c; + + --neon-cyan: #00f2fe; + --neon-emerald: #00ff87; + + --red-suit: #ff2a5f; + --red-glow: rgba(255, 42, 95, 0.3); + --black-suit: #e2e8f0; + --black-glow: rgba(226, 232, 240, 0.2); + + --glass-bg: rgba(13, 23, 38, 0.7); + --glass-bg-hover: rgba(22, 36, 59, 0.85); + --glass-border: rgba(212, 175, 55, 0.2); + --glass-border-focus: rgba(0, 242, 254, 0.4); + + --text-primary: #f1f5f9; + --text-muted: #94a3b8; + --text-dark: #0f172a; + + --font-heading: 'Cinzel', serif; + --font-body: 'Outfit', sans-serif; + + --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* --- Base Reset & Setup --- */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 16px; + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; + overflow-x: hidden; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} +::-webkit-scrollbar-track { + background: var(--bg-dark); +} +::-webkit-scrollbar-thumb { + background: var(--gold-dark); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--gold); +} + +/* --- Layout Components --- */ +.app-header { + background: rgba(7, 11, 18, 0.95); + border-bottom: 2px solid var(--gold); + box-shadow: 0 4px 20px rgba(0,0,0,0.5); + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.logo-container { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.logo-container h1 { + font-family: var(--font-heading); + font-size: 1.8rem; + font-weight: 900; + letter-spacing: 2px; + background: linear-gradient(135deg, var(--gold) 30%, #fff 70%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-shadow: 0 0 10px var(--gold-glow); +} + +.math-magic { + font-family: var(--font-heading); + font-size: 2.2rem; + color: var(--neon-cyan); + text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); + animation: pulse-glow 3s infinite ease-in-out; +} + +.header-status { + display: flex; + align-items: center; + gap: 1rem; +} + +.player-pill { + background: rgba(0, 242, 254, 0.1); + border: 1px solid var(--neon-cyan); + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.9rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.player-pill .bullet { + width: 8px; + height: 8px; + background-color: var(--neon-emerald); + border-radius: 50%; + box-shadow: 0 0 8px var(--neon-emerald); +} + +.phase-pill { + background: rgba(212, 175, 55, 0.1); + border: 1px solid var(--gold); + color: var(--gold); + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 1px; +} + +.app-main { + flex: 1; + padding: 2rem; + max-width: 1400px; + width: 100%; + margin: 0 auto; +} + +.app-footer { + background: var(--bg-dark); + padding: 1.5rem; + text-align: center; + border-top: 1px solid rgba(255,255,255,0.05); + font-size: 0.85rem; + color: var(--text-muted); +} + +/* --- Loader / Spinner widgets --- */ +.loader-container { + padding: 4rem; +} + +.spinner { + width: 50px; + height: 50px; + border: 3px solid rgba(0, 242, 254, 0.1); + border-radius: 50%; + border-top-color: var(--neon-cyan); + animation: spin 1s ease-in-out infinite; + margin: 0 auto 1.5rem; +} + +.spinner-small { + width: 20px; + height: 20px; + border: 2px solid rgba(255,255,255,0.1); + border-radius: 50%; + border-top-color: var(--gold); + animation: spin 1s ease-in-out infinite; + display: inline-block; +} + +/* --- Alert styles --- */ +.alert { + padding: 0.75rem 1.25rem; + margin-bottom: 1.5rem; + border-radius: 8px; + text-align: center; + font-weight: 500; +} + +.alert-danger { + background: rgba(192, 57, 43, 0.2); + border: 1px solid #c0392b; + color: #e74c3c; +} + +/* --- Animation keyframes --- */ +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes pulse-glow { + 0% { + text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); + transform: scale(1); + } + 50% { + text-shadow: 0 0 15px var(--neon-cyan), 0 0 30px var(--neon-cyan), 0 0 40px var(--neon-cyan); + transform: scale(1.05); + } + 100% { + text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); + transform: scale(1); + } +} + +/* --- Utilities --- */ +.text-center { text-align: center; } +.text-left { text-align: left; } +.margin-top { margin-top: 1.5rem; } +.gold-text { color: var(--gold); } +.center-block { margin: 1rem auto; max-width: 500px; } +.inline-form { display: flex; gap: 0.5rem; width: 100%; } +.inline-group { display: flex; gap: 0.5rem; width: 100%; } +.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; } +.select-xsmall { padding: 0.4rem; font-size: 0.85rem; } +.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; } diff --git a/src/pirats/static/css/lobby.css b/src/pirats/static/css/lobby.css new file mode 100644 index 0000000..9b48fe1 --- /dev/null +++ b/src/pirats/static/css/lobby.css @@ -0,0 +1,106 @@ +/* --- Lobby / Hold View --- */ +.lobby-view { + max-width: 700px; + margin: 2rem auto; +} + +.lobby-view h2 { + font-family: var(--font-heading); + font-size: 2.2rem; + color: var(--gold); + margin-bottom: 0.5rem; +} + +.lobby-status-box { + margin: 2rem 0; +} + +.player-chips { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + margin-top: 1rem; +} + +.player-chip { + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + padding: 0.5rem 1.25rem; + border-radius: 30px; + display: flex; + align-items: center; + gap: 0.5rem; + transition: var(--transition-smooth); +} + +.player-chip.current-user { + background: rgba(0, 242, 254, 0.15); + border-color: var(--neon-cyan); + box-shadow: 0 0 10px rgba(0,242,254,0.15); +} + +.player-chip.voted-chip { + border-color: var(--neon-emerald); + background-color: rgba(0,255,135,0.05); +} + +.player-chip.not-voted-chip { + border-color: rgba(255,255,255,0.1); +} + +.creator-badge { + background: var(--gold); + color: var(--text-dark); + font-size: 0.7rem; + font-weight: 700; + padding: 0.1rem 0.4rem; + border-radius: 4px; + text-transform: uppercase; +} + +.links-box { + text-align: left; + margin-bottom: 2rem; +} + +.link-item { + margin-bottom: 1.5rem; +} + +.link-item h4 { + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.copy-container { + display: flex; + gap: 0.5rem; +} + +.copy-input { + flex: 1; + background: #05080e; + border: 1px solid var(--glass-border); + color: var(--text-primary); + padding: 0.5rem 1rem; + border-radius: 8px; + font-family: var(--font-body); + font-size: 0.95rem; +} + +.copy-input.admin-input { + border-color: var(--gold-dark); + color: var(--gold); +} + +.admin-link-item { + border-top: 1px dashed rgba(212, 175, 55, 0.3); + padding-top: 1.5rem; +} + +.info-text { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} diff --git a/src/pirats/static/css/scene-play.css b/src/pirats/static/css/scene-play.css new file mode 100644 index 0000000..2a29996 --- /dev/null +++ b/src/pirats/static/css/scene-play.css @@ -0,0 +1,270 @@ +/* --- Scene Play Board Layout --- */ +.scene-view-layout { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 2rem; +} + +@media (max-width: 1100px) { + .scene-view-layout { + grid-template-columns: 1fr; + } +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(212, 175, 55, 0.2); + padding-bottom: 0.5rem; + margin-bottom: 1rem; +} + +.card-header h3 { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.deck-counter { + font-size: 0.85rem; + color: var(--gold); + font-weight: 700; +} + +/* --- Active Obstacle Item Display --- */ +.obstacles-container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.obstacle-item { + border: 1px solid var(--glass-border); + border-radius: 10px; + padding: 1.25rem; + background: rgba(7, 11, 18, 0.4); + display: grid; + grid-template-columns: 0.8fr 2fr 1fr 1fr; + gap: 1rem; + position: relative; + overflow: hidden; +} + +@media (max-width: 768px) { + .obstacle-item { + grid-template-columns: 0.8fr 2fr 1.5fr; + } +} + +@media (max-width: 600px) { + .obstacle-item { + grid-template-columns: 1fr; + } +} + +/* Color theme mappings for suits */ +.suit-c { border-left: 4px solid var(--black-suit); } +.suit-s { border-left: 4px solid var(--black-suit); } +.suit-h { border-left: 4px solid var(--red-suit); } +.suit-d { border-left: 4px solid var(--red-suit); } + +.obstacle-meta { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: rgba(0,0,0,0.3); + border-radius: 6px; + padding: 0.5rem; +} + +.suit-badge { + font-weight: 900; + font-size: 1.1rem; +} + +.suit-c .suit-badge, .suit-s .suit-badge { color: var(--black-suit); } +.suit-h .suit-badge, .suit-d .suit-badge { color: var(--red-suit); } + +.card-code { + font-size: 0.8rem; + color: var(--text-muted); + font-family: var(--font-heading); +} + +.obstacle-details h4 { + font-family: var(--font-heading); + color: var(--text-primary); + margin-bottom: 0.25rem; + font-size: 1.1rem; +} + +.obstacle-details .desc { + font-size: 0.85rem; + color: var(--text-muted); +} + +.obstacle-value-display { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: rgba(212, 175, 55, 0.04); + border: 1px dashed var(--glass-border); + border-radius: 6px; + padding: 0.5rem; +} + +.obstacle-successes-display { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: rgba(0, 255, 135, 0.04); + border: 1px dashed rgba(0, 255, 135, 0.25); + border-radius: 6px; + padding: 0.5rem; +} + +.obstacle-successes-display .val-number { + color: var(--neon-emerald); +} + +.val-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; +} + +.val-number { + font-size: 1.8rem; + font-family: var(--font-heading); + font-weight: 900; + color: var(--gold); +} + +.played-column { + grid-column: span 4; + border-top: 1px solid rgba(255,255,255,0.05); + padding-top: 0.75rem; + margin-top: 0.5rem; +} + +@media (max-width: 600px) { + .played-column { + grid-column: span 1; + } +} + +.played-column h5 { + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.column-cards-flex { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +/* --- Challenges Section --- */ +.challenges-container { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.challenge-item { + background: rgba(255,255,255,0.02); + border: 1px solid var(--glass-border); + border-radius: 8px; + padding: 1.25rem; + text-align: left; +} + +.challenge-item h4 { + font-family: var(--font-heading); + color: var(--neon-cyan); + font-size: 1.2rem; + margin-bottom: 0.25rem; +} + +.challenge-item .desc { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.applied-obstacles h5 { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.applied-obs-row { + padding: 0.75rem 1rem; + margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; +} + +.obs-info { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.obs-info .symbol { + font-size: 1.2rem; +} + +.play-card-form { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.select-xsmall { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + width: 140px; + background: var(--bg-dark); +} + +/* --- Deep Control panel --- */ +.deep-text { + color: var(--neon-cyan) !important; +} + +.deep-divider { + border: 0; + height: 1px; + background: linear-gradient(to right, rgba(0, 242, 254, 0), rgba(0, 242, 254, 0.4), rgba(0, 242, 254, 0)); + margin: 2rem 0; +} + +.obstacles-checkboxes { + display: flex; + flex-direction: column; + gap: 0.5rem; + background: rgba(0,0,0,0.2); + padding: 1rem; + border-radius: 8px; + max-height: 150px; + overflow-y: auto; + border: 1px solid var(--glass-border); +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.95rem; +} diff --git a/src/pirats/static/css/scene-setup.css b/src/pirats/static/css/scene-setup.css new file mode 100644 index 0000000..cc8d232 --- /dev/null +++ b/src/pirats/static/css/scene-setup.css @@ -0,0 +1,83 @@ +/* --- Scene Setup / Role Choose --- */ +.setup-grid { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 2rem; + margin: 2rem 0; +} + +@media (max-width: 800px) { + .setup-grid { + grid-template-columns: 1fr; + } +} + +.role-buttons { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-top: 1.5rem; +} + +.role-btn { + padding: 1.5rem; + font-size: 1.2rem; + background: rgba(255,255,255,0.03); + border: 2px solid rgba(255,255,255,0.1); + color: var(--text-muted); +} + +.role-btn.pirat-btn:hover, .role-btn.pirat-btn.active { + border-color: var(--neon-emerald); + color: var(--text-primary); + background-color: rgba(0,255,135,0.06); + box-shadow: 0 0 15px rgba(0,255,135,0.15); +} + +.role-btn.deep-btn:hover, .role-btn.deep-btn.active { + border-color: var(--neon-cyan); + color: var(--text-primary); + background-color: rgba(0,242,254,0.06); + box-shadow: 0 0 15px rgba(0,242,254,0.15); +} + +.large-badge { + padding: 0.5rem 2rem; + font-size: 1.5rem; + border-radius: 8px; + font-family: var(--font-heading); + margin-top: 0.5rem; +} + +.role-badge.deep { + background: rgba(0, 242, 254, 0.15); + border: 2px solid var(--neon-cyan); + color: var(--neon-cyan); + box-shadow: 0 0 15px rgba(0,242,254,0.1); +} + +.role-badge.pirat { + background: rgba(0, 255, 135, 0.15); + border: 2px solid var(--neon-emerald); + color: var(--neon-emerald); + box-shadow: 0 0 15px rgba(0,255,135,0.1); +} + +.list-chips { + justify-content: flex-start; +} + +.list-chips .player-chip { + padding: 0.4rem 1rem; + font-size: 0.9rem; +} + +.deep-chip { + border-color: var(--neon-cyan); + background: rgba(0, 242, 254, 0.03); +} + +.pirat-chip { + border-color: var(--neon-emerald); + background: rgba(0, 255, 135, 0.03); +} diff --git a/src/pirats/static/css/style.css b/src/pirats/static/css/style.css index b880915..2a5d954 100644 --- a/src/pirats/static/css/style.css +++ b/src/pirats/static/css/style.css @@ -2,1773 +2,17 @@ RATS WITH GATS DESIGN SYSTEM & STYLESHEET A weathered math-magic pirate aesthetic: deep-ocean dark mode, glassmorphism, neon math runes, gold accents, and animated card widgets. + + This file imports modular stylesheets divided by page phase and components. ========================================================================== */ -/* --- Variables & Tokens --- */ -:root { - --bg-dark: #070b12; - --bg-ocean: #0d1726; - --bg-ocean-light: #16243b; - - --gold: #d4af37; - --gold-glow: rgba(212, 175, 55, 0.4); - --gold-dark: #aa841c; - - --neon-cyan: #00f2fe; - --neon-emerald: #00ff87; - - --red-suit: #ff2a5f; - --red-glow: rgba(255, 42, 95, 0.3); - --black-suit: #e2e8f0; - --black-glow: rgba(226, 232, 240, 0.2); - - --glass-bg: rgba(13, 23, 38, 0.7); - --glass-bg-hover: rgba(22, 36, 59, 0.85); - --glass-border: rgba(212, 175, 55, 0.2); - --glass-border-focus: rgba(0, 242, 254, 0.4); - - --text-primary: #f1f5f9; - --text-muted: #94a3b8; - --text-dark: #0f172a; - - --font-heading: 'Cinzel', serif; - --font-body: 'Outfit', sans-serif; - - --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -/* --- Base Reset & Setup --- */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - background: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%); - color: var(--text-primary); - font-family: var(--font-body); - font-size: 16px; - line-height: 1.6; - min-height: 100vh; - display: flex; - flex-direction: column; - overflow-x: hidden; -} - -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 8px; -} -::-webkit-scrollbar-track { - background: var(--bg-dark); -} -::-webkit-scrollbar-thumb { - background: var(--gold-dark); - border-radius: 4px; -} -::-webkit-scrollbar-thumb:hover { - background: var(--gold); -} - -/* --- Layout Components --- */ -.app-header { - background: rgba(7, 11, 18, 0.95); - border-bottom: 2px solid var(--gold); - box-shadow: 0 4px 20px rgba(0,0,0,0.5); - padding: 1rem 2rem; - display: flex; - justify-content: space-between; - align-items: center; - position: sticky; - top: 0; - z-index: 100; -} - -.logo-container { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.logo-container h1 { - font-family: var(--font-heading); - font-size: 1.8rem; - font-weight: 900; - letter-spacing: 2px; - background: linear-gradient(135deg, var(--gold) 30%, #fff 70%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - text-shadow: 0 0 10px var(--gold-glow); -} - -.math-magic { - font-family: var(--font-heading); - font-size: 2.2rem; - color: var(--neon-cyan); - text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); - animation: pulse-glow 3s infinite ease-in-out; -} - -.header-status { - display: flex; - align-items: center; - gap: 1rem; -} - -.player-pill { - background: rgba(0, 242, 254, 0.1); - border: 1px solid var(--neon-cyan); - padding: 0.25rem 0.75rem; - border-radius: 20px; - font-size: 0.9rem; - font-weight: 700; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.player-pill .bullet { - width: 8px; - height: 8px; - background-color: var(--neon-emerald); - border-radius: 50%; - box-shadow: 0 0 8px var(--neon-emerald); -} - -.phase-pill { - background: rgba(212, 175, 55, 0.1); - border: 1px solid var(--gold); - color: var(--gold); - padding: 0.25rem 0.75rem; - border-radius: 20px; - font-size: 0.9rem; - font-weight: 700; - letter-spacing: 1px; -} - -.app-main { - flex: 1; - padding: 2rem; - max-width: 1400px; - width: 100%; - margin: 0 auto; -} - -.app-footer { - background: var(--bg-dark); - padding: 1.5rem; - text-align: center; - border-top: 1px solid rgba(255,255,255,0.05); - font-size: 0.85rem; - color: var(--text-muted); -} - -/* --- UI Panels & Glassmorphism --- */ -.glass-panel { - background: var(--glass-bg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid var(--glass-border); - border-radius: 12px; - padding: 2rem; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - transition: var(--transition-smooth); -} - -.glass-panel:hover { - border-color: rgba(212, 175, 55, 0.35); - box-shadow: 0 8px 32px rgba(212, 175, 55, 0.05); -} - -.card { - background: var(--bg-ocean-light); - border: 1px solid var(--glass-border); - border-radius: 12px; - padding: 1.5rem; - margin-bottom: 1.5rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.3); - transition: var(--transition-smooth); -} - -.card h3 { - font-family: var(--font-heading); - color: var(--gold); - margin-bottom: 1rem; - border-bottom: 1px solid rgba(212, 175, 55, 0.2); - padding-bottom: 0.5rem; -} - -/* --- Forms & Controls --- */ -.form-group { - margin-bottom: 1.25rem; - text-align: left; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-size: 0.95rem; - color: var(--text-muted); - font-weight: 500; -} - -.input-field { - width: 100%; - padding: 0.75rem 1rem; - background: rgba(7, 11, 18, 0.8); - border: 1px solid var(--glass-border); - border-radius: 8px; - color: var(--text-primary); - font-family: var(--font-body); - font-size: 1rem; - transition: var(--transition-smooth); -} - -.input-field:focus { - outline: none; - border-color: var(--neon-cyan); - box-shadow: 0 0 10px rgba(0, 242, 254, 0.2); -} - -.select-field { - width: 100%; - padding: 0.75rem 1rem; - background: rgba(7, 11, 18, 0.8); - border: 1px solid var(--glass-border); - border-radius: 8px; - color: var(--text-primary); - font-family: var(--font-body); - font-size: 1rem; - transition: var(--transition-smooth); - cursor: pointer; -} - -.select-field:focus { - outline: none; - border-color: var(--neon-cyan); -} - -.input-row { - display: flex; - gap: 0.5rem; -} - -.input-row .input-field { - flex: 1; -} - -/* --- Buttons --- */ -.btn { - display: inline-block; - padding: 0.75rem 1.5rem; - font-family: var(--font-body); - font-weight: 700; - font-size: 1rem; - text-transform: uppercase; - letter-spacing: 1px; - border: none; - border-radius: 8px; - cursor: pointer; - transition: var(--transition-smooth); - text-decoration: none; -} - -.btn-primary { - background: linear-gradient(135deg, var(--gold) 0%, var(--gold-dark) 100%); - color: var(--text-dark); - box-shadow: 0 4px 15px var(--gold-glow); -} - -.btn-primary:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(212, 175, 55, 0.6); -} - -.btn-secondary { - background: rgba(255,255,255,0.08); - border: 1px solid rgba(255,255,255,0.15); - color: var(--text-primary); -} - -.btn-secondary:hover:not(:disabled) { - background: rgba(255,255,255,0.15); - transform: translateY(-2px); -} - -.btn-deep { - background: linear-gradient(135deg, #152238 0%, #0d1726 100%); - border: 1px solid var(--neon-cyan); - color: var(--neon-cyan); - box-shadow: 0 4px 15px rgba(0, 242, 254, 0.1); -} - -.btn-deep:hover:not(:disabled) { - background: var(--neon-cyan); - color: var(--text-dark); - box-shadow: 0 4px 20px rgba(0, 242, 254, 0.4); - transform: translateY(-2px); -} - -.btn-danger { - background: linear-gradient(135deg, #7a1c1c 0%, #c0392b 100%); - color: var(--text-primary); - box-shadow: 0 4px 15px rgba(192, 57, 43, 0.2); -} - -.btn-danger:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(192, 57, 43, 0.5); -} - -.btn-gold { - background: transparent; - border: 2px solid var(--gold); - color: var(--gold); - box-shadow: 0 0 10px rgba(212,175,55,0.1); -} - -.btn-gold:hover:not(:disabled) { - background: var(--gold); - color: var(--bg-dark); - box-shadow: 0 0 20px var(--gold-glow); - transform: translateY(-2px); -} - -.btn-full { - width: 100%; -} - -.btn-small { - padding: 0.4rem 0.8rem; - font-size: 0.8rem; -} - -.btn-large { - padding: 1rem 2rem; - font-size: 1.1rem; -} - -.btn:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none !important; - box-shadow: none !important; -} - -.glow-effect:hover { - filter: brightness(1.2); -} - -/* --- Welcome / Main Menu Screen --- */ -.welcome-container { - max-width: 900px; - margin: 4rem auto; -} - -.welcome-hero h2 { - font-family: var(--font-heading); - font-size: 2.5rem; - color: var(--text-primary); - margin-bottom: 1rem; -} - -.welcome-hero .subtitle { - font-size: 1.2rem; - color: var(--text-muted); - margin-bottom: 3rem; -} - -.welcome-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem; -} - -@media (max-width: 768px) { - .welcome-actions { - grid-template-columns: 1fr; - } -} - -.action-card { - padding: 2.5rem 1.5rem; - display: flex; - flex-direction: column; - justify-content: space-between; - height: 300px; -} - -.action-card h3 { - font-family: var(--font-heading); - color: var(--gold); - font-size: 1.5rem; - margin-bottom: 0.75rem; -} - -.action-card p { - color: var(--text-muted); - margin-bottom: 2rem; -} - -/* --- Lobby / Hold View --- */ -.lobby-view { - max-width: 700px; - margin: 2rem auto; -} - -.lobby-view h2 { - font-family: var(--font-heading); - font-size: 2.2rem; - color: var(--gold); - margin-bottom: 0.5rem; -} - -.lobby-status-box { - margin: 2rem 0; -} - -.player-chips { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - justify-content: center; - margin-top: 1rem; -} - -.player-chip { - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.1); - padding: 0.5rem 1.25rem; - border-radius: 30px; - display: flex; - align-items: center; - gap: 0.5rem; - transition: var(--transition-smooth); -} - -.player-chip.current-user { - background: rgba(0, 242, 254, 0.15); - border-color: var(--neon-cyan); - box-shadow: 0 0 10px rgba(0,242,254,0.15); -} - -.player-chip.voted-chip { - border-color: var(--neon-emerald); - background-color: rgba(0,255,135,0.05); -} - -.player-chip.not-voted-chip { - border-color: rgba(255,255,255,0.1); -} - -.creator-badge { - background: var(--gold); - color: var(--text-dark); - font-size: 0.7rem; - font-weight: 700; - padding: 0.1rem 0.4rem; - border-radius: 4px; - text-transform: uppercase; -} - -.links-box { - text-align: left; - margin-bottom: 2rem; -} - -.link-item { - margin-bottom: 1.5rem; -} - -.link-item h4 { - color: var(--text-primary); - margin-bottom: 0.5rem; -} - -.copy-container { - display: flex; - gap: 0.5rem; -} - -.copy-input { - flex: 1; - background: #05080e; - border: 1px solid var(--glass-border); - color: var(--text-primary); - padding: 0.5rem 1rem; - border-radius: 8px; - font-family: var(--font-body); - font-size: 0.95rem; -} - -.copy-input.admin-input { - border-color: var(--gold-dark); - color: var(--gold); -} - -.admin-link-item { - border-top: 1px dashed rgba(212, 175, 55, 0.3); - padding-top: 1.5rem; -} - -.info-text { - font-size: 0.9rem; - color: var(--text-muted); - margin-bottom: 0.5rem; -} - -/* --- Character Creation & Sheet Grid --- */ -.creation-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem; - margin-top: 2rem; -} - -@media (max-width: 900px) { - .creation-grid { - grid-template-columns: 1fr; - } -} - -.section-desc { - font-size: 0.9rem; - color: var(--text-muted); - margin-bottom: 1rem; -} - -.record-line { - background: rgba(0,0,0,0.2); - padding: 0.75rem 1rem; - border-radius: 6px; - margin-bottom: 0.75rem; - border-left: 3px solid var(--gold); -} - -.delegation-box { - margin-bottom: 1.25rem; - padding: 1.25rem; - text-align: left; -} - -.delegation-box h4 { - font-size: 0.95rem; - color: var(--text-primary); - margin-bottom: 0.5rem; -} - -.answer-box { - font-style: italic; - color: var(--neon-cyan); - font-size: 1.05rem; -} - -.author-label { - font-size: 0.8rem; - color: var(--text-muted); - font-style: normal; -} - -.waiting-box { - font-size: 0.9rem; - color: var(--gold); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.inbox-list:has(.inbox-item) .inbox-empty-message { - display: none; -} - -.inbox-item { - padding: 1rem; - margin-bottom: 0.75rem; - border-left: 3px solid var(--neon-cyan); - background: rgba(0, 242, 254, 0.02); -} - -.inbox-item label { - display: block; - margin-bottom: 0.5rem; - font-size: 0.95rem; -} - -.submitted-techniques .tech-chip { - background: rgba(212,175,55,0.05); - border: 1px solid var(--gold); - color: var(--gold); - padding: 0.5rem; - border-radius: 6px; - margin-bottom: 0.5rem; - font-family: var(--font-heading); -} - -.tech-chip { - padding: 0.6rem 1rem; - font-size: 0.95rem; -} - -.tech-assignment-pill { - background: rgba(0, 242, 254, 0.05); - border: 1px solid var(--neon-cyan); - color: var(--text-primary); - padding: 0.6rem 1rem; - border-radius: 6px; - margin-bottom: 0.5rem; - text-align: left; - display: flex; - align-items: center; - gap: 1rem; -} - -.card-rank { - background: var(--neon-cyan); - color: var(--bg-dark); - font-weight: 900; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - font-size: 0.85rem; -} - -/* --- Scene Setup / Role Choose --- */ -.setup-grid { - display: grid; - grid-template-columns: 1.2fr 1fr; - gap: 2rem; - margin: 2rem 0; -} - -@media (max-width: 800px) { - .setup-grid { - grid-template-columns: 1fr; - } -} - -.role-buttons { - display: flex; - flex-direction: column; - gap: 1.5rem; - margin-top: 1.5rem; -} - -.role-btn { - padding: 1.5rem; - font-size: 1.2rem; - background: rgba(255,255,255,0.03); - border: 2px solid rgba(255,255,255,0.1); - color: var(--text-muted); -} - -.role-btn.pirat-btn:hover, .role-btn.pirat-btn.active { - border-color: var(--neon-emerald); - color: var(--text-primary); - background-color: rgba(0,255,135,0.06); - box-shadow: 0 0 15px rgba(0,255,135,0.15); -} - -.role-btn.deep-btn:hover, .role-btn.deep-btn.active { - border-color: var(--neon-cyan); - color: var(--text-primary); - background-color: rgba(0,242,254,0.06); - box-shadow: 0 0 15px rgba(0,242,254,0.15); -} - -.large-badge { - padding: 0.5rem 2rem; - font-size: 1.5rem; - border-radius: 8px; - font-family: var(--font-heading); - margin-top: 0.5rem; -} - -.role-badge.deep { - background: rgba(0, 242, 254, 0.15); - border: 2px solid var(--neon-cyan); - color: var(--neon-cyan); - box-shadow: 0 0 15px rgba(0,242,254,0.1); -} - -.role-badge.pirat { - background: rgba(0, 255, 135, 0.15); - border: 2px solid var(--neon-emerald); - color: var(--neon-emerald); - box-shadow: 0 0 15px rgba(0,255,135,0.1); -} - -.list-chips { - justify-content: flex-start; -} - -.list-chips .player-chip { - padding: 0.4rem 1rem; - font-size: 0.9rem; -} - -.deep-chip { - border-color: var(--neon-cyan); - background: rgba(0, 242, 254, 0.03); -} - -.pirat-chip { - border-color: var(--neon-emerald); - background: rgba(0, 255, 135, 0.03); -} - -/* --- Scene Play Board Layout --- */ -.scene-view-layout { - display: grid; - grid-template-columns: 1.4fr 1fr; - gap: 2rem; -} - -@media (max-width: 1100px) { - .scene-view-layout { - grid-template-columns: 1fr; - } -} - -.card-header { - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid rgba(212, 175, 55, 0.2); - padding-bottom: 0.5rem; - margin-bottom: 1rem; -} - -.card-header h3 { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; -} - -.deck-counter { - font-size: 0.85rem; - color: var(--gold); - font-weight: 700; -} - -/* --- Active Obstacle Item Display --- */ -.obstacles-container { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.obstacle-item { - border: 1px solid var(--glass-border); - border-radius: 10px; - padding: 1.25rem; - background: rgba(7, 11, 18, 0.4); - display: grid; - grid-template-columns: 0.8fr 2fr 1fr 1fr; - gap: 1rem; - position: relative; - overflow: hidden; -} - -@media (max-width: 768px) { - .obstacle-item { - grid-template-columns: 0.8fr 2fr 1.5fr; - } -} - -@media (max-width: 600px) { - .obstacle-item { - grid-template-columns: 1fr; - } -} - -/* Color theme mappings for suits */ -.suit-c { border-left: 4px solid var(--black-suit); } -.suit-s { border-left: 4px solid var(--black-suit); } -.suit-h { border-left: 4px solid var(--red-suit); } -.suit-d { border-left: 4px solid var(--red-suit); } - -.obstacle-meta { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - background: rgba(0,0,0,0.3); - border-radius: 6px; - padding: 0.5rem; -} - -.suit-badge { - font-weight: 900; - font-size: 1.1rem; -} - -.suit-c .suit-badge, .suit-s .suit-badge { color: var(--black-suit); } -.suit-h .suit-badge, .suit-d .suit-badge { color: var(--red-suit); } - -.card-code { - font-size: 0.8rem; - color: var(--text-muted); - font-family: var(--font-heading); -} - -.obstacle-details h4 { - font-family: var(--font-heading); - color: var(--text-primary); - margin-bottom: 0.25rem; - font-size: 1.1rem; -} - -.obstacle-details .desc { - font-size: 0.85rem; - color: var(--text-muted); -} - -.obstacle-value-display { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - background: rgba(212, 175, 55, 0.04); - border: 1px dashed var(--glass-border); - border-radius: 6px; - padding: 0.5rem; -} - -.obstacle-successes-display { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - background: rgba(0, 255, 135, 0.04); - border: 1px dashed rgba(0, 255, 135, 0.25); - border-radius: 6px; - padding: 0.5rem; -} - -.obstacle-successes-display .val-number { - color: var(--neon-emerald); -} - -.val-label { - font-size: 0.75rem; - color: var(--text-muted); - text-transform: uppercase; -} - -.val-number { - font-size: 1.8rem; - font-family: var(--font-heading); - font-weight: 900; - color: var(--gold); -} - -.played-column { - grid-column: span 4; - border-top: 1px solid rgba(255,255,255,0.05); - padding-top: 0.75rem; - margin-top: 0.5rem; -} - -@media (max-width: 600px) { - .played-column { - grid-column: span 1; - } -} - -.played-column h5 { - font-size: 0.8rem; - color: var(--text-muted); - margin-bottom: 0.5rem; -} - -.column-cards-flex { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.75rem; -} - -/* Card Widget (Mini) */ -.card-mini { - width: 32px; - height: 48px; - border-radius: 4px; - border: 1px solid var(--text-muted); - background: var(--bg-dark); - display: flex; - flex-direction: column; - justify-content: space-between; - padding: 2px 4px; - font-size: 0.7rem; - font-weight: 900; -} - -.card-mini.base-card { - border-color: var(--gold); - color: var(--gold); - background: rgba(212,175,55,0.1); -} - -.card-mini.rotated { - transform: rotate(90deg); - margin: 0 4px; -} - -.card-mini.success { - border-color: var(--neon-emerald); - color: var(--neon-emerald); -} - -.card-mini.failure { - border-color: var(--red-suit); - color: var(--red-suit); -} - -.card-mini .val { - align-self: flex-start; -} - -.card-mini .suit { - align-self: flex-end; -} - -.card-mini .owner { - font-size: 0.5rem; - text-transform: uppercase; - position: absolute; - bottom: -1px; - left: 1px; - color: var(--text-muted); -} - -/* --- Challenges Section --- */ -.challenges-container { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -.challenge-item { - background: rgba(255,255,255,0.02); - border: 1px solid var(--glass-border); - border-radius: 8px; - padding: 1.25rem; - text-align: left; -} - -.challenge-item h4 { - font-family: var(--font-heading); - color: var(--neon-cyan); - font-size: 1.2rem; - margin-bottom: 0.25rem; -} - -.challenge-item .desc { - font-size: 0.9rem; - color: var(--text-muted); - margin-bottom: 1rem; -} - -.applied-obstacles h5 { - font-size: 0.85rem; - color: var(--text-muted); - margin-bottom: 0.5rem; -} - -.applied-obs-row { - padding: 0.75rem 1rem; - margin-bottom: 0.5rem; - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 0.75rem; -} - -.obs-info { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.obs-info .symbol { - font-size: 1.2rem; -} - -.play-card-form { - display: flex; - gap: 0.5rem; - align-items: center; -} - -.select-xsmall { - padding: 0.25rem 0.5rem; - font-size: 0.8rem; - width: 140px; - background: var(--bg-dark); -} - -/* --- Deep Control panel --- */ -.deep-text { - color: var(--neon-cyan) !important; -} - -.deep-divider { - border: 0; - height: 1px; - background: linear-gradient(to right, rgba(0, 242, 254, 0), rgba(0, 242, 254, 0.4), rgba(0, 242, 254, 0)); - margin: 2rem 0; -} - -.obstacles-checkboxes { - display: flex; - flex-direction: column; - gap: 0.5rem; - background: rgba(0,0,0,0.2); - padding: 1rem; - border-radius: 8px; - max-height: 150px; - overflow-y: auto; - border: 1px solid var(--glass-border); -} - -.checkbox-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - font-size: 0.95rem; -} - -/* --- Private Section & Hands --- */ -.hand-flex { - display: flex; - flex-wrap: wrap; - gap: 1rem; - justify-content: center; - margin-top: 1.5rem; -} - -/* Card Widget (Medium) */ -.card-medium { - width: 75px; - height: 112px; - border-radius: 6px; - border: 1.5px solid var(--glass-border); - background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%); - box-shadow: 0 3px 10px rgba(0,0,0,0.4); - display: flex; - flex-direction: column; - justify-content: space-between; - padding: 0.3rem; - position: relative; - font-size: 0.85rem; -} - -.card-medium .card-corner { - display: flex; - flex-direction: column; - align-items: center; - line-height: 1; -} - -.card-medium .card-corner .val { - font-weight: 900; - font-size: 0.9rem; -} - -.card-medium .card-corner .suit { - font-size: 0.75rem; -} - -.card-medium.suit-c, .card-medium.suit-s { - border-color: rgba(226, 232, 240, 0.15); -} - -.card-medium.suit-h, .card-medium.suit-d { - border-color: rgba(255, 42, 95, 0.15); -} - -.card-medium.joker-card { - border-color: var(--gold); - background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%); -} - -.card-medium.suit-c .val, .card-medium.suit-c .suit, -.card-medium.suit-s .val, .card-medium.suit-s .suit { - color: var(--black-suit); -} - -.card-medium.suit-h .val, .card-medium.suit-h .suit, -.card-medium.suit-d .val, .card-medium.suit-d .suit { - color: var(--red-suit); -} - -.card-medium .card-center { - font-size: 1.8rem; - text-align: center; - align-self: center; - margin: auto 0; -} - -.card-medium.suit-c .card-center, .card-medium.suit-s .card-center { color: var(--black-suit); } -.card-medium.suit-h .card-center, .card-medium.suit-d .card-center { color: var(--red-suit); } - -/* Card Widget (Large) */ -.card-large { - width: 110px; - height: 165px; - border-radius: 8px; - border: 2px solid var(--glass-border); - background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%); - box-shadow: 0 4px 15px rgba(0,0,0,0.5); - display: flex; - flex-direction: column; - justify-content: space-between; - padding: 0.5rem; - position: relative; - transition: var(--transition-smooth); -} - -.card-large:hover { - transform: translateY(-8px) scale(1.05); - border-color: var(--gold); - box-shadow: 0 10px 25px rgba(212, 175, 55, 0.2); -} - -.card-large.suit-c, .card-large.suit-s { - border-color: rgba(226, 232, 240, 0.15); -} -.card-large.suit-c:hover, .card-large.suit-s:hover { - border-color: var(--black-suit); - box-shadow: 0 10px 25px var(--black-glow); -} - -.card-large.suit-h, .card-large.suit-d { - border-color: rgba(255, 42, 95, 0.15); -} -.card-large.suit-h:hover, .card-large.suit-d:hover { - border-color: var(--red-suit); - box-shadow: 0 10px 25px var(--red-glow); -} - -.card-large.joker-card { - border-color: var(--gold); - background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%); -} - -.card-large .card-corner { - display: flex; - flex-direction: column; - align-items: center; - line-height: 1.1; -} - -.card-large .card-corner .val { - font-size: 1.2rem; - font-weight: 900; -} - -.card-large .card-corner .suit { - font-size: 1rem; -} - -.card-large.suit-c .val, .card-large.suit-c .suit, -.card-large.suit-s .val, .card-large.suit-s .suit { - color: var(--black-suit); -} - -.card-large.suit-h .val, .card-large.suit-h .suit, -.card-large.suit-d .val, .card-large.suit-d .suit { - color: var(--red-suit); -} - -.card-large .card-center { - font-size: 2.8rem; - text-align: center; - align-self: center; - margin: auto 0; -} - -.card-large.suit-c .card-center, .card-large.suit-s .card-center { color: var(--black-suit); } -.card-large.suit-h .card-center, .card-large.suit-d .card-center { color: var(--red-suit); } - -.card-large .card-tech-overlay { - position: absolute; - bottom: 0.5rem; - left: 0.5rem; - right: 0.5rem; - text-align: center; -} - -.tech-tag { - background: rgba(0, 0, 0, 0.7); - border: 1px solid var(--gold); - color: var(--gold); - font-size: 0.65rem; - padding: 0.15rem 0.3rem; - border-radius: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.joker-action { - display: flex; - gap: 2px; -} - -/* --- Character Sheet layout --- */ -.character-sheet-details { - text-align: left; -} - -.sheet-group { - background: rgba(0,0,0,0.15); - padding: 1rem; - border-radius: 8px; - border-left: 2px solid var(--gold-dark); -} - -.sheet-group h4 { - color: var(--gold); - font-size: 0.95rem; - margin-bottom: 0.5rem; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.footnote { - font-size: 0.8rem; - color: var(--text-muted); -} - -.techniques-list-sheet { - list-style: none; -} - -.techniques-list-sheet li { - margin-bottom: 0.25rem; - font-size: 0.95rem; -} - -.footnote-desc { - font-size: 0.8rem; - color: var(--text-muted); - margin-top: 0.15rem; - margin-left: 1.5rem; -} - -.margin-top-small { - margin-top: 0.5rem; -} - -/* --- Between Scenes Upkeep --- */ -.between-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem; - margin: 2rem 0; -} - -@media (max-width: 800px) { - .between-grid { - grid-template-columns: 1fr; - } -} - -.voting-card, .deep-rest-card { - text-align: left; -} - -.voted-confirmation-box { - padding: 1.5rem; - text-align: center; - border: 1px solid var(--neon-emerald); -} - -.success-text { - color: var(--neon-emerald); - font-weight: 700; -} - -.ranks-ledger { - list-style: none; -} - -.ranks-ledger li { - padding: 0.5rem; - border-bottom: 1px solid rgba(255,255,255,0.05); - display: flex; - justify-content: space-between; -} - -.deep-badge { - background: rgba(0, 242, 254, 0.15); - border: 1px solid var(--neon-cyan); - color: var(--neon-cyan); - padding: 0.1rem 0.4rem; - font-size: 0.75rem; - border-radius: 4px; -} - -.pirat-badge { - background: rgba(0, 255, 135, 0.15); - border: 1px solid var(--neon-emerald); - color: var(--neon-emerald); - padding: 0.1rem 0.4rem; - font-size: 0.75rem; - border-radius: 4px; -} - -/* --- Loader / Spinner widgets --- */ -.loader-container { - padding: 4rem; -} - -.spinner { - width: 50px; - height: 50px; - border: 3px solid rgba(0, 242, 254, 0.1); - border-radius: 50%; - border-top-color: var(--neon-cyan); - animation: spin 1s ease-in-out infinite; - margin: 0 auto 1.5rem; -} - -.spinner-small { - width: 20px; - height: 20px; - border: 2px solid rgba(255,255,255,0.1); - border-radius: 50%; - border-top-color: var(--gold); - animation: spin 1s ease-in-out infinite; - display: inline-block; -} - -/* --- Admin Panel --- */ -.admin-players-list { - display: flex; - flex-direction: column; - gap: 1rem; - margin-top: 1rem; -} - -.admin-player-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem; - gap: 1rem; - flex-wrap: wrap; -} - -.player-info-basic { - font-size: 1.05rem; -} - -.link-copy-action { - display: flex; - gap: 0.5rem; - align-items: center; - flex: 1; - max-width: 500px; -} - -/* --- Alert styles --- */ -.alert { - padding: 0.75rem 1.25rem; - margin-bottom: 1.5rem; - border-radius: 8px; - text-align: center; - font-weight: 500; -} - -.alert-danger { - background: rgba(192, 57, 43, 0.2); - border: 1px solid #c0392b; - color: #e74c3c; -} - -/* --- Animation keyframes --- */ -@keyframes spin { - to { transform: rotate(360deg); } -} - -@keyframes pulse-glow { - 0% { - text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); - transform: scale(1); - } - 50% { - text-shadow: 0 0 15px var(--neon-cyan), 0 0 30px var(--neon-cyan), 0 0 40px var(--neon-cyan); - transform: scale(1.05); - } - 100% { - text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); - transform: scale(1); - } -} - -/* --- Utilities --- */ -.text-center { text-align: center; } -.text-left { text-align: left; } -.margin-top { margin-top: 1.5rem; } -.gold-text { color: var(--gold); } -.center-block { margin: 1rem auto; max-width: 500px; } -.inline-form { display: flex; gap: 0.5rem; width: 100%; } -.inline-group { display: flex; gap: 0.5rem; width: 100%; } -.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; } -.select-xsmall { padding: 0.4rem; font-size: 0.85rem; } -.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; } - -/* --- Visual & Tactile Technique Assignment --- */ -.face-tech-drag-form { - margin-top: 1.5rem; -} - -.available-techniques-container { - background: rgba(255, 255, 255, 0.02); - border: 1px dashed var(--glass-border); - border-radius: 12px; - padding: 1.25rem; - margin-bottom: 2rem; - box-shadow: inset 0 0 15px rgba(0,0,0,0.2); -} - -.available-techniques-container h4 { - font-family: var(--font-heading); - color: var(--gold); - font-size: 1.1rem; - margin-bottom: 0.25rem; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.instruction-help { - font-size: 0.8rem; - color: var(--text-muted); - margin-bottom: 1rem; -} - -.techniques-drag-pool { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - justify-content: center; - min-height: 50px; - align-items: center; -} - -.draggable-tech-chip { - background: linear-gradient(135deg, rgba(22, 36, 59, 0.9) 0%, rgba(13, 23, 38, 0.9) 100%); - border: 1px solid var(--gold); - color: var(--text-primary); - padding: 0.6rem 1rem; - border-radius: 8px; - cursor: grab; - user-select: none; - font-family: var(--font-body); - font-size: 0.95rem; - font-weight: 500; - transition: var(--transition-smooth); - box-shadow: 0 4px 10px rgba(0,0,0,0.3), 0 0 5px rgba(212, 175, 55, 0.1); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.draggable-tech-chip:hover { - transform: translateY(-2px); - border-color: var(--neon-cyan); - box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4), 0 0 10px rgba(0, 242, 254, 0.3); -} - -.draggable-tech-chip:active { - cursor: grabbing; -} - -.draggable-tech-chip.dragging { - opacity: 0.4; - border-style: dashed; -} - -.draggable-tech-chip.selected { - border-color: var(--neon-cyan); - box-shadow: 0 0 15px rgba(0, 242, 254, 0.5); - background: rgba(0, 242, 254, 0.1); -} - -.draggable-tech-chip.assigned-hidden { - opacity: 0.2; - pointer-events: none; - cursor: not-allowed; - border-color: var(--text-muted); -} - -.drag-icon { - color: var(--gold); - font-size: 0.8rem; - opacity: 0.7; -} - -/* Slots Grid */ -.face-cards-slots-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1.5rem; - margin-bottom: 2rem; -} - -@media (max-width: 768px) { - .face-cards-slots-grid { - grid-template-columns: 1fr; - } -} - -.face-card-slot-wrapper { - display: flex; - justify-content: center; -} - -.face-card-slot { - position: relative; - width: 100%; - max-width: 220px; - aspect-ratio: 2 / 3; - min-height: 280px; - background: linear-gradient(135deg, rgba(13, 23, 38, 0.8) 0%, rgba(7, 11, 18, 0.9) 100%); - border: 2px dashed rgba(212, 175, 55, 0.3); - border-radius: 12px; - padding: 1rem; - display: flex; - flex-direction: column; - justify-content: space-between; - transition: var(--transition-smooth); - overflow: hidden; - cursor: pointer; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4); -} - -.face-card-slot:hover { - border-color: rgba(212, 175, 55, 0.7); - box-shadow: 0 12px 30px rgba(212, 175, 55, 0.15); - transform: translateY(-4px); -} - -.face-card-slot.drag-over { - border-color: var(--neon-cyan); - border-style: solid; - background: rgba(0, 242, 254, 0.05); - box-shadow: 0 0 20px rgba(0, 242, 254, 0.2); - transform: scale(1.02); -} - -.face-card-slot.has-assignment { - border-style: solid; - border-color: var(--gold); - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 15px rgba(212, 175, 55, 0.15); -} - -.card-bg-letter { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-family: var(--font-heading); - font-size: 8rem; - font-weight: 900; - color: rgba(255, 255, 255, 0.025); - line-height: 1; - pointer-events: none; - user-select: none; - transition: var(--transition-smooth); -} - -.face-card-slot.has-assignment .card-bg-letter { - color: rgba(212, 175, 55, 0.04); -} - -.card-slot-header, .card-slot-footer { - display: flex; - justify-content: space-between; - align-items: center; - font-family: var(--font-heading); - font-weight: 700; - font-size: 1.1rem; - color: var(--text-muted); - pointer-events: none; - user-select: none; -} - -.face-card-slot.has-assignment .card-slot-header, -.face-card-slot.has-assignment .card-slot-footer { - color: var(--gold); -} - -.card-slot-body { - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - text-align: center; - z-index: 2; - padding: 0.5rem 0; -} - -.card-title { - font-family: var(--font-heading); - font-size: 1.2rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 0.75rem; - pointer-events: none; -} - -.face-card-slot.has-assignment .card-title { - color: var(--gold); -} - -.drop-zone-placeholder { - font-size: 0.8rem; - color: var(--text-muted); - border: 1px dashed rgba(255, 255, 255, 0.1); - background: rgba(0, 0, 0, 0.2); - border-radius: 6px; - padding: 0.5rem 0.75rem; - transition: var(--transition-smooth); - pointer-events: none; -} - -.face-card-slot:hover .drop-zone-placeholder { - color: var(--text-primary); - border-color: rgba(255, 255, 255, 0.2); -} - -.assigned-tech-content { - width: 100%; - display: none; -} - -.face-card-slot.has-assignment .drop-zone-placeholder { - display: none; -} - -.face-card-slot.has-assignment .assigned-tech-content { - display: block; -} - -.assigned-tech-chip { - background: rgba(212, 175, 55, 0.08); - border: 1px solid var(--gold); - color: var(--text-primary); - padding: 0.5rem; - border-radius: 8px; - font-size: 0.85rem; - position: relative; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); - word-break: break-word; - animation: fadeIn 0.3s ease; -} - -.remove-tech-btn { - position: absolute; - top: -8px; - right: -8px; - width: 18px; - height: 18px; - border-radius: 50%; - background: #c0392b; - border: 1px solid #e74c3c; - color: white; - font-size: 10px; - font-weight: 900; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - box-shadow: 0 2px 5px rgba(0,0,0,0.5); - transition: var(--transition-smooth); -} - -.remove-tech-btn:hover { - background: #e74c3c; - transform: scale(1.1); -} - -@keyframes fadeIn { - from { opacity: 0; transform: scale(0.9); } - to { opacity: 1; transform: scale(1); } -} - -/* Drag and Drop Interface Styles */ -.card-large[draggable="true"] { - cursor: grab; -} - -.card-large[draggable="true"]:active { - cursor: grabbing; -} - -.card-large.dragging { - opacity: 0.4; - border-style: dashed; - transform: scale(0.95); -} - -.obstacle-item.drag-over { - border-color: var(--gold) !important; - background: rgba(212, 175, 55, 0.1) !important; - box-shadow: 0 0 20px rgba(212, 175, 55, 0.2) !important; - transform: translateY(-2px) scale(1.01); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); -} - -/* --- Deep Upkeep Hand Refresh Drag & Drop --- */ -.upkeep-drag-container { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem; - margin: 2rem 0; -} - -@media (max-width: 768px) { - .upkeep-drag-container { - grid-template-columns: 1fr; - } -} - -.upkeep-box { - min-height: 400px; - display: flex; - flex-direction: column; -} - -.upkeep-box h3 { - margin-bottom: 1rem; - font-family: var(--font-heading); -} - -.upkeep-flex { - flex: 1; - display: flex; - flex-wrap: wrap; - gap: 1rem; - padding: 1.5rem; - background: rgba(0, 0, 0, 0.3); - border: 1px dashed var(--glass-border); - border-radius: 8px; - align-content: flex-start; - min-height: 300px; - transition: var(--transition-smooth); -} - -.upkeep-box[ondragover]:hover .upkeep-flex { - border-color: var(--neon-cyan); -} - +@import url("core.css"); +@import url("components.css"); +@import url("card.css"); +@import url("welcome.css"); +@import url("lobby.css"); +@import url("character.css"); +@import url("scene-setup.css"); +@import url("scene-play.css"); +@import url("upkeep.css"); +@import url("admin.css"); diff --git a/src/pirats/static/css/upkeep.css b/src/pirats/static/css/upkeep.css new file mode 100644 index 0000000..5a949a8 --- /dev/null +++ b/src/pirats/static/css/upkeep.css @@ -0,0 +1,100 @@ +/* --- Between Scenes Upkeep --- */ +.between-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin: 2rem 0; +} + +@media (max-width: 800px) { + .between-grid { + grid-template-columns: 1fr; + } +} + +.voting-card, .deep-rest-card { + text-align: left; +} + +.voted-confirmation-box { + padding: 1.5rem; + text-align: center; + border: 1px solid var(--neon-emerald); +} + +.success-text { + color: var(--neon-emerald); + font-weight: 700; +} + +.ranks-ledger { + list-style: none; +} + +.ranks-ledger li { + padding: 0.5rem; + border-bottom: 1px solid rgba(255,255,255,0.05); + display: flex; + justify-content: space-between; +} + +.deep-badge { + background: rgba(0, 242, 254, 0.15); + border: 1px solid var(--neon-cyan); + color: var(--neon-cyan); + padding: 0.1rem 0.4rem; + font-size: 0.75rem; + border-radius: 4px; +} + +.pirat-badge { + background: rgba(0, 255, 135, 0.15); + border: 1px solid var(--neon-emerald); + color: var(--neon-emerald); + padding: 0.1rem 0.4rem; + font-size: 0.75rem; + border-radius: 4px; +} + +/* --- Deep Upkeep Hand Refresh Drag & Drop --- */ +.upkeep-drag-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin: 2rem 0; +} + +@media (max-width: 768px) { + .upkeep-drag-container { + grid-template-columns: 1fr; + } +} + +.upkeep-box { + min-height: 400px; + display: flex; + flex-direction: column; +} + +.upkeep-box h3 { + margin-bottom: 1rem; + font-family: var(--font-heading); +} + +.upkeep-flex { + flex: 1; + display: flex; + flex-wrap: wrap; + gap: 1rem; + padding: 1.5rem; + background: rgba(0, 0, 0, 0.3); + border: 1px dashed var(--glass-border); + border-radius: 8px; + align-content: flex-start; + min-height: 300px; + transition: var(--transition-smooth); +} + +.upkeep-box[ondragover]:hover .upkeep-flex { + border-color: var(--neon-cyan); +} diff --git a/src/pirats/static/css/welcome.css b/src/pirats/static/css/welcome.css new file mode 100644 index 0000000..a06e78e --- /dev/null +++ b/src/pirats/static/css/welcome.css @@ -0,0 +1,50 @@ +/* --- Welcome / Main Menu Screen --- */ +.welcome-container { + max-width: 900px; + margin: 4rem auto; +} + +.welcome-hero h2 { + font-family: var(--font-heading); + font-size: 2.5rem; + color: var(--text-primary); + margin-bottom: 1rem; +} + +.welcome-hero .subtitle { + font-size: 1.2rem; + color: var(--text-muted); + margin-bottom: 3rem; +} + +.welcome-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; +} + +@media (max-width: 768px) { + .welcome-actions { + grid-template-columns: 1fr; + } +} + +.action-card { + padding: 2.5rem 1.5rem; + display: flex; + flex-direction: column; + justify-content: space-between; + height: 300px; +} + +.action-card h3 { + font-family: var(--font-heading); + color: var(--gold); + font-size: 1.5rem; + margin-bottom: 0.75rem; +} + +.action-card p { + color: var(--text-muted); + margin-bottom: 2rem; +} diff --git a/src/pirats/templates.py b/src/pirats/templates.py new file mode 100644 index 0000000..bc3ea6a --- /dev/null +++ b/src/pirats/templates.py @@ -0,0 +1,8 @@ +from pathlib import Path +from fastapi.templating import Jinja2Templates + +# Get the directory of this module +BASE_DIR = Path(__file__).resolve().parent + +# Templates setup +templates = Jinja2Templates(directory=BASE_DIR / "templates")