Initial checkin
This commit is contained in:
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal 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
119
flake.nix
Normal 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
35
pyproject.toml
Normal 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
1
src/pirats/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Pirats package
|
||||
203
src/pirats/cards.py
Normal file
203
src/pirats/cards.py
Normal 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
811
src/pirats/crud.py
Normal 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
19
src/pirats/database.py
Normal 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
705
src/pirats/main.py
Normal 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
85
src/pirats/models.py
Normal 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")
|
||||
1337
src/pirats/static/css/style.css
Normal file
1337
src/pirats/static/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
1423
src/pirats/static/js/suggestions.js
Normal file
1423
src/pirats/static/js/suggestions.js
Normal file
File diff suppressed because it is too large
Load Diff
59
src/pirats/templates/admin.html
Normal file
59
src/pirats/templates/admin.html
Normal 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 %}
|
||||
39
src/pirats/templates/base.html
Normal file
39
src/pirats/templates/base.html
Normal 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>
|
||||
105
src/pirats/templates/between_scenes_partial.html
Normal file
105
src/pirats/templates/between_scenes_partial.html
Normal 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>
|
||||
117
src/pirats/templates/character_creation_partial.html
Normal file
117
src/pirats/templates/character_creation_partial.html
Normal 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>
|
||||
76
src/pirats/templates/crew_status_snippet.html
Normal file
76
src/pirats/templates/crew_status_snippet.html
Normal 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>
|
||||
25
src/pirats/templates/dashboard.html
Normal file
25
src/pirats/templates/dashboard.html
Normal 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 %}
|
||||
50
src/pirats/templates/delegation_snippet.html
Normal file
50
src/pirats/templates/delegation_snippet.html
Normal 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>
|
||||
41
src/pirats/templates/inbox_snippet.html
Normal file
41
src/pirats/templates/inbox_snippet.html
Normal 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>
|
||||
33
src/pirats/templates/index.html
Normal file
33
src/pirats/templates/index.html
Normal 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 %}
|
||||
22
src/pirats/templates/join_form.html
Normal file
22
src/pirats/templates/join_form.html
Normal 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 %}
|
||||
66
src/pirats/templates/lobby_partial.html
Normal file
66
src/pirats/templates/lobby_partial.html
Normal 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>
|
||||
7
src/pirats/templates/lobby_players_snippet.html
Normal file
7
src/pirats/templates/lobby_players_snippet.html
Normal 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 %}
|
||||
63
src/pirats/templates/scene_challenges_snippet.html
Normal file
63
src/pirats/templates/scene_challenges_snippet.html
Normal 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>
|
||||
46
src/pirats/templates/scene_obstacles_snippet.html
Normal file
46
src/pirats/templates/scene_obstacles_snippet.html
Normal 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 %}
|
||||
318
src/pirats/templates/scene_partial.html
Normal file
318
src/pirats/templates/scene_partial.html
Normal 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>
|
||||
27
src/pirats/templates/scene_roster_snippet.html
Normal file
27
src/pirats/templates/scene_roster_snippet.html
Normal 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>
|
||||
117
src/pirats/templates/scene_setup_partial.html
Normal file
117
src/pirats/templates/scene_setup_partial.html
Normal 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>
|
||||
99
src/pirats/templates/techniques_snippet.html
Normal file
99
src/pirats/templates/techniques_snippet.html
Normal 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>
|
||||
10
src/pirats/templates/voting_roster_snippet.html
Normal file
10
src/pirats/templates/voting_roster_snippet.html
Normal 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
229
tests/test_game.py
Normal 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"
|
||||
Reference in New Issue
Block a user