Initial checkin

This commit is contained in:
2026-06-09 16:06:44 -07:00
commit 82ece0de10
31 changed files with 6348 additions and 0 deletions

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1780749050,
"narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a799d3e3886da994fa307f817a6bc705ae538eeb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

119
flake.nix Normal file
View File

@@ -0,0 +1,119 @@
{
description = "Rats with Gats Remote Play Web App";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
python = pkgs.python3;
# Define the application package
piratsApp = python.pkgs.buildPythonApplication {
pname = "pirats";
version = "0.1.0";
src = ./.;
format = "pyproject";
nativeBuildInputs = with python.pkgs; [
setuptools
wheel
];
propagatedBuildInputs = with python.pkgs; [
fastapi
sqlmodel
uvicorn
jinja2
python-multipart
];
nativeCheckInputs = with python.pkgs; [
pytestCheckHook
];
pythonImportsCheck = [ "pirats" ];
};
in
{
packages.default = piratsApp;
packages.pirats = piratsApp;
devShells.default = pkgs.mkShell {
inputsFrom = [ piratsApp ];
packages = with pkgs; [
python.pkgs.pytest
];
};
}
) // {
# NixOS Module to run the application as a service
nixosModules.default = { config, lib, pkgs, ... }:
let
cfg = config.services.pirats;
in
{
options.services.pirats = {
enable = lib.mkEnableOption "Rats with Gats remote play service";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.default;
description = "The pirats package to use.";
};
host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "The host address to bind to.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8000;
description = "The port to listen on.";
};
databasePath = lib.mkOption {
type = lib.types.str;
default = "/var/lib/pirats/rats_with_gats.db";
description = "Path to the SQLite database file.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open ports in the firewall for the service.";
};
};
config = lib.mkIf cfg.enable {
networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [ cfg.port ];
systemd.services.pirats = {
description = "Rats with Gats Web Application";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
DATABASE_URL = "sqlite:///${cfg.databasePath}";
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/pirats --host ${cfg.host} --port ${toString cfg.port}";
Restart = "always";
# Sandboxing and security
DynamicUser = true;
StateDirectory = "pirats";
WorkingDirectory = "/var/lib/pirats";
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
};
};
};
};
};
}

35
pyproject.toml Normal file
View File

@@ -0,0 +1,35 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pirats"
version = "0.1.0"
description = "Rats with Gats Remote Play web application"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"fastapi",
"sqlmodel",
"uvicorn",
"jinja2",
"python-multipart",
]
[project.optional-dependencies]
dev = [
"pytest",
]
[project.scripts]
pirats = "pirats.main:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
pirats = ["static/**/*", "templates/**/*.html", "templates/*.html"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

1
src/pirats/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Pirats package

203
src/pirats/cards.py Normal file
View File

@@ -0,0 +1,203 @@
import random
from typing import Dict, Any, List, Tuple
SUITS = {
"C": {"symbol": "", "name": "Clubs", "color": "black", "narration": "Shootin' and Stabbin' (violence/combat)"},
"S": {"symbol": "", "name": "Spades", "color": "black", "narration": "Sneakin' and Schemin' (stealth/cunning)"},
"H": {"symbol": "", "name": "Hearts", "color": "red", "narration": "Shoutin' and Singin' (social/morale)"},
"D": {"symbol": "", "name": "Diamonds", "color": "red", "narration": "Swimmin' and Sailin' (nautical/athletics)"}
}
VALUES = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
# Obstacle data dictionary from the rules text
OBSTACLES_DATA = {
"C": {
"A": ("Animal Stowaways", "Octopuses, seagulls and lobsters infiltrating the crew, pretending they belong while being terrible at their jobs!"),
"2": ("Squidfolk Fisherman", "Confused passenger who thinks this is a merchant vessel looking to buy his daily catch. He is quite insistent!"),
"3": ("Goblin Research Team", "These interstellar beings like to conduct invasive studies all across Yeld. The goblins are mostly harmless, but annoying and constantly in the way with their invasive probing."),
"4": ("Fairy Deserter", "Deserter from an enemy vessel seeking asylum. No one trusts a deserter, but Fairies are almost five times the size of a Pi-Rat. It might be best to play nice!"),
"5": ("Loitering Mermaid", "Mermaids love to hitch rides on unsuspecting ships, drinking all the booze, smoking all the cigarettes, and starting all kinds of drama among the crew. Some do this professionally as saboteurs!"),
"6": ("Mergang", "A group of Mermaid Privateers have emerged from the ocean below to board the ship in a good old-fashioned robbery. They are highly trained in naval combat and won't back down easily!"),
"7": ("Lustful Bean Whale", "A giant, magical whale made of beans! The creature seems to have mistaken the ship for a potential mate. If the Bean Whale gets too frisky, its massive body could do some real damage to the ship!"),
"8": ("Nautical Knights", "Oh Shit, it's the feds! A band of Vampire Knights loyal to the Kingdom of Yeld have snuck aboard somehow and are slaughtering everyone on the ship. It's every Rat for themselves!"),
"9": ("Deep Mage Fleet Inquisitor", "Deep Mages are powerful spell casters who work directly for the oceanic god hidden below the waves. This Inquisitor is investigating all vessels, looking for traitors and spies."),
"10": ("Pi-Rat Commodore", "A Pi-Rat commodore has come to claim the ship as one of their own by out-pirating the current Captain. We can't let ourselves be absorbed into some other rat's crew!"),
"J": ("Annoying Sailor", "Ship's previous crewmember who wants their bunk back, and maybe some revenge."),
"Q": ("Bossy First Mate", "Ship's previous first mate still trying to give orders. The problem is some of the crew keep listening to 'em!"),
"K": ("Vengeful Captain", "Ship's previous Captain who refuses to let go, and keeps challenging the current Captain for control of the ship.")
},
"S": {
"A": ("Freaky Fog", "Heavy fog has reduced visibility to almost nothing, and the crew is a little spooked. Everyone should be extra careful!"),
"2": ("Dead Seas", "A strange, ghostly stillness and silence claims the area. No waves, no wind, no life to be found. The living shouldn't stay here for long!"),
"3": ("Secret Reefs", "Dangerous shallow waters and secret reefs hide who knows what else. Navigating this will be difficult!"),
"4": ("Deep Legion Blockade", "A blockade of Deep Legion Ships has cut off our route. They may want to come aboard to perform an inspection before we can pass. Better be on our best behavior!"),
"5": ("Pirate Hideout", "A secret pirate island happens to be nearby. Crews from all over come to drink, relax, and swap tales with each other. Sometimes a fight breaks out, but no hard feelings!"),
"6": ("Wicked Whirlpool", "A Whirlpool of epic proportions has captured every vessel in the area. The Whirlpool threatens to pull the ship down. Is it natural, or is there something else going on?"),
"7": ("Floating Fairy Fortress", "Fairy sailors working for the Vampire Prince have built a massive fortress that floats off the coast. If any Fairy scouts spot us, we'll have a nasty cannon battle on our hands!"),
"8": ("Frozen Fury", "A brutal blizzard has covered the sea in freezing rain and thick snow. Worse still, hidden icebergs have been spotted nearby!"),
"9": ("Hurricane Havoc", "A massive and deadly storm comes rolling in. The wind and rain beats the ship from all sides as the waves throw it every which way. Secure the cargo! Batten down the hatches!"),
"10": ("Ship-wrecker Queen's Graveyard", "A colossal, Kaiju-sized Mermaid known as a Shipwrecker Queen has made a hunting grounds out of the area. In her wake is an endless graveyard of torn-apart ships. Don't let her see us!"),
"J": ("Fish Storm", "The ship is being pursued by a magic fish storm. Aquatic life of all shapes and sizes rain down from the clouds above, and they seem just as upset about it as we are!"),
"Q": ("Mysterious Pursuers", "The ship is being chased by an unknown vessel. It seems to keep its distance, but may retaliate if provoked. Who could it be?"),
"K": ("Cursed Island", "A strange island keeps appearing before the ship, no matter the direction we sail in. Almost every crewmember says they've had strange dreams about it.")
},
"H": {
"A": ("Over Served", "Everyone got really drunk on the job. This isn't really a problem for Pi-Rats."),
"2": ("Pirate Flu", "There's a bug going around the ship and everyone is calling in sick to work!"),
"3": ("Tail Measuring Contest", "A tail measuring contest has led to some major Gat Envy amongst the crew! Everybody is in their feelings about it, and petty arguments have broken out several times."),
"4": ("Gambling Problems", "The Pi-Rats have been gambling over everything and it's starting to cause tension and debt. There's only one way out for those in debt: More gambling!"),
"5": ("Scurvy", "The scourge of the seas! Scurvy has taken down many a Pi-Rat. There are few things more dramatic than a Pi-Rat who thinks they are dying of Scurvy."),
"6": ("Doldrums", "A wave of depression has hit the crew, and everyone's feeling pretty down right now. What's even the point, ya'know?"),
"7": ("Bad Breakups", "A lover's quarrel has divided the crew! Everyone has to choose a side, and it's going to be really awkward for a while."),
"8": ("Crew Wedding", "Members of the crew are getting married! Everyone is trying to juggle their pirate duties with their wedding duties, and nothing is going right."),
"9": ("Double Down Dares", "A series of escalating pirate dares among the crew has gotten completely out of hand. Someone's going to get shot on purpose, or worse, sink the ship!"),
"10": ("Cheese Thief", "Someone has been stealing cheese, and now everyone is livid. The culprit must be found, and immediately dealt with... Pi-Rat style."),
"J": ("Super-duper-stitious", "The crew has grown superstitious as of late, and morale has taken a dive. Only more superstition can save us!"),
"Q": ("Pirate Curse", "The crew has been magically cursed by some unknown thing. There has to be some kind of complicated or convoluted way of breaking it!"),
"K": ("Among us", "Someone on the ship is a traitor, and everyone is suspicious of everyone else. The traitor must be found before they can sell us out to our enemies!")
},
"D": {
"A": ("Rat Bastards", "Regular rats have eaten through the cargo, destroying, devouring, and defecating on everything below deck. It's a real mess!"),
"2": ("Slip and Slide", "The deck has been freshly swabbed, but the crew used slippery soap to do it. Now everyone is sliding around, and some have slipped overboard!"),
"3": ("Broken Straps", "Loose cargo has broken from its secured position and is rolling around dangerously. The cargo must be tied back down before it's lost or someone gets squished!"),
"4": ("Low Supplies", "The ship is running low on the essentials: alcohol, tobacco, fresh water, and food (in that order). If we don't get some supplies soon, we'll have sober, cranky mutineers!"),
"5": ("Torn Sails", "The sails have been badly shredded, slowing the ship's max speed significantly. These sails will need to be patched or replaced!"),
"6": ("Jammed Helm and Rutter", "Something has jammed or damaged the Rutter and now the helm won't budge. We can't change course!"),
"7": ("Stuck Anchor", "The anchor won't lower or raise. Either it's stuck somewhere on the reefs or wreckage, or it won't deploy at all, causing the ship to drift!"),
"8": ("Broken Bilge Pump", "The Bilge pump has failed and the ship has begun taking on too much water. Somebody get some buckets before we sink!"),
"9": ("Severed Mast", "The main mast has been heavily damaged, compromising the rigging and sails. The Ship isn't going anywhere now!"),
"10": ("Powder Bomb", "The Ship's stockpile of ammunition and explosives has detonated, causing massive damage and starting several large fires. The ship can still be saved if we work together!"),
"J": ("Mystery Leak", "Somewhere on board is a leak, and no one can seem to find it. If the leak isn't dealt with, we're all going to wake up under water!"),
"Q": ("Bad Navigation", "Incorrect navigational charts and bad directions have sent the ship way off course and into stranger seas. Where are we, and where do we go from here?"),
"K": ("It's Alive", "The ship is brought to life through magical means. It's not going to cooperate without an incentive, now that it has its own wants and needs.")
}
}
# 20 funny Secret Pirate Technique suggestions for when players need ideas
TECHNIQUE_SUGGESTIONS = [
"I missed on purpose",
"Carlos will figure it out",
"Never trust a hatless captain",
"I'm not left handed",
"The Albatross is always watching",
"I've got a bomb",
"Divide by Pi",
"Cheese-scented smoke screen",
"Look behind you, a three-headed parrot!",
"Parabolic boarding leap",
"The Pocket Cannon",
"Aggressive Math-whining",
"Unspeakable Tail Swish",
"Bandana Whip",
"Double-barrelled cheese blaster",
"Mathematical Scurvy cure",
"I was hiding under the carpet",
"Batten down my feelings",
"Wait, is that cheese?",
"Sudden pirate flip"
]
def get_fresh_deck() -> List[str]:
"""Generates a shuffled deck of 54 cards (52 standard cards + 2 Jokers)."""
deck = []
for suit in SUITS.keys():
for val in VALUES:
deck.append(f"{val}{suit}")
# Add Jokers: Joker1 (Black Joker), Joker2 (Red Joker)
deck.extend(["Joker1", "Joker2"])
random.shuffle(deck)
return deck
def parse_card(card_code: str) -> Dict[str, Any]:
"""Parses a card code (e.g. '7C', '10D', 'KH', 'Joker1') into metadata."""
if card_code.startswith("Joker"):
color = "black" if card_code == "Joker1" else "red"
symbol = "🃏"
name = "Black Joker" if card_code == "Joker1" else "Red Joker"
return {
"code": card_code,
"value": "Joker",
"suit": "Joker",
"color": color,
"symbol": symbol,
"name": name,
"display": f"{symbol} {name}",
"numeric_value": 0,
"is_joker": True
}
# Extract suit (last character) and value (preceding characters)
suit_code = card_code[-1]
value_code = card_code[:-1]
suit_info = SUITS[suit_code]
# Numeric value logic
if value_code == "A":
num_val = 1
elif value_code == "J":
num_val = 11
elif value_code == "Q":
num_val = 12
elif value_code == "K":
num_val = 13
else:
num_val = int(value_code)
val_names = {
"A": "Ace",
"J": "Jack",
"Q": "Queen",
"K": "King"
}
val_name = val_names.get(value_code, value_code)
return {
"code": card_code,
"value": value_code,
"suit": suit_code,
"color": suit_info["color"],
"symbol": suit_info["symbol"],
"name": f"{val_name} of {suit_info['name']}",
"display": f"{value_code}{suit_info['symbol']}",
"numeric_value": num_val,
"is_joker": False
}
def get_obstacle_info(card_code: str) -> Dict[str, Any]:
"""Retrieves the title and description for an obstacle card drawn from the deck."""
parsed = parse_card(card_code)
if parsed["is_joker"]:
return {
"title": "Pirate Luck (Joker)",
"description": "Morale and wind change direction! Immediately replace this obstacle.",
"suit": "Joker",
"color": parsed["color"],
"value": "Joker",
"symbol": "🃏",
"initial_value": 0,
"is_joker": True
}
suit = parsed["suit"]
val = parsed["value"]
title, desc = OBSTACLES_DATA[suit][val]
return {
"title": title,
"description": desc,
"suit": suit,
"color": parsed["color"],
"value": val,
"symbol": parsed["symbol"],
"initial_value": parsed["numeric_value"],
"is_joker": False
}
def match_suit_color(card_a: str, card_b: str) -> bool:
"""Checks if two card codes have the same suit color (red or black)."""
p_a = parse_card(card_a)
p_b = parse_card(card_b)
return p_a["color"] == p_b["color"]

811
src/pirats/crud.py Normal file
View File

@@ -0,0 +1,811 @@
import json
import random
from typing import List, Dict, Any, Optional, Tuple
from sqlmodel import Session, select
from .models import Game, Player, Obstacle, Challenge, Vote
from . import cards
# --- Card and Hand Utilities ---
def get_player_hand(player: Player) -> List[str]:
return json.loads(player.hand_cards)
def set_player_hand(player: Player, hand: List[str]):
player.hand_cards = json.dumps(hand)
def get_game_deck(game: Game) -> List[str]:
return json.loads(game.deck_cards)
def set_game_deck(game: Game, deck: List[str]):
game.deck_cards = json.dumps(deck)
def calculate_max_hand_size(player: Player, players_in_scene: List[Player]) -> int:
"""
Calculates a player's maximum hand size based on their Rank relative to others in the scene,
and Captain privileges (+1).
Rank Hand sizes:
- Highest Rank Pi-Rat(s): max 4 cards
- Middle Rank Pi-Rat(s): max 3 cards
- Lowest Rank Pi-Rat(s): max 2 cards
- Captain gets +1
If all players have the same Rank, they are all 'middle' and get 3 cards.
"""
if player.role != "pirat":
# 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 == "pirat"]
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 != "pirat":
return False
pi_rats = [p for p in players_in_scene if p.role == "pirat"]
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) -> Game:
deck = cards.get_fresh_deck()
game = Game(
deck_cards=json.dumps(deck),
phase="lobby",
current_scene_number=1,
extra_obstacles=0
)
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:
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
)
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
# 1. Count roles
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."
# 2. 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."
# Everything is valid! Start the scene.
# A. Reset obstacles and challenges
for obs in game.obstacles:
db.delete(obs)
for chal in game.challenges:
db.delete(chal)
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 create_challenge(db: Session, game_id: str, title: str, description: str, obstacle_ids: List[str]) -> Challenge:
challenge = Challenge(
game_id=game_id,
title=title,
description=description,
applied_obstacle_ids=json.dumps(obstacle_ids),
is_active=True
)
db.add(challenge)
db.commit()
db.refresh(challenge)
return challenge
def play_card_on_obstacle(
db: Session,
player_id: str,
obstacle_id: str,
card_code: str,
challenge_id: 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()
# 4. Deactivate the challenge (since a card was played against one of its obstacles)
# Note: If the challenge had multiple obstacles, this single play resolves it.
# In a more advanced UI we could track individual obstacle resolutions, but to keep the flow moving,
# resolving the active challenge is clean. Let's mark the challenge as resolved by this player.
challenge = db.get(Challenge, challenge_id)
if challenge:
challenge.is_active = False
challenge.resolved_by_player_id = player.id
db.add(challenge)
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)
# Remove active challenges
for chal in game.challenges:
db.delete(chal)
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 redraw_deep_players_hands(db: Session, game: Game):
"""
Step 3 of Between Scenes: Deep players from the previous scene can discard and redraw.
To make it simple in the UI, we'll let them click a button to redraw or automatically redraw
their hands to their maximum size. Let's empty their hand, shuffle them into the deck,
and draw back up to their new max hand size!
"""
for p in game.players:
if p.previous_role == "deep":
# Return hand to deck
hand = get_player_hand(p)
deck = get_game_deck(game)
deck.extend(hand)
random.shuffle(deck)
set_game_deck(game, deck)
set_player_hand(p, [])
# Recalculate max hand size and draw back up
max_size = calculate_max_hand_size(p, game.players)
draw_cards_for_player(db, game, p, max_size)
def proceed_to_next_scene_setup(db: Session, game_id: str):
game = get_game(db, game_id)
if not game:
return
# Process votes & redraw hands
process_between_scenes_votes(db, game)
redraw_deep_players_hands(db, game)
# Increment scene number
game.current_scene_number += 1
game.phase = "scene_setup"
# Reset role options for everyone
for p in game.players:
p.is_ready = False
p.role = None
db.add(p)
db.add(game)
db.commit()

19
src/pirats/database.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlmodel import SQLModel, create_engine, Session
import os
from pathlib import Path
# Allow configuring via environment variable
sqlite_url = os.environ.get("DATABASE_URL")
if not sqlite_url:
DB_FILE = Path.cwd() / "rats_with_gats.db"
sqlite_url = f"sqlite:///{DB_FILE}"
# Use connect_args={"check_same_thread": False} for SQLite in FastAPI
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session

705
src/pirats/main.py Normal file
View File

@@ -0,0 +1,705 @@
import json
from typing import List, Optional
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlmodel import Session
import uvicorn
from pathlib import Path
from . import crud
from . import cards
from .database import create_db_and_tables, get_session
from .models import Game, Player, Obstacle, Challenge, Vote
app = FastAPI(title="Rats with Gats Remote Play")
# Get the directory of this module
BASE_DIR = Path(__file__).resolve().parent
# Mount static files
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
# Templates setup
templates = Jinja2Templates(directory=BASE_DIR / "templates")
# Register startup event to create tables
@app.on_event("startup")
def on_startup():
create_db_and_tables()
# --- HTTP Route Handlers ---
@app.get("/", response_class=HTMLResponse)
def home(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.post("/game/create")
def create_game_route(db: Session = Depends(get_session)):
game = crud.create_game(db)
# Redirect creator to the join page with creator flag
return RedirectResponse(url=f"/game/{game.id}/join?creator=true", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/game/{game_id}/join", response_class=HTMLResponse)
def join_game_form(request: Request, game_id: str, creator: bool = False, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
return templates.TemplateResponse("join_form.html", {"request": request, "game": game, "creator": creator})
@app.post("/game/{game_id}/join")
def join_game_submit(
game_id: str,
name: str = Form(...),
db: Session = Depends(get_session)
):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
# First player is automatically the creator
is_creator = len(game.players) == 0
player = crud.add_player(db, game_id, name.strip(), is_creator=is_creator)
return RedirectResponse(url=f"/game/{game_id}/player/{player.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/game/{game_id}/player/{player_id}", response_class=HTMLResponse)
def player_dashboard(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="Game or Player not found")
return templates.TemplateResponse("dashboard.html", {
"request": request,
"game": game,
"player": player
})
# Creator Admin Panel
@app.get("/game/{game_id}/admin", response_class=HTMLResponse)
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
if game.admin_key != key:
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
base_url = str(request.base_url).rstrip("/")
return templates.TemplateResponse("admin.html", {
"request": request,
"game": game,
"base_url": base_url
})
# --- HTMX Snippet and Phase Polling Routes ---
@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)):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
if game.phase != current:
# Phase changed! Trigger page reload on client via HTMX header
return HTMLResponse(status_code=200, headers={"HX-Trigger": "phase-changed"})
return HTMLResponse(status_code=204) # No content, no reload
@app.get("/game/{game_id}/lobby/players", response_class=HTMLResponse)
def lobby_players_snippet(request: Request, game_id: str, player_id: Optional[str] = None, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
return HTMLResponse("")
return templates.TemplateResponse("lobby_players_snippet.html", {
"request": request,
"game": game,
"player_id": player_id
})
@app.get("/game/{game_id}/scene/roster", response_class=HTMLResponse)
def scene_roster_snippet(request: Request, game_id: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
return HTMLResponse("")
return templates.TemplateResponse("scene_roster_snippet.html", {
"request": request,
"game": game,
"is_captain": crud.is_player_captain
})
@app.get("/game/{game_id}/scene/obstacles", response_class=HTMLResponse)
def scene_obstacles_snippet(request: Request, game_id: str, player_rank: int, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
if not game:
return HTMLResponse("")
return templates.TemplateResponse("scene_obstacles_snippet.html", {
"request": request,
"game": game,
"player_rank": player_rank,
"parse_card": cards.parse_card,
"json_loads": json.loads
})
@app.get("/game/{game_id}/scene/challenges", response_class=HTMLResponse)
def scene_challenges_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("")
def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]:
return db.get(Obstacle, obs_id)
hand = crud.get_player_hand(player)
return templates.TemplateResponse("scene_challenges_snippet.html", {
"request": request,
"game": game,
"player": player,
"hand": hand,
"parse_card": cards.parse_card,
"get_obstacle_by_id": get_obstacle_by_id,
"json_loads": json.loads
})
@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, 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"})
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
}
# 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)
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 HTMLResponse(status_code=204)
# 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)
):
player = crud.get_player(db, player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
player.avatar_look = ""
player.avatar_smell = ""
player.first_words = ""
db.add(player)
db.commit()
# Trigger HTMX reload
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", 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(
request: Request,
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)
# Re-fetch data to render the updated inbox snippet
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")
# Compute inbox tasks
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"})
return templates.TemplateResponse("inbox_snippet.html", {
"request": request,
"game": game,
"player": player,
"inbox_tasks": inbox_tasks
})
# 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")
def player_set_role(
game_id: str,
player_id: str,
role: str,
db: Session = Depends(get_session)
):
crud.update_scene_role(db, player_id, role)
return HTMLResponse(status_code=204)
# 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)
# 10. Deep creates a new challenge
@app.post("/game/{game_id}/challenge/create")
def create_challenge_route(
game_id: str,
title: str = Form(...),
description: str = Form(""),
obstacle_ids: List[str] = Form(...),
db: Session = Depends(get_session)
):
crud.create_challenge(db, game_id, title.strip(), description.strip(), obstacle_ids)
return HTMLResponse(status_code=204)
# 11. Pi-Rat plays card against an obstacle in a challenge
@app.post("/game/{game_id}/player/{player_id}/play-card")
def play_card_route(
game_id: str,
player_id: str,
challenge_id: str = Form(...),
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, challenge_id)
if not ok:
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
# Return a success notification box that HTMX will show, or just return 204 to let polling pick it up.
# Returning a message is nice! But returning 204 is clean. Let's return a brief HTML snippet.
color_prefix = "🟩" if res["success"] else "🟥"
drew_text = f" (Drew card: {res['drew_card']})" if res["drew_card"] else ""
return HTMLResponse(
f"<div class='play-notification' 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>"
)
# 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)
):
# Retrieve any active challenge that contains this obstacle to use as a dummy,
# or let play_card_on_obstacle handle challenge_id=None.
# Wait, we can pass challenge_id = "" and check in play_card if it exists.
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 HTMLResponse(
f"<div class='play-notification' 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>"
)
# 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 HTMLResponse(status_code=204)
# 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 next scene!
game = crud.get_game(db, game_id)
if game and all(p.is_ready for p in game.players):
crud.proceed_to_next_scene_setup(db, game_id)
return HTMLResponse(status_code=204)
def main():
import argparse
parser = argparse.ArgumentParser(description="Run the Rats with Gats web app")
parser.add_argument("--host", default="0.0.0.0", help="Host address to bind to")
parser.add_argument("--port", type=int, default=8000, help="Port to listen on")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
args = parser.parse_args()
if args.reload:
uvicorn.run("pirats.main:app", host=args.host, port=args.port, reload=True)
else:
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()

85
src/pirats/models.py Normal file
View File

@@ -0,0 +1,85 @@
import uuid
from typing import Optional, List
from sqlmodel import Field, SQLModel, Relationship
class Game(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes"
current_scene_number: int = Field(default=1)
extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers)
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True)
challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True)
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
class Player(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
game_id: str = Field(foreign_key="game.id", index=True)
name: str = Field(default="")
is_ready: bool = Field(default=False)
rank: int = Field(default=1)
hand_cards: str = Field(default="[]") # JSON string of list of card codes
role: Optional[str] = Field(default=None) # "pirat", "deep", or None
previous_role: Optional[str] = Field(default=None) # "pirat", "deep", or None
is_creator: bool = Field(default=False)
# Character sheet questions
avatar_look: str = Field(default="")
avatar_smell: str = Field(default="")
first_words: str = Field(default="")
good_at_math: str = Field(default="")
# Delegated questions
other_like: str = Field(default="")
other_like_from_player_id: Optional[str] = Field(default=None)
other_hate: str = Field(default="")
other_hate_from_player_id: Optional[str] = Field(default=None)
# Techniques
created_techniques: str = Field(default="[]") # JSON string of list of 3 strings
swapped_techniques: str = Field(default="[]") # JSON string of list of 3 strings
tech_jack: str = Field(default="")
tech_queen: str = Field(default="")
tech_king: str = Field(default="")
# Objectives
completed_personal_1: bool = Field(default=False) # Get a Gat
completed_personal_2: bool = Field(default=False) # Earn a Name
completed_personal_3: bool = Field(default=False) # Die like a Pirate
# Relationships
game: Optional[Game] = Relationship(back_populates="players")
class Obstacle(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
game_id: str = Field(foreign_key="game.id", index=True)
original_card: str = Field(...) # e.g., "7C"
suit: str = Field(...) # "C", "S", "H", "D"
title: str = Field(...) # e.g., "Lustful Bean Whale"
description: str = Field(default="")
current_value: int = Field(default=0) # Numerical value of the obstacle
played_cards: str = Field(default="[]") # JSON string of list of dicts: [{"card": "9H", "player_id": "...", "player_name": "..."}]
game: Optional[Game] = Relationship(back_populates="obstacles")
class Challenge(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
game_id: str = Field(foreign_key="game.id", index=True)
title: str = Field(...)
description: str = Field(default="")
applied_obstacle_ids: str = Field(default="[]") # JSON string of list of obstacle IDs
resolved_by_player_id: Optional[str] = Field(default=None) # Player ID if resolved (for logging/history)
is_active: bool = Field(default=True)
game: Optional[Game] = Relationship(back_populates="challenges")
class Vote(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
game_id: str = Field(foreign_key="game.id", index=True)
voter_player_id: str = Field(...)
nominated_player_id: str = Field(...)
game: Optional[Game] = Relationship(back_populates="votes")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Creator Admin Panel - Game #{{ game.id[:8] }}{% endblock %}
{% block content %}
<div class="admin-panel-container glass-panel">
<div class="welcome-hero text-center">
<h2>Game Creator Admin Panel</h2>
<p class="subtitle">Use this dashboard to copy magic sheet links for players who misplace theirs.</p>
</div>
<div class="admin-card-container">
<div class="card glass-panel">
<h3>Player Magic Links</h3>
<div class="admin-players-list">
{% for p in game.players %}
<div class="admin-player-row glass-panel">
<div class="player-info-basic">
<strong>{{ p.name }}</strong>
{% if p.is_creator %}<span class="creator-badge">Creator</span>{% endif %}
<span class="footnote-desc">(Rank {{ p.rank }}, Role: {{ p.role or 'None' }})</span>
</div>
<div class="link-copy-action">
<input type="text" readonly value="{{ base_url }}/game/{{ game.id }}/player/{{ p.id }}" class="copy-input" id="player-link-{{ p.id }}">
<button onclick="navigator.clipboard.writeText(document.getElementById('player-link-{{ p.id }}').value); alert('Player magic link copied!');" class="btn btn-secondary btn-small">Copy Link</button>
</div>
</div>
{% else %}
<p class="empty-text text-center">No players have joined yet.</p>
{% endfor %}
</div>
</div>
<div class="card glass-panel text-center margin-top">
<h3>Lobby Details</h3>
<p><strong>Game Phase:</strong> {{ game.phase.replace('_', ' ').title() }}</p>
<p><strong>Scene Number:</strong> {{ game.current_scene_number }}</p>
<p><strong>Join URL (Share this with players):</strong></p>
<div class="copy-container center-block">
<input type="text" readonly value="{{ base_url }}/game/{{ game.id }}/join" class="copy-input" id="join-link-copy">
<button onclick="navigator.clipboard.writeText(document.getElementById('join-link-copy').value); alert('Join link copied!');" class="btn btn-primary btn-small">Copy Join Link</button>
</div>
<div class="back-link margin-top">
<!-- Redirect back to the creator's personal magic dashboard link -->
{% set creator = None %}
{% for p in game.players if p.is_creator %}
{% set creator = p %}
{% endfor %}
{% if creator %}
<a href="/game/{{ game.id }}/player/{{ creator.id }}" class="btn btn-secondary btn-full">Go to Creator Dashboard</a>
{% else %}
<a href="/" class="btn btn-secondary btn-full">Go to Main Menu</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Rats with Gats{% endblock %}</title>
<!-- Google Fonts: serif header and modern sans-serif body -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;900&family=Outfit:wght@300;400;500;700&display=swap" rel="stylesheet">
<!-- Custom Weathered Math-Pirate Stylesheet -->
<link rel="stylesheet" href="/static/css/style.css">
<!-- HTMX for reactive, AJAX-driven, multi-player dynamics -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<!-- Suggestions pool for character creation -->
<script src="/static/js/suggestions.js"></script>
</head>
<body>
<header class="app-header">
<div class="logo-container">
<span class="math-magic">π</span>
<h1>Rats with Gats</h1>
</div>
{% block header_extra %}{% endblock %}
</header>
<main class="app-main">
{% block content %}{% endblock %}
</main>
<footer class="app-footer">
<p>Rats with Gats is a Story Game of mathematical piracy by Nick Smith and J. Richmond. Web-app built with FastAPI & HTMX.</p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,105 @@
<div class="between-scenes-view text-center">
<h2>Between Scenes (Scene #{{ game.current_scene_number }} Concluded)</h2>
<p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p>
<div class="between-grid">
<!-- Ranks and Voting Card -->
<div class="card glass-panel voting-card">
<h3>1. Pirate Ranking (Voting)</h3>
<p class="section-desc">Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
{% set has_voted = False %}
{% for v in game.votes %}
{% if v.voter_player_id == player.id %}
{% set has_voted = True %}
{% endif %}
{% endfor %}
{% if has_voted %}
<div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
</div>
{% else %}
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-vote" hx-swap="none" class="vote-form inline-form">
<div class="form-group inline-group">
<select name="nominated_id" class="select-field" required>
<option value="" disabled selected>Nominate crewmate...</option>
{% for other in game.players if other.id != player.id and other.previous_role != 'deep' %}
<option value="{{ other.id }}">{{ other.name }} (Rank {{ other.rank }})</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Cast Nomination</button>
</div>
</form>
{% endif %}
<!-- Voting Roster Status -->
<div class="vote-status-table margin-top">
<h4>Nomination Progress:</h4>
<div class="player-chips list-chips" id="voting-roster"
hx-get="/game/{{ game.id }}/between/roster"
hx-trigger="every 2s"
hx-swap="innerHTML">
{% for p in game.players %}
{% set voted = False %}
{% for v in game.votes if v.voter_player_id == p.id %}
{% set voted = True %}
{% endfor %}
<div class="player-chip {% if voted %}voted-chip{% else %}not-voted-chip{% endif %}">
<span class="name">{{ p.name }}</span>
<span class="status-badge">{% if voted %}Voted{% else %}Voting...{% endif %}</span>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Resting Deep Player Card -->
<div class="card glass-panel deep-rest-card">
<h3>2. Resting Deep Upkeep</h3>
{% if player.previous_role == "deep" %}
<div class="deep-rest-panel glass-panel">
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size (Rank {{ player.rank }} maximum: {{ max_hand_size }} cards).</p>
<p class="footnote-desc">This is handled automatically when transitioning, but you must click 'Ready' below to proceed.</p>
</div>
{% else %}
<p class="info-text text-center">Your Pi-Rat was active. Your hand cards carry over to the next scene.</p>
{% endif %}
<div class="roster-sheet-summary margin-top text-left glass-panel">
<h4>Crew Rank Ledger:</h4>
<ul class="ranks-ledger">
{% for p in game.players %}
<li>
<strong>{{ p.name }}:</strong> Rank {{ p.rank }}
{% if p.previous_role == 'deep' %}<span class="deep-badge">Deep</span>{% else %}<span class="pirat-badge">Pi-Rat</span>{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<!-- Next Scene Ready Up -->
<div class="action-box margin-top">
{% if player.is_ready %}
<div class="waiting-box">
<p>Ready! Waiting for other players to ready up...</p>
<div class="spinner-small"></div>
</div>
{% else %}
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/ready-next"
hx-swap="none"
class="btn btn-primary btn-large glow-effect">
Ready for Next Scene
</button>
{% endif %}
</div>
</div>
<!-- Hidden phase-check trigger -->
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
hx-trigger="every 2s"
hx-swap="none"
style="display:none;"></div>

View File

@@ -0,0 +1,117 @@
<div class="character-creation-view">
<div class="view-header text-center">
<h2>Character Sheet Creation</h2>
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
<button hx-get="/game/{{ game.id }}/player/{{ player.id }}/view"
hx-target="#game-view"
class="btn btn-secondary btn-small margin-top">
🔄 Refresh Tasks & Sheet
</button>
</div>
<!-- MAIN PANEL: Grid for Layout -->
<div class="creation-grid">
<!-- SECTION 1: Rat Records (Basic Info) -->
<div class="card glass-panel section-basic">
<h3>I. Basic Rat Records</h3>
{% if player.avatar_look and player.avatar_smell and player.first_words %}
<!-- Saved View -->
<div class="read-only-sheet">
<div class="record-line"><strong>Look:</strong> {{ player.avatar_look }}</div>
<div class="record-line"><strong>Smell:</strong> {{ player.avatar_smell }}</div>
<div class="record-line"><strong>First Words:</strong> "{{ player.first_words }}"</div>
<div class="record-line"><strong>Good at Math?</strong> {{ player.good_at_math }}</div>
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/edit-basic"
hx-swap="innerHTML"
hx-target="#game-view"
class="btn btn-secondary btn-small margin-top">Edit Records</button>
</div>
{% else %}
<!-- Form View -->
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/save-basic" hx-swap="none" class="creation-form">
<div class="form-group">
<label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row">
<input type="text" name="avatar_look" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" value="{{ player.avatar_look }}" required class="input-field">
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('avatar_look').value = getSuggestion('look');">Suggest</button>
</div>
</div>
<div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
<div class="input-row">
<input type="text" name="avatar_smell" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" value="{{ player.avatar_smell }}" required class="input-field">
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('avatar_smell').value = getSuggestion('smell');">Suggest</button>
</div>
</div>
<div class="form-group">
<label for="first_words">What were your first words after transforming?</label>
<div class="input-row">
<input type="text" name="first_words" id="first_words" placeholder="e.g. Where is my gat?!" value="{{ player.first_words }}" required class="input-field">
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('first_words').value = getSuggestion('first_words');">Suggest</button>
</div>
</div>
<div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row">
<input type="text" name="good_at_math" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" value="{{ player.good_at_math }}" required class="input-field">
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('good_at_math').value = getSuggestion('good_at_math');">Suggest</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-full">Save Records</button>
</form>
{% endif %}
</div>
<!-- SECTION 2: Delegated Questions (Like/Hate) -->
<div class="card glass-panel section-delegations">
<div class="card-header-flex" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; border-bottom:1px solid rgba(212,175,55,0.2); padding-bottom:0.5rem;">
<h3 style="border:none; margin:0; padding:0; color:var(--gold); font-family:var(--font-heading);">II. Crew Delegations</h3>
{% if not player.other_like_from_player_id or not player.other_hate_from_player_id %}
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/delegate/auto"
hx-target="#game-view"
hx-swap="innerHTML"
class="btn btn-secondary btn-small">
🎲 Auto-Assign Tasks
</button>
{% endif %}
</div>
<p class="section-desc">You must let other players decide what they like and hate about your character.</p>
<div class="delegation-list">
<!-- WHAT OTHERS LIKE -->
{% set question_type = 'like' %}
{% include 'delegation_snippet.html' %}
<!-- WHAT OTHERS HATE -->
{% set question_type = 'hate' %}
{% include 'delegation_snippet.html' %}
</div>
</div>
<!-- SECTION 3: Answer Delegations for Others -->
{% include 'inbox_snippet.html' %}
<!-- SECTION 4: Techniques (Depends on Phase) -->
{% include 'techniques_snippet.html' %}
<!-- SECTION 5: Crew Creation Progress -->
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
<h3>V. Crew Creation Status</h3>
<p class="section-desc">See the creation progress of the entire crew in real time.</p>
<div class="crew-status-list" id="crew-status-list"
hx-get="/game/{{ game.id }}/player/{{ player.id }}/crew-status"
hx-trigger="every 3s"
hx-swap="innerHTML">
{% include 'crew_status_snippet.html' %}
</div>
</div>
</div>
</div>
<!-- Hidden phase-check trigger -->
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
hx-trigger="every 2s"
hx-swap="none"
style="display:none;"></div>

View File

@@ -0,0 +1,76 @@
<div class="crew-status-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; width: 100%;">
{% for p in game.players %}
{% set created_techs = json_loads(p.created_techniques) %}
{% set has_submitted_techs = created_techs|length == 3 and created_techs[0] %}
<div class="player-status-card glass-panel {% if p.id == player.id %}current-user-status{% endif %}"
style="padding: 1.25rem; border-left: 4px solid {% if p.id == player.id %}var(--neon-cyan){% else %}rgba(255,255,255,0.15){% endif %}; background: rgba(7, 11, 18, 0.5); border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.2);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 0.5rem;">
<strong style="color: var(--gold); font-size: 1.1rem; font-family: var(--font-heading);">
🐀 {{ p.name }} {% if p.id == player.id %}(You){% endif %}
</strong>
<span class="badge" style="font-size: 0.8rem; background: rgba(212,175,55,0.1); border: 1px solid rgba(212,175,55,0.2); padding: 0.15rem 0.4rem; border-radius: 4px; color: var(--gold); font-weight: 700;">
Rank {{ p.rank }}
</span>
</div>
<div class="status-details" style="font-size: 0.9rem; display: flex; flex-direction: column; gap: 0.5rem;">
<!-- Basic Records -->
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--text-muted);">Basic Records:</span>
{% if p.avatar_look and p.avatar_smell and p.first_words %}
<span style="color: var(--neon-emerald); font-weight: 700;">✅ Complete</span>
{% else %}
<span style="color: var(--text-muted); font-style: italic;">✍️ Writing...</span>
{% endif %}
</div>
<!-- Delegations -->
<div style="border-top: 1px dashed rgba(255,255,255,0.05); padding-top: 0.4rem;">
<span style="color: var(--text-muted); font-weight: 700; display: block; margin-bottom: 0.2rem;">Delegated Tasks:</span>
<div style="padding-left: 0.5rem; font-size: 0.85rem; display: flex; flex-direction: column; gap: 0.25rem;">
<div style="display: flex; justify-content: space-between;">
<span>• Like:</span>
{% if p.other_like %}
<span style="color: var(--neon-emerald);">Done ({{ get_player_name(p.other_like_from_player_id) }})</span>
{% elif p.other_like_from_player_id %}
<span style="color: var(--gold);">Waiting on {{ get_player_name(p.other_like_from_player_id) }}</span>
{% else %}
<span style="color: var(--text-muted); font-style: italic;">Not delegated</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between;">
<span>• Hate:</span>
{% if p.other_hate %}
<span style="color: var(--neon-emerald);">Done ({{ get_player_name(p.other_hate_from_player_id) }})</span>
{% elif p.other_hate_from_player_id %}
<span style="color: var(--gold);">Waiting on {{ get_player_name(p.other_hate_from_player_id) }}</span>
{% else %}
<span style="color: var(--text-muted); font-style: italic;">Not delegated</span>
{% endif %}
</div>
</div>
</div>
<!-- Secret Techniques -->
<div style="border-top: 1px dashed rgba(255,255,255,0.05); padding-top: 0.4rem; display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--text-muted); font-weight: 700;">Secret Techniques:</span>
{% if game.phase == "character_creation" %}
{% if has_submitted_techs %}
<span style="color: var(--neon-emerald); font-weight: 700;">✅ Submitted</span>
{% else %}
<span style="color: var(--text-muted); font-style: italic;">✍️ Writing...</span>
{% endif %}
{% else %}
<!-- swap_techniques phase -->
{% if p.is_ready %}
<span style="color: var(--neon-emerald); font-weight: 700;">✅ J/Q/K Assigned</span>
{% else %}
<span style="color: var(--text-muted); font-style: italic;">✍️ Assigning...</span>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Game #{{ game.id[:8] }} - {% if player %}{{ player.name }}{% else %}Lobby{% endif %}{% endblock %}
{% block header_extra %}
<div class="header-status">
{% if player %}
<span class="player-pill"><span class="bullet"></span> {{ player.name }} (Rank {{ player.rank }})</span>
{% endif %}
<span class="phase-pill">{{ game.phase.replace('_', ' ').title() }}</span>
</div>
{% endblock %}
{% block content %}
<!-- Main game container that polls the server for phase changes and state updates -->
<div id="game-view"
hx-get="/game/{{ game.id }}/player/{{ player.id }}/view"
hx-trigger="load, phase-changed from:body"
hx-swap="innerHTML scroll:top">
<div class="loader-container text-center">
<div class="spinner"></div>
<p>Contacting the math-magic spirits...</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% if question_type == 'like' %}
{% set ans = player.other_like %}
{% set delegate_id = player.other_like_from_player_id %}
{% else %}
{% set ans = player.other_hate %}
{% set delegate_id = player.other_hate_from_player_id %}
{% endif %}
<div class="delegation-box glass-panel" id="delegation-{{ question_type }}"
{% if not ans and delegate_id %}
hx-get="/game/{{ game.id }}/player/{{ player.id }}/delegation/{{ question_type }}"
hx-trigger="every 4s"
hx-swap="outerHTML"
{% endif %}>
<h4>What other pirates {{ question_type.upper() }} about you:</h4>
{% if ans %}
<div class="answer-box">"{{ ans }}" <span class="author-label">— answered by {{ get_player_name(delegate_id) }}</span></div>
<!-- Button to allow revoking even after answered if they want to override or reassign -->
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/revoke-delegate/{{ question_type }}"
hx-target="#delegation-{{ question_type }}"
hx-swap="outerHTML"
class="btn btn-secondary btn-small margin-top-small">Reassign Task</button>
{% elif delegate_id %}
<div class="waiting-box-container">
<div class="waiting-box">
<span class="spinner-small"></span>
Delegated to <strong>{{ get_player_name(delegate_id) }}</strong>. Waiting...
</div>
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/revoke-delegate/{{ question_type }}"
hx-target="#delegation-{{ question_type }}"
hx-swap="outerHTML"
class="btn btn-danger btn-small margin-top-small">Revoke Task</button>
</div>
{% else %}
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/delegate/{{ question_type }}"
hx-target="#delegation-{{ question_type }}"
hx-swap="outerHTML"
class="delegate-form inline-form">
<select name="target_player_id" class="select-field select-small" required>
<option value="" disabled selected>Select a crewmate...</option>
{% for other in game.players if other.id != player.id %}
<option value="{{ other.id }}">{{ other.name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-secondary btn-small">Ask them</button>
</form>
{% endif %}
</div>

View File

@@ -0,0 +1,41 @@
<div class="card glass-panel section-inbox" id="inbox-card">
<h3>III. Your Tasks for Others</h3>
<p class="section-desc">Answer questions that your crewmates have delegated to you.</p>
<div class="inbox-list">
{% if inbox_tasks %}
{% for task in inbox_tasks %}
{% set p = task.player %}
{% if task.type == 'like' %}
<div class="inbox-item glass-panel">
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-delegate/{{ p.id }}/like"
hx-target="#inbox-card"
hx-swap="outerHTML">
<label>What do you <strong>LIKE</strong> about <strong>{{ p.name }}</strong>?</label>
<div class="input-row">
<input type="text" name="answer" id="inbox-like-{{ p.id }}" placeholder="e.g. He always shares his cheese" required class="input-field">
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('inbox-like-{{ p.id }}').value = getSuggestion('like');">Suggest</button>
<button type="submit" class="btn btn-primary btn-small">Submit</button>
</div>
</form>
</div>
{% else %}
<div class="inbox-item glass-panel">
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-delegate/{{ p.id }}/hate"
hx-target="#inbox-card"
hx-swap="outerHTML">
<label>What do you <strong>HATE</strong> about <strong>{{ p.name }}</strong>?</label>
<div class="input-row">
<input type="text" name="answer" id="inbox-hate-{{ p.id }}" placeholder="e.g. He chews his tail when nervous" required class="input-field">
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('inbox-hate-{{ p.id }}').value = getSuggestion('hate');">Suggest</button>
<button type="submit" class="btn btn-primary btn-small">Submit</button>
</div>
</form>
</div>
{% endif %}
{% endfor %}
{% else %}
<p class="empty-text text-center">No tasks in your inbox. Check back when other players delegate to you!</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Welcome - Rats with Gats{% endblock %}
{% block content %}
<div class="welcome-container glass-panel">
<div class="welcome-hero text-center">
<h2>Steal Ships. Commit Piracy. Run on Math Magic.</h2>
<p class="subtitle">A collaborative storytelling web-app to facilitate remote play of the whimsical roleplaying game.</p>
</div>
<div class="welcome-actions">
<div class="action-card glass-panel text-center">
<h3>Start a New Adventure</h3>
<p>Generate a fresh game deck and take command as the crew creator.</p>
<form action="/game/create" method="POST">
<button type="submit" class="btn btn-primary btn-large glow-effect">Create Game</button>
</form>
</div>
<div class="action-card glass-panel text-center">
<h3>Join an Existing Crew</h3>
<p>Enter a Game ID or Join Link provided by your Captain.</p>
<form onsubmit="event.preventDefault(); window.location.href = '/game/' + document.getElementById('game-id-input').value.trim() + '/join';">
<div class="form-group">
<input type="text" id="game-id-input" placeholder="e.g. 550e8400-e29b-41d4-a716-446655440000" required class="input-field text-center">
</div>
<button type="submit" class="btn btn-secondary btn-large">Join Game</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Join Crew - Rats with Gats{% endblock %}
{% block content %}
<div class="join-container glass-panel text-center">
<h2>Join the Pi-Rat Crew!</h2>
<p>The mathematical transformation spell is about to cast. Enter your name below to stow away in the cargo hold.</p>
<form action="/game/{{ game.id }}/join" method="POST" class="join-form">
<div class="form-group">
<label for="name-input">Your Stowaway Name:</label>
<input type="text" id="name-input" name="name" placeholder="e.g. Whisker-Face" required class="input-field text-center" autofocus>
</div>
<button type="submit" class="btn btn-primary btn-large glow-effect">Transform into a Pi-Rat!</button>
</form>
<div class="back-link">
<a href="/">Back to Main Menu</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,66 @@
<div class="lobby-view text-center">
<h2>Ship's Hold (Lobby)</h2>
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
<div class="lobby-status-box glass-panel">
<h3>Joined Crew members</h3>
<div class="player-chips" id="lobby-player-list"
hx-get="/game/{{ game.id }}/lobby/players?player_id={{ player.id }}"
hx-trigger="every 2s"
hx-swap="innerHTML">
{% for p in game.players %}
<div class="player-chip {% if p.id == player.id %}current-user{% endif %}">
<span class="avatar-icon">🐀</span>
<span class="name">{{ p.name }}</span>
{% if p.is_creator %}<span class="creator-badge">Creator</span>{% endif %}
</div>
{% endfor %}
</div>
</div>
<div class="links-box glass-panel">
<div class="link-item">
<h4>Share Join Link:</h4>
<div class="copy-container">
<input type="text" readonly value="{{ base_url }}/game/{{ game.id }}/join" class="copy-input" id="join-link-copy">
<button onclick="navigator.clipboard.writeText(document.getElementById('join-link-copy').value); alert('Join link copied!');" class="btn btn-secondary btn-small">Copy</button>
</div>
</div>
{% if player.is_creator %}
<div class="link-item admin-link-item">
<h4 class="gold-text">⚠️ Creator Admin Magic Link:</h4>
<p class="info-text">Save this admin link! You can use it to recover character sheets if another player loses their connection or link.</p>
<div class="copy-container">
<input type="text" readonly value="{{ base_url }}/game/{{ game.id }}/admin?key={{ game.admin_key }}" class="copy-input admin-input" id="admin-link-copy">
<button onclick="navigator.clipboard.writeText(document.getElementById('admin-link-copy').value); alert('Admin link copied!');" class="btn btn-gold btn-small">Copy Admin Link</button>
</div>
</div>
{% endif %}
</div>
<div class="lobby-action">
{% if player.is_creator %}
{% if game.players|length >= 1 %}
<button hx-post="/game/{{ game.id }}/lobby/start"
hx-swap="none"
class="btn btn-primary btn-large glow-effect">
Start Character Creation
</button>
{% else %}
<button class="btn btn-primary btn-large" disabled>Waiting for players...</button>
{% endif %}
{% else %}
<div class="waiting-indicator">
<div class="spinner-small"></div>
<p>Waiting for Captain to cast the spell...</p>
</div>
{% endif %}
</div>
</div>
<!-- Hidden phase-check trigger -->
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
hx-trigger="every 2s"
hx-swap="none"
style="display:none;"></div>

View File

@@ -0,0 +1,7 @@
{% for p in game.players %}
<div class="player-chip {% if p.id == player_id %}current-user{% endif %}">
<span class="avatar-icon">🐀</span>
<span class="name">{{ p.name }}</span>
{% if p.is_creator %}<span class="creator-badge">Creator</span>{% endif %}
</div>
{% endfor %}

View File

@@ -0,0 +1,63 @@
<div class="challenges-container" id="scene-challenges-container"
hx-get="/game/{{ game.id }}/scene/challenges?player_id={{ player.id }}"
hx-trigger="every 4s"
hx-swap="outerHTML">
{% for chal in game.challenges if chal.is_active %}
{% set applied_ids = json_loads(chal.applied_obstacle_ids) %}
<div class="challenge-item">
<h4>{{ chal.title }}</h4>
{% if chal.description %}
<p class="desc">{{ chal.description }}</p>
{% endif %}
<div class="applied-obstacles">
<h5>Applied Obstacles to beat:</h5>
{% for obs_id in applied_ids %}
{% set obs = get_obstacle_by_id(obs_id) %}
{% if obs %}
<div class="applied-obs-row glass-panel">
<div class="obs-info">
<span class="symbol suit-{{ obs.suit.lower() }}">{{ obs.symbol }}</span>
<strong>{{ obs.title }}</strong> (Difficulty:
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
Rank ({{ player.rank }})
{% else %}
{{ obs.current_value }}
{% endif %})
</div>
<!-- Play Card Form (Only for Pi-Rats) -->
{% if player.role == "pirat" %}
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/play-card"
hx-swap="none"
class="play-card-form inline-form">
<input type="hidden" name="challenge_id" value="{{ chal.id }}">
<input type="hidden" name="obstacle_id" value="{{ obs.id }}">
<select name="card_code" class="select-field select-small" required>
<option value="" disabled selected>Play card...</option>
{% for card in hand %}
{% set parsed = parse_card(card) %}
<option value="{{ card }}">
{{ parsed.display }}
{% if parsed.value == "J" %} - (Secret Tech: J)
{% elif parsed.value == "Q" %} - (Secret Tech: Q)
{% elif parsed.value == "K" %} - (Secret Tech: K)
{% endif %}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary btn-small">Fight</button>
</form>
{% else %}
<span class="sub-label italic">Waiting for Pi-Rats to resolve</span>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% else %}
<p class="empty-text text-center">No active challenges. Deep players should describe the scene and call for challenges!</p>
{% endfor %}
</div>

View File

@@ -0,0 +1,46 @@
{% for obs in game.obstacles %}
<div class="obstacle-item suit-{{ obs.suit.lower() }}">
<div class="obstacle-meta">
<span class="suit-badge">{{ obs.symbol }} {{ obs.suit.upper() }}</span>
<span class="card-code">{{ obs.original_card }}</span>
</div>
<div class="obstacle-details">
<h4>{{ obs.title }}</h4>
<p class="desc">{{ obs.description }}</p>
</div>
<!-- Value Display -->
<div class="obstacle-value-display text-center">
<span class="val-label">Current Difficulty</span>
<span class="val-number">
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
Rank ({{ player_rank }})
{% else %}
{{ obs.current_value }}
{% endif %}
</span>
</div>
<!-- Played Cards Column -->
<div class="played-column">
<h5>Card History (Column)</h5>
{% set column_cards = json_loads(obs.played_cards) %}
<div class="column-cards-flex">
<div class="card-mini base-card">
<span class="val">{{ obs.original_card[:-1] }}</span>
<span class="suit">{{ obs.symbol }}</span>
</div>
{% for pc in column_cards %}
<div class="card-mini played-card rotated {% if pc.success %}success{% else %}failure{% endif %}"
title="{{ pc.player_name }} played {{ pc.card }}: {{ pc.details }}">
<span class="val">{{ pc.card[:-1] if not pc.card.startswith('Joker') else '🃏' }}</span>
<span class="suit">{{ parse_card(pc.card).symbol }}</span>
<span class="owner">{{ pc.player_name[:4] }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
{% endfor %}

View File

@@ -0,0 +1,318 @@
<div class="scene-view-layout">
<!-- LEFT COLUMN: The Shared Table -->
<div class="table-column">
<!-- Game Deck and Obstacles Roster -->
<div class="card glass-panel obstacle-list-card">
<div class="card-header">
<h3>🌊 The Obstacle List</h3>
<span class="deck-counter">🎴 Deck: {{ deck_count }} cards left</span>
</div>
<div class="obstacles-container" id="scene-obstacles-container"
hx-get="/game/{{ game.id }}/scene/obstacles?player_rank={{ player.rank }}"
hx-trigger="every 2s"
hx-swap="innerHTML">
{% for obs in game.obstacles %}
<div class="obstacle-item suit-{{ obs.suit.lower() }}">
<div class="obstacle-meta">
<span class="suit-badge">{{ obs.symbol }} {{ obs.suit.upper() }}</span>
<span class="card-code">{{ obs.original_card }}</span>
</div>
<div class="obstacle-details">
<h4>{{ obs.title }}</h4>
<p class="desc">{{ obs.description }}</p>
</div>
<!-- Value Display -->
<div class="obstacle-value-display text-center">
<span class="val-label">Current Difficulty</span>
<span class="val-number">
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
Rank ({{ player.rank }})
{% else %}
{{ obs.current_value }}
{% endif %}
</span>
</div>
<!-- Played Cards Column -->
<div class="played-column">
<h5>Card History (Column)</h5>
{% set column_cards = json_loads(obs.played_cards) %}
<div class="column-cards-flex">
<div class="card-mini base-card">
<span class="val">{{ obs.original_card[:-1] }}</span>
<span class="suit">{{ obs.symbol }}</span>
</div>
{% for pc in column_cards %}
<div class="card-mini played-card rotated {% if pc.success %}success{% else %}failure{% endif %}"
title="{{ pc.player_name }} played {{ pc.card }}: {{ pc.details }}">
<span class="val">{{ pc.card[:-1] if not pc.card.startswith('Joker') else '🃏' }}</span>
<span class="suit">{{ parse_card(pc.card).symbol }}</span>
<span class="owner">{{ pc.player_name[:4] }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
{% endfor %}
</div>
</div>
<!-- Challenges List -->
<div class="card glass-panel challenges-card">
<h3>⚔️ Active Challenges</h3>
<div class="challenges-container" id="scene-challenges-container"
hx-get="/game/{{ game.id }}/scene/challenges?player_id={{ player.id }}"
hx-trigger="every 4s"
hx-swap="outerHTML">
{% for chal in game.challenges if chal.is_active %}
{% set applied_ids = json_loads(chal.applied_obstacle_ids) %}
<div class="challenge-item">
<h4>{{ chal.title }}</h4>
{% if chal.description %}
<p class="desc">{{ chal.description }}</p>
{% endif %}
<div class="applied-obstacles">
<h5>Applied Obstacles to beat (Success on any, but failures carry complications!):</h5>
{% for obs_id in applied_ids %}
{% set obs = get_obstacle_by_id(obs_id) %}
{% if obs %}
<div class="applied-obs-row glass-panel">
<div class="obs-info">
<span class="symbol suit-{{ obs.suit.lower() }}">{{ obs.symbol }}</span>
<strong>{{ obs.title }}</strong> (Difficulty:
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
Rank ({{ player.rank }})
{% else %}
{{ obs.current_value }}
{% endif %})
</div>
<!-- Play Card Form (Only for Pi-Rats) -->
{% if player.role == "pirat" %}
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/play-card"
hx-swap="none"
class="play-card-form inline-form">
<input type="hidden" name="challenge_id" value="{{ chal.id }}">
<input type="hidden" name="obstacle_id" value="{{ obs.id }}">
<select name="card_code" class="select-field select-small" required>
<option value="" disabled selected>Play card...</option>
{% for card in hand %}
{% set parsed = parse_card(card) %}
<option value="{{ card }}">
{{ parsed.display }}
{% if parsed.value == "J" %} - (Secret Tech: J)
{% elif parsed.value == "Q" %} - (Secret Tech: Q)
{% elif parsed.value == "K" %} - (Secret Tech: K)
{% endif %}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary btn-small">Fight</button>
</form>
{% else %}
<span class="sub-label italic">Waiting for Pi-Rats to resolve</span>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% else %}
<p class="empty-text text-center">No active challenges. Deep players should describe the scene and call for challenges!</p>
{% endfor %}
</div>
</div>
<!-- DEEP CONTROLS: Create Challenges and End Scene -->
{% if player.role == "deep" %}
<div class="card glass-panel deep-panel-card">
<h3 class="deep-text">🌊 Deep Master Control Panel</h3>
<!-- Challenge Creator -->
<form hx-post="/game/{{ game.id }}/challenge/create" hx-swap="none" class="challenge-creator-form">
<h4>1. Call for a New Challenge:</h4>
<div class="form-group">
<label for="chal-title">Challenge Title:</label>
<input type="text" id="chal-title" name="title" placeholder="e.g. Steal the cell keys from the sleeping sailor" required class="input-field">
</div>
<div class="form-group">
<label for="chal-desc">Narration Context (Optional):</label>
<input type="text" id="chal-desc" name="description" placeholder="e.g. You need to sneak past without creaking the floorboard." class="input-field">
</div>
<div class="form-group">
<label>2. Select Applied Obstacles (Must select at least 1):</label>
<div class="obstacles-checkboxes">
{% for obs in game.obstacles %}
<label class="checkbox-label">
<input type="checkbox" name="obstacle_ids" value="{{ obs.id }}">
<span>{{ obs.symbol }} {{ obs.title }} (Difficulty: {{ obs.current_value }})</span>
</label>
{% endfor %}
</div>
</div>
<button type="submit" class="btn btn-deep btn-full glow-effect">Spawn Challenge</button>
</form>
<hr class="deep-divider">
<!-- End Scene Button -->
<div class="scene-upkeep-controls text-center">
<h4>3. End the Current Scene:</h4>
<p class="info-text">Deep players can conclude the scene once players have made attempts or the obstacle list is depleted.</p>
<button hx-post="/game/{{ game.id }}/scene/end"
hx-swap="none"
class="btn btn-danger btn-large">
End Scene & Proceed to Ranking
</button>
</div>
</div>
{% endif %}
</div>
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
<div class="private-column">
<!-- Player Hand Card -->
<div class="card glass-panel hand-card">
<h3>🃏 Your Hand</h3>
<p class="section-desc">Keep your cards secret! Max hand size: {{ max_hand_size }} cards. {% if is_captain(player, game.players) %} (Includes +1 Captain bonus) {% endif %}</p>
<div class="hand-flex">
{% for card in hand %}
{% set parsed = parse_card(card) %}
<div class="card-large suit-{{ parsed.suit.lower() }} {% if parsed.is_joker %}joker-card{% endif %}">
<div class="card-corner top-left">
<span class="val">{{ parsed.value }}</span>
<span class="suit">{{ parsed.symbol }}</span>
</div>
<div class="card-center">
{% if parsed.is_joker %}
🃏
{% else %}
<span class="giant-symbol">{{ parsed.symbol }}</span>
{% endif %}
</div>
<!-- Technique Helper on Card Face -->
<div class="card-tech-overlay">
{% if parsed.value == "J" %}
<div class="tech-tag" title="Automatic success when played">J: "{{ player.tech_jack }}"</div>
{% elif parsed.value == "Q" %}
<div class="tech-tag" title="Automatic success when played">Q: "{{ player.tech_queen }}"</div>
{% elif parsed.value == "K" %}
<div class="tech-tag" title="Automatic success when played">K: "{{ player.tech_king }}"</div>
{% elif parsed.is_joker %}
<div class="joker-action">
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/play-joker" hx-swap="none" class="inline-form">
<input type="hidden" name="card_code" value="{{ card }}">
<select name="obstacle_id" class="select-field select-xsmall" required>
<option value="" disabled selected>Discard Obstacle...</option>
{% for obs in game.obstacles %}
<option value="{{ obs.id }}">{{ obs.title }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-small btn-primary">Play</button>
</form>
</div>
{% endif %}
</div>
<div class="card-corner bottom-right">
<span class="val">{{ parsed.value }}</span>
<span class="suit">{{ parsed.symbol }}</span>
</div>
</div>
{% else %}
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
{% endfor %}
</div>
</div>
<!-- Player Character Sheet Card -->
<div class="card glass-panel sheet-card">
<h3>🐀 Your Character sheet</h3>
<div class="character-sheet-details">
<div class="sheet-group">
<h4>Description:</h4>
<p><strong>Look:</strong> {{ player.avatar_look }}</p>
<p><strong>Smell:</strong> {{ player.avatar_smell }}</p>
<p><strong>First Words:</strong> "{{ player.first_words }}"</p>
<p><strong>Likes:</strong> "{{ player.other_like }}" <span class="footnote">(by {{ get_player_name(player.other_like_from_player_id) }})</span></p>
<p><strong>Hates:</strong> "{{ player.other_hate }}" <span class="footnote">(by {{ get_player_name(player.other_hate_from_player_id) }})</span></p>
</div>
<div class="sheet-group margin-top">
<h4>Assigned Secret Techniques (J/Q/K):</h4>
<ul class="techniques-list-sheet">
<li><strong>Jack (J):</strong> "{{ player.tech_jack }}"</li>
<li><strong>Queen (Q):</strong> "{{ player.tech_queen }}"</li>
<li><strong>King (K):</strong> "{{ player.tech_king }}"</li>
</ul>
</div>
<div class="sheet-group margin-top">
<h4>Objectives Tracker:</h4>
<p class="info-text">Toggle your objectives as you complete them to earn Ranks and unlock privileges.</p>
<div class="objectives-checklist">
<!-- Personal 1 -->
<label class="checkbox-label">
<input type="checkbox"
hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=personal_1"
hx-trigger="click"
hx-swap="none"
{% if player.completed_personal_1 %}checked{% endif %}>
<span>🔫 Personal 1: Get a Gat</span>
</label>
<div class="footnote-desc">
Privilege: +1 to Black cards (Clubs/Spades). Tax: Can ask a Gatted player to do challenges.
</div>
<!-- Personal 2 -->
<label class="checkbox-label margin-top-small">
<input type="checkbox"
hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=personal_2"
hx-trigger="click"
hx-swap="none"
{% if player.completed_personal_2 %}checked{% endif %}>
<span>👑 Personal 2: Earn a Name</span>
</label>
<div class="footnote-desc">
Privilege: +1 to Red cards (Hearts/Diamonds). Tax: Can ask a Named player to do challenges.
</div>
<!-- Personal 3 -->
<label class="checkbox-label margin-top-small">
<input type="checkbox"
hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=personal_3"
hx-trigger="click"
hx-swap="none"
{% if player.completed_personal_3 %}checked{% endif %}>
<span>☠️ Personal 3: Die Like a Pirate (or Retire)</span>
</label>
<div class="footnote-desc">
Bonus: Rank up another player.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Hidden phase-check trigger -->
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
hx-trigger="every 2s"
hx-swap="none"
style="display:none;"></div>

View File

@@ -0,0 +1,27 @@
<div class="roster-section">
<h4>🌊 The Deep (NPCs & Hazards)</h4>
<div class="player-chips list-chips">
{% for p in game.players if p.role == 'deep' %}
<div class="player-chip deep-chip">
<span class="name">{{ p.name }}</span>
<span class="sub-label">Rank {{ p.rank }}</span>
</div>
{% else %}
<p class="empty-text">None selected</p>
{% endfor %}
</div>
</div>
<div class="roster-section margin-top">
<h4>🐀 Pi-Rats (Crew Players)</h4>
<div class="player-chips list-chips">
{% for p in game.players if p.role == 'pirat' %}
<div class="player-chip pirat-chip">
<span class="name">{{ p.name }}</span>
<span class="sub-label">Rank {{ p.rank }}{% if is_captain(p, game.players) %} (Captain) {% endif %}</span>
</div>
{% else %}
<p class="empty-text">None selected</p>
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,117 @@
<div class="scene-setup-view text-center">
<h2>Scene Setup (Scene #{{ game.current_scene_number }})</h2>
<p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p>
<div class="setup-grid">
<!-- Role Selection Card -->
<div class="card glass-panel role-selection-card">
<h3>Select Your Role</h3>
{% set force_pirat = False %}
{% set locked_role = None %}
<!-- First scene rules -->
{% if game.current_scene_number == 1 %}
{% if player.rank == 3 %}
{% set locked_role = "deep" %}
{% elif player.rank == 1 %}
{% set locked_role = "pirat" %}
{% endif %}
<!-- Normal scene rules -->
{% else %}
{% if player.previous_role == "deep" %}
{% set force_pirat = True %}
{% set locked_role = "pirat" %}
{% endif %}
{% endif %}
{% if locked_role %}
<div class="locked-role-box glass-panel text-center">
<p class="info-text">
{% if game.current_scene_number == 1 %}
Based on starting Ranks, you are locked into:
{% else %}
You were Deep in the previous scene! You are locked into:
{% endif %}
</p>
<div class="role-badge large-badge {{ locked_role }}">
{{ locked_role.upper() }}
</div>
<!-- Silently auto-select this role in the database using HTMX onload if not already set -->
<div hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role={{ locked_role }}" hx-trigger="load" style="display:none;"></div>
</div>
{% else %}
<div class="role-buttons">
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role=pirat"
hx-swap="none"
class="btn btn-large role-btn pirat-btn {% if player.role == 'pirat' %}active{% endif %}">
🐀 Play Pi-Rat
</button>
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role=deep"
hx-swap="none"
class="btn btn-large role-btn deep-btn {% if player.role == 'deep' %}active{% endif %}">
🌊 Play the Deep
</button>
</div>
{% endif %}
</div>
<!-- Live Roster Card -->
<div class="card glass-panel roster-card">
<h3>Live Roster Selection</h3>
<div class="roster-list" id="scene-roster-list"
hx-get="/game/{{ game.id }}/scene/roster"
hx-trigger="every 2s"
hx-swap="innerHTML">
<div class="roster-section">
<h4>🌊 The Deep (NPCs & Hazards)</h4>
<div class="player-chips list-chips">
{% for p in game.players if p.role == 'deep' %}
<div class="player-chip deep-chip">
<span class="name">{{ p.name }}</span>
<span class="sub-label">Rank {{ p.rank }}</span>
</div>
{% else %}
<p class="empty-text">None selected</p>
{% endfor %}
</div>
</div>
<div class="roster-section margin-top">
<h4>🐀 Pi-Rats (Crew Players)</h4>
<div class="player-chips list-chips">
{% for p in game.players if p.role == 'pirat' %}
<div class="player-chip pirat-chip">
<span class="name">{{ p.name }}</span>
<span class="sub-label">Rank {{ p.rank }}{% if is_captain(p, game.players) %} (Captain) {% endif %}</span>
</div>
{% else %}
<p class="empty-text">None selected</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<div class="action-box margin-top">
{% if error_msg %}
<div class="alert alert-danger">{{ error_msg }}</div>
{% endif %}
<!-- Any player can attempt to start the scene, but validation will run -->
<button hx-post="/game/{{ game.id }}/scene/start"
hx-swap="innerHTML"
hx-target="#game-view"
class="btn btn-primary btn-large glow-effect">
Confirm Roles & Shufle Deck
</button>
</div>
</div>
<!-- Hidden phase-check trigger -->
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
hx-trigger="every 2s"
hx-swap="none"
style="display:none;"></div>

View File

@@ -0,0 +1,99 @@
<div class="card glass-panel section-techniques" id="techniques-card">
{% if game.phase == "character_creation" %}
<h3>IV. Create 3 Secret Techniques</h3>
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
{% set created_techs = json_loads(player.created_techniques) %}
{% if created_techs|length == 3 and created_techs[0] %}
<div class="submitted-techniques text-center">
<div class="tech-chip">#1: {{ created_techs[0] }}</div>
<div class="tech-chip">#2: {{ created_techs[1] }}</div>
<div class="tech-chip">#3: {{ created_techs[2] }}</div>
<p class="waiting-box margin-top">
<span class="spinner-small"></span>
Techniques submitted! Waiting for other players to submit so we can shuffle and swap them.
</p>
</div>
{% else %}
<!-- Creation Form -->
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-techniques"
hx-target="#techniques-card"
hx-swap="outerHTML"
id="tech-form">
<div class="form-group">
<label for="tech1">Technique #1:</label>
<div class="input-row">
<input type="text" name="tech1" id="tech1" placeholder="e.g. Carlo's cheese shield" required class="input-field">
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('tech1').value = getSuggestion('techniques');">Suggest</button>
</div>
</div>
<div class="form-group">
<label for="tech2">Technique #2:</label>
<div class="input-row">
<input type="text" name="tech2" id="tech2" placeholder="e.g. Parabolic boarding bounce" required class="input-field">
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('tech2').value = getSuggestion('techniques');">Suggest</button>
</div>
</div>
<div class="form-group">
<label for="tech3">Technique #3:</label>
<div class="input-row">
<input type="text" name="tech3" id="tech3" placeholder="e.g. Look, a three-headed gull!" required class="input-field">
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('tech3').value = getSuggestion('techniques');">Suggest</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-full glow-effect">Submit Techniques to Swap Pool</button>
</form>
{% endif %}
{% elif game.phase == "swap_techniques" %}
<h3>IV. Assign Swapped Secret Techniques</h3>
<p class="section-desc">You received these techniques from your crew. Assign one to Jack, Queen, and King cards.</p>
{% set swapped_techs = json_loads(player.swapped_techniques) %}
{% if player.is_ready %}
<div class="assigned-techs text-center">
<div class="tech-assignment-pill"><span class="card-rank">J</span> {{ player.tech_jack }}</div>
<div class="tech-assignment-pill"><span class="card-rank">Q</span> {{ player.tech_queen }}</div>
<div class="tech-assignment-pill"><span class="card-rank">K</span> {{ player.tech_king }}</div>
<p class="waiting-box margin-top">
<span class="spinner-small"></span>
Ready! Waiting for other players to finish J/Q/K assignment...
</p>
</div>
{% else %}
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/assign-face-techniques"
hx-target="#techniques-card"
hx-swap="outerHTML">
<div class="form-group">
<label for="tech_j">Jack (J) Secret Technique:</label>
<select name="jack" id="tech_j" class="select-field" required>
<option value="" disabled selected>Choose technique...</option>
{% for t in swapped_techs %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="tech_q">Queen (Q) Secret Technique:</label>
<select name="queen" id="tech_q" class="select-field" required>
<option value="" disabled selected>Choose technique...</option>
{% for t in swapped_techs %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="tech_k">King (K) Secret Technique:</label>
<select name="king" id="tech_k" class="select-field" required>
<option value="" disabled selected>Choose technique...</option>
{% for t in swapped_techs %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-full glow-effect">Confirm Assignments & Ready Up</button>
</form>
{% endif %}
{% endif %}
</div>

View File

@@ -0,0 +1,10 @@
{% for p in game.players %}
{% set voted = False %}
{% for v in game.votes if v.voter_player_id == p.id %}
{% set voted = True %}
{% endfor %}
<div class="player-chip {% if voted %}voted-chip{% else %}not-voted-chip{% endif %}">
<span class="name">{{ p.name }}</span>
<span class="status-badge">{% if voted %}Voted{% else %}Voting...{% endif %}</span>
</div>
{% endfor %}

229
tests/test_game.py Normal file
View File

@@ -0,0 +1,229 @@
import pytest
import json
from sqlmodel import SQLModel, create_engine, Session
from pirats import cards
from pirats import crud
from pirats.models import Game, Player, Obstacle, Challenge
# In-memory database for testing
@pytest.fixture(name="session")
def session_fixture():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
def test_card_parsing():
c_10d = cards.parse_card("10D")
assert c_10d["value"] == "10"
assert c_10d["suit"] == "D"
assert c_10d["color"] == "red"
assert c_10d["numeric_value"] == 10
assert not c_10d["is_joker"]
c_ac = cards.parse_card("AC")
assert c_ac["value"] == "A"
assert c_ac["suit"] == "C"
assert c_ac["color"] == "black"
assert c_ac["numeric_value"] == 1
c_kh = cards.parse_card("KH")
assert c_kh["value"] == "K"
assert c_kh["numeric_value"] == 13
c_joker = cards.parse_card("Joker1")
assert c_joker["is_joker"]
assert c_joker["color"] == "black"
def test_obstacle_info():
obs = cards.get_obstacle_info("7C")
assert obs["title"] == "Lustful Bean Whale"
assert "bean" in obs["description"].lower()
assert obs["initial_value"] == 7
assert obs["color"] == "black"
def test_game_creation(session):
game = crud.create_game(session)
assert game.id is not None
assert game.phase == "lobby"
assert len(crud.get_game_deck(game)) == 54
def test_character_creation_and_swap(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "Carlos", is_creator=True)
p2 = crud.add_player(session, game.id, "Barry")
p3 = crud.add_player(session, game.id, "Jaspar")
session.refresh(game)
assert len(game.players) == 3
# Fill basic details
p1.avatar_look = "Bandana"
p2.avatar_look = "Eyepatch"
p3.avatar_look = "Pegleg"
session.add_all([p1, p2, p3])
session.commit()
# Submit techniques
crud.submit_techniques(session, p1.id, "Carlos Strike", "Hide", "Roll")
crud.submit_techniques(session, p2.id, "Barry Leap", "Bite", "Scream")
crud.submit_techniques(session, p3.id, "Jaspar Blast", "Shoot", "Run")
session.refresh(game)
# The swap should have automatically triggered because all 3 submitted
assert game.phase == "swap_techniques"
session.refresh(p1)
session.refresh(p2)
session.refresh(p3)
techs_p1 = json.loads(p1.swapped_techniques)
assert len(techs_p1) == 3
# Check that Carlos didn't get any of his own techniques
assert "Carlos Strike" not in techs_p1
# Now assign to face cards
crud.assign_face_card_techniques(session, p1.id, techs_p1[0], techs_p1[1], techs_p1[2])
crud.assign_face_card_techniques(session, p2.id, "T2", "T3", "T4")
crud.assign_face_card_techniques(session, p3.id, "T5", "T6", "T7")
session.refresh(game)
# All are ready, should transition to scene_setup and assign starting ranks!
assert game.phase == "scene_setup"
session.refresh(p1)
session.refresh(p2)
session.refresh(p3)
ranks = [p1.rank, p2.rank, p3.rank]
assert 3 in ranks
assert 1 in ranks
assert 2 in ranks
roles = [p1.role, p2.role, p3.role]
assert "deep" in roles
assert "pirat" in roles
def test_scene_start_and_challenges(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
# Bypass char creation for quick scene test
p1.rank = 3
p1.role = "deep"
p1.previous_role = "deep"
p2.rank = 1
p2.role = "pirat"
p2.previous_role = "pirat"
session.add_all([p1, p2])
session.commit()
# Confirm setup (1 Deep, 1 Pi-Rat)
success, msg = crud.confirm_scene_setup(session, game.id)
assert success
session.refresh(game)
assert game.phase == "scene"
assert len(game.obstacles) == 2 # Deeps (1) + 1 = 2 obstacles
obs_list = game.obstacles
obs = obs_list[0]
# Give p2 a known hand for testing
p2.hand_cards = json.dumps(["10D", "2H", "JS", "Joker1"])
session.add(p2)
session.commit()
# Create challenge
chal = crud.create_challenge(session, game.id, "Challenge 1", "Sneak past", [obs.id])
# Play card: 10D (value 10) vs Obstacle. Let's make sure obstacle value is less than 10.
# Set obstacle value to 5
obs.current_value = 5
session.add(obs)
session.commit()
# Play 10D (red) on obstacle. Let's check original obstacle color.
# If original obstacle was red, we expect a card draw.
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
deck_before = len(crud.get_game_deck(game))
ok, msg, res = crud.play_card_on_obstacle(session, p2.id, obs.id, "10D", chal.id)
assert ok
assert res["success"]
assert "Success" in res["details"]
session.refresh(p2)
session.refresh(obs)
# Obstacle current value should update to 10
assert obs.current_value == 10
# Check if hand size matches card draw rule
# 10D was played (-1 card). If colors matched, drew 1 back (+1 card).
hand = crud.get_player_hand(p2)
if orig_is_red:
assert len(hand) == 4
assert res["drew_card"] is not None
else:
assert len(hand) == 3
assert res["drew_card"] is None
def test_secret_technique_auto_success(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p1.role = "pirat"
p1.rank = 2
p1.tech_jack = "Pocket Sand"
p1.hand_cards = json.dumps(["JS"])
session.add(p1)
obs = Obstacle(
game_id=game.id,
original_card="10C",
suit="C",
title="Knights",
current_value=10
)
session.add(obs)
session.commit()
chal = crud.create_challenge(session, game.id, "C1", "D1", [obs.id])
# Play Jack. J is a face card -> auto success!
ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "JS", chal.id)
assert ok
assert res["success"]
assert res["is_technique"]
assert res["tech_name"] == "Pocket Sand"
def test_joker_play(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p1.role = "pirat"
p1.hand_cards = json.dumps(["Joker1"])
session.add(p1)
obs = Obstacle(
game_id=game.id,
original_card="10C",
suit="C",
title="Knights",
current_value=10
)
session.add(obs)
session.commit()
chal = crud.create_challenge(session, game.id, "C1", "D1", [obs.id])
# Play Joker
ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "Joker1", chal.id)
assert ok
assert res["is_joker"]
# Verify obstacle was deleted and replaced
session.refresh(game)
assert len(game.obstacles) == 1
assert game.obstacles[0].original_card != "10C"