Refactor for smaller context
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
.venv
|
.venv
|
||||||
rats_with_gats.db
|
rats_with_gats.db
|
||||||
|
result
|
||||||
|
*.pdf
|
||||||
|
|||||||
112
SOURCEMAP.md
Normal file
112
SOURCEMAP.md
Normal 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.
|
||||||
@@ -1,840 +1,5 @@
|
|||||||
import json
|
# Facade for backward compatibility and centralized access
|
||||||
import random
|
from .crud_base import *
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from .crud_character import *
|
||||||
from sqlmodel import Session, select
|
from .crud_scene import *
|
||||||
from .models import Game, Player, Obstacle, Vote
|
from .crud_upkeep import *
|
||||||
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()
|
|
||||||
|
|||||||
182
src/pirats/crud_base.py
Normal file
182
src/pirats/crud_base.py
Normal 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
|
||||||
173
src/pirats/crud_character.py
Normal file
173
src/pirats/crud_character.py
Normal 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
337
src/pirats/crud_scene.py
Normal 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
154
src/pirats/crud_upkeep.py
Normal 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()
|
||||||
@@ -1,35 +1,26 @@
|
|||||||
import json
|
from typing import Optional
|
||||||
from typing import List, Optional
|
|
||||||
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status
|
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import crud
|
from . import crud
|
||||||
from . import cards
|
|
||||||
from .database import create_db_and_tables, get_session
|
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")
|
app = FastAPI(title="Rats with Gats Remote Play")
|
||||||
|
|
||||||
# Get the directory of this module
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
|
||||||
|
|
||||||
# Mount static files
|
# Mount static files
|
||||||
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
||||||
|
|
||||||
# Templates setup
|
|
||||||
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
|
||||||
|
|
||||||
# Register startup event to create tables
|
# Register startup event to create tables
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
def on_startup():
|
||||||
create_db_and_tables()
|
create_db_and_tables()
|
||||||
|
|
||||||
# --- HTTP Route Handlers ---
|
# --- HTTP Core Route Handlers ---
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def home(request: Request):
|
def home(request: Request):
|
||||||
@@ -80,23 +71,7 @@ def player_dashboard(request: Request, game_id: str, player_id: str, db: Session
|
|||||||
"player": player
|
"player": player
|
||||||
})
|
})
|
||||||
|
|
||||||
# Creator Admin Panel
|
# --- HTMX Core Polling Route ---
|
||||||
@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 ---
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/player/{player_id}/phase-check")
|
@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)):
|
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=200, headers={"HX-Trigger": "phase-changed"})
|
||||||
return HTMLResponse(status_code=204) # No content, no reload
|
return HTMLResponse(status_code=204) # No content, no reload
|
||||||
|
|
||||||
@app.get("/game/{game_id}/lobby/players", response_class=HTMLResponse)
|
# --- Register Modular Phase Routers ---
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/scene/roster", response_class=HTMLResponse)
|
from .phase_view import router as phase_view_router
|
||||||
def scene_roster_snippet(request: Request, game_id: str, db: Session = Depends(get_session)):
|
from .routes_lobby import router as lobby_router
|
||||||
game = crud.get_game(db, game_id)
|
from .routes_character import router as character_router
|
||||||
if not game:
|
from .routes_scene import router as scene_router
|
||||||
return HTMLResponse("")
|
from .routes_upkeep import router as upkeep_router
|
||||||
return templates.TemplateResponse("scene_roster_snippet.html", {
|
from .routes_admin import router as admin_router
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"is_captain": crud.is_player_captain
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/scene/obstacles", response_class=HTMLResponse)
|
app.include_router(phase_view_router)
|
||||||
def scene_obstacles_snippet(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)):
|
app.include_router(lobby_router)
|
||||||
game = crud.get_game(db, game_id)
|
app.include_router(character_router)
|
||||||
player = crud.get_player(db, player_id)
|
app.include_router(scene_router)
|
||||||
if not game or not player:
|
app.include_router(upkeep_router)
|
||||||
return HTMLResponse("")
|
app.include_router(admin_router)
|
||||||
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)
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
import argparse
|
import argparse
|
||||||
|
|||||||
95
src/pirats/phase_view.py
Normal file
95
src/pirats/phase_view.py
Normal 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>")
|
||||||
24
src/pirats/routes_admin.py
Normal file
24
src/pirats/routes_admin.py
Normal 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
|
||||||
|
})
|
||||||
273
src/pirats/routes_character.py
Normal file
273
src/pirats/routes_character.py
Normal 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
|
||||||
|
})
|
||||||
30
src/pirats/routes_lobby.py
Normal file
30
src/pirats/routes_lobby.py
Normal 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
136
src/pirats/routes_scene.py
Normal 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)
|
||||||
64
src/pirats/routes_upkeep.py
Normal file
64
src/pirats/routes_upkeep.py
Normal 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)
|
||||||
28
src/pirats/static/css/admin.css
Normal file
28
src/pirats/static/css/admin.css
Normal 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;
|
||||||
|
}
|
||||||
244
src/pirats/static/css/card.css
Normal file
244
src/pirats/static/css/card.css
Normal 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);
|
||||||
|
}
|
||||||
442
src/pirats/static/css/character.css
Normal file
442
src/pirats/static/css/character.css
Normal 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); }
|
||||||
|
}
|
||||||
195
src/pirats/static/css/components.css
Normal file
195
src/pirats/static/css/components.css
Normal 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);
|
||||||
|
}
|
||||||
231
src/pirats/static/css/core.css
Normal file
231
src/pirats/static/css/core.css
Normal 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; }
|
||||||
106
src/pirats/static/css/lobby.css
Normal file
106
src/pirats/static/css/lobby.css
Normal 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;
|
||||||
|
}
|
||||||
270
src/pirats/static/css/scene-play.css
Normal file
270
src/pirats/static/css/scene-play.css
Normal 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;
|
||||||
|
}
|
||||||
83
src/pirats/static/css/scene-setup.css
Normal file
83
src/pirats/static/css/scene-setup.css
Normal 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
100
src/pirats/static/css/upkeep.css
Normal file
100
src/pirats/static/css/upkeep.css
Normal 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);
|
||||||
|
}
|
||||||
50
src/pirats/static/css/welcome.css
Normal file
50
src/pirats/static/css/welcome.css
Normal 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
8
src/pirats/templates.py
Normal 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")
|
||||||
Reference in New Issue
Block a user