Refactor for smaller context

This commit is contained in:
2026-06-10 15:29:03 -07:00
parent 2d57973cd8
commit 2ddcb86cc0
26 changed files with 3373 additions and 3188 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
.venv
rats_with_gats.db
result
*.pdf

112
SOURCEMAP.md Normal file
View File

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

View File

@@ -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 *

182
src/pirats/crud_base.py Normal file
View File

@@ -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

View File

@@ -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()

337
src/pirats/crud_scene.py Normal file
View File

@@ -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()

154
src/pirats/crud_upkeep.py Normal file
View File

@@ -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()

View File

@@ -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("<p class='alert alert-danger'>Session disconnected or expired.</p>")
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("<p>Unknown game state.</p>")
# --- 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("<p class='alert alert-danger'>Error: Each card must be assigned a unique technique.</p>", 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"<div class='alert alert-danger'>{msg}</div>", 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"<p class='alert alert-danger'>{msg}</p>", 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"<div class='play-notification' data-drew='{drew_attr}' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
f"<p>{color_prefix} {res['details']}{drew_text}</p>"
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
f"</div>"
)
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"<p class='alert alert-danger'>{msg}</p>", status_code=400)
html = (
f"<div class='play-notification' data-drew='false' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
f"<p>🃏 Play Joker: Discarded obstacle and drew a new replacement!</p>"
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
f"</div>"
)
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

95
src/pirats/phase_view.py Normal file
View File

@@ -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("<p class='alert alert-danger'>Session disconnected or expired.</p>")
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("<p>Unknown game state.</p>")

View File

@@ -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
})

View File

@@ -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("<p class='alert alert-danger'>Error: Each card must be assigned a unique technique.</p>", 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
})

View File

@@ -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
})

136
src/pirats/routes_scene.py Normal file
View File

@@ -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"<div class='alert alert-danger'>{msg}</div>", 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"<p class='alert alert-danger'>{msg}</p>", 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"<div class='play-notification' data-drew='{drew_attr}' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
f"<p>{color_prefix} {res['details']}{drew_text}</p>"
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
f"</div>"
)
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"<p class='alert alert-danger'>{msg}</p>", status_code=400)
html = (
f"<div class='play-notification' data-drew='false' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
f"<p>🃏 Play Joker: Discarded obstacle and drew a new replacement!</p>"
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
f"</div>"
)
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)

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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); }
}

View File

@@ -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);
}

View File

@@ -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; }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}

View File

@@ -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;
}

8
src/pirats/templates.py Normal file
View File

@@ -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")