From 82ece0de10ae555a51767041d8a75c500df144cb Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Tue, 9 Jun 2026 16:06:44 -0700 Subject: [PATCH] Initial checkin --- flake.lock | 61 + flake.nix | 119 ++ pyproject.toml | 35 + src/pirats/__init__.py | 1 + src/pirats/cards.py | 203 +++ src/pirats/crud.py | 811 ++++++++++ src/pirats/database.py | 19 + src/pirats/main.py | 705 ++++++++ src/pirats/models.py | 85 + src/pirats/static/css/style.css | 1337 ++++++++++++++++ src/pirats/static/js/suggestions.js | 1423 +++++++++++++++++ src/pirats/templates/admin.html | 59 + src/pirats/templates/base.html | 39 + .../templates/between_scenes_partial.html | 105 ++ .../templates/character_creation_partial.html | 117 ++ src/pirats/templates/crew_status_snippet.html | 76 + src/pirats/templates/dashboard.html | 25 + src/pirats/templates/delegation_snippet.html | 50 + src/pirats/templates/inbox_snippet.html | 41 + src/pirats/templates/index.html | 33 + src/pirats/templates/join_form.html | 22 + src/pirats/templates/lobby_partial.html | 66 + .../templates/lobby_players_snippet.html | 7 + .../templates/scene_challenges_snippet.html | 63 + .../templates/scene_obstacles_snippet.html | 46 + src/pirats/templates/scene_partial.html | 318 ++++ .../templates/scene_roster_snippet.html | 27 + src/pirats/templates/scene_setup_partial.html | 117 ++ src/pirats/templates/techniques_snippet.html | 99 ++ .../templates/voting_roster_snippet.html | 10 + tests/test_game.py | 229 +++ 31 files changed, 6348 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 pyproject.toml create mode 100644 src/pirats/__init__.py create mode 100644 src/pirats/cards.py create mode 100644 src/pirats/crud.py create mode 100644 src/pirats/database.py create mode 100644 src/pirats/main.py create mode 100644 src/pirats/models.py create mode 100644 src/pirats/static/css/style.css create mode 100644 src/pirats/static/js/suggestions.js create mode 100644 src/pirats/templates/admin.html create mode 100644 src/pirats/templates/base.html create mode 100644 src/pirats/templates/between_scenes_partial.html create mode 100644 src/pirats/templates/character_creation_partial.html create mode 100644 src/pirats/templates/crew_status_snippet.html create mode 100644 src/pirats/templates/dashboard.html create mode 100644 src/pirats/templates/delegation_snippet.html create mode 100644 src/pirats/templates/inbox_snippet.html create mode 100644 src/pirats/templates/index.html create mode 100644 src/pirats/templates/join_form.html create mode 100644 src/pirats/templates/lobby_partial.html create mode 100644 src/pirats/templates/lobby_players_snippet.html create mode 100644 src/pirats/templates/scene_challenges_snippet.html create mode 100644 src/pirats/templates/scene_obstacles_snippet.html create mode 100644 src/pirats/templates/scene_partial.html create mode 100644 src/pirats/templates/scene_roster_snippet.html create mode 100644 src/pirats/templates/scene_setup_partial.html create mode 100644 src/pirats/templates/techniques_snippet.html create mode 100644 src/pirats/templates/voting_roster_snippet.html create mode 100644 tests/test_game.py diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..8f6b10e --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..6f32109 --- /dev/null +++ b/flake.nix @@ -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; + }; + }; + }; + }; + }; +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7427aa3 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/pirats/__init__.py b/src/pirats/__init__.py new file mode 100644 index 0000000..0d147c5 --- /dev/null +++ b/src/pirats/__init__.py @@ -0,0 +1 @@ +# Pirats package diff --git a/src/pirats/cards.py b/src/pirats/cards.py new file mode 100644 index 0000000..b7694d8 --- /dev/null +++ b/src/pirats/cards.py @@ -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"] diff --git a/src/pirats/crud.py b/src/pirats/crud.py new file mode 100644 index 0000000..f410b90 --- /dev/null +++ b/src/pirats/crud.py @@ -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() diff --git a/src/pirats/database.py b/src/pirats/database.py new file mode 100644 index 0000000..cf58669 --- /dev/null +++ b/src/pirats/database.py @@ -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 diff --git a/src/pirats/main.py b/src/pirats/main.py new file mode 100644 index 0000000..77a2c0d --- /dev/null +++ b/src/pirats/main.py @@ -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("

Session disconnected or expired.

") + + base_url = str(request.base_url).rstrip("/") + + # Context helper functions to run inside Jinja templates + def get_player_name_by_id(*args) -> str: + pid = args[1] if len(args) == 2 else (args[0] if len(args) == 1 else None) + if not pid: + return "None" + p = db.get(Player, pid) + return p.name if p else "Unknown" + + def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]: + return db.get(Obstacle, obs_id) + + # Core variables + hand = crud.get_player_hand(player) + max_hand_size = crud.calculate_max_hand_size(player, game.players) + + # Compute inbox tasks for current player + inbox_tasks = [] + for p in game.players: + if p.id != player.id: + if p.other_like_from_player_id == player.id and not p.other_like: + inbox_tasks.append({"player": p, "type": "like"}) + if p.other_hate_from_player_id == player.id and not p.other_hate: + inbox_tasks.append({"player": p, "type": "hate"}) + + 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("

Unknown game state.

") + +# --- HTMX Action Handlers --- + +# 1. Start character creation from lobby +@app.post("/game/{game_id}/lobby/start") +def lobby_start_character_creation(game_id: str, db: Session = Depends(get_session)): + game = crud.get_game(db, game_id) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + game.phase = "character_creation" + db.add(game) + db.commit() + return HTMLResponse(status_code=204) # Return No Content, polling redirects + +# 2. Save basic character details +@app.post("/game/{game_id}/player/{player_id}/save-basic") +def player_save_basic_details( + game_id: str, + player_id: str, + avatar_look: str = Form(...), + avatar_smell: str = Form(...), + first_words: str = Form(...), + good_at_math: str = Form(...), + db: Session = Depends(get_session) +): + player = crud.get_player(db, player_id) + if not player: + raise HTTPException(status_code=404, detail="Player not found") + + player.avatar_look = avatar_look.strip() + player.avatar_smell = avatar_smell.strip() + player.first_words = first_words.strip() + player.good_at_math = good_at_math + db.add(player) + db.commit() + + return 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("

Error: Each card must be assigned a unique technique.

", status_code=400) + + crud.assign_face_card_techniques(db, player_id, jack, queen, king) + + game = crud.get_game(db, game_id) + player = crud.get_player(db, player_id) + if not game or not player: + raise HTTPException(status_code=404, detail="Not found") + + return templates.TemplateResponse("techniques_snippet.html", { + "request": request, + "game": game, + "player": player, + "json_loads": json.loads + }) + +# 8. Set Role during Scene Setup +@app.post("/game/{game_id}/player/{player_id}/set-role") +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"
{msg}
", 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"

{msg}

", 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"
" + f"

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

" + f"Click to dismiss" + f"
" + ) + +# 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"

{msg}

", status_code=400) + + return HTMLResponse( + f"
" + f"

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

" + f"Click to dismiss" + f"
" + ) + +# 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() diff --git a/src/pirats/models.py b/src/pirats/models.py new file mode 100644 index 0000000..7db8e6b --- /dev/null +++ b/src/pirats/models.py @@ -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") diff --git a/src/pirats/static/css/style.css b/src/pirats/static/css/style.css new file mode 100644 index 0000000..65ae6df --- /dev/null +++ b/src/pirats/static/css/style.css @@ -0,0 +1,1337 @@ +/* ========================================================================== + RATS WITH GATS DESIGN SYSTEM & STYLESHEET + A weathered math-magic pirate aesthetic: deep-ocean dark mode, glassmorphism, + neon math runes, gold accents, and animated card widgets. + ========================================================================== */ + +/* --- Variables & Tokens --- */ +:root { + --bg-dark: #070b12; + --bg-ocean: #0d1726; + --bg-ocean-light: #16243b; + + --gold: #d4af37; + --gold-glow: rgba(212, 175, 55, 0.4); + --gold-dark: #aa841c; + + --neon-cyan: #00f2fe; + --neon-emerald: #00ff87; + + --red-suit: #ff2a5f; + --red-glow: rgba(255, 42, 95, 0.3); + --black-suit: #e2e8f0; + --black-glow: rgba(226, 232, 240, 0.2); + + --glass-bg: rgba(13, 23, 38, 0.7); + --glass-bg-hover: rgba(22, 36, 59, 0.85); + --glass-border: rgba(212, 175, 55, 0.2); + --glass-border-focus: rgba(0, 242, 254, 0.4); + + --text-primary: #f1f5f9; + --text-muted: #94a3b8; + --text-dark: #0f172a; + + --font-heading: 'Cinzel', serif; + --font-body: 'Outfit', sans-serif; + + --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* --- Base Reset & Setup --- */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 16px; + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; + overflow-x: hidden; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} +::-webkit-scrollbar-track { + background: var(--bg-dark); +} +::-webkit-scrollbar-thumb { + background: var(--gold-dark); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--gold); +} + +/* --- Layout Components --- */ +.app-header { + background: rgba(7, 11, 18, 0.95); + border-bottom: 2px solid var(--gold); + box-shadow: 0 4px 20px rgba(0,0,0,0.5); + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.logo-container { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.logo-container h1 { + font-family: var(--font-heading); + font-size: 1.8rem; + font-weight: 900; + letter-spacing: 2px; + background: linear-gradient(135deg, var(--gold) 30%, #fff 70%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-shadow: 0 0 10px var(--gold-glow); +} + +.math-magic { + font-family: var(--font-heading); + font-size: 2.2rem; + color: var(--neon-cyan); + text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); + animation: pulse-glow 3s infinite ease-in-out; +} + +.header-status { + display: flex; + align-items: center; + gap: 1rem; +} + +.player-pill { + background: rgba(0, 242, 254, 0.1); + border: 1px solid var(--neon-cyan); + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.9rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.player-pill .bullet { + width: 8px; + height: 8px; + background-color: var(--neon-emerald); + border-radius: 50%; + box-shadow: 0 0 8px var(--neon-emerald); +} + +.phase-pill { + background: rgba(212, 175, 55, 0.1); + border: 1px solid var(--gold); + color: var(--gold); + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 1px; +} + +.app-main { + flex: 1; + padding: 2rem; + max-width: 1400px; + width: 100%; + margin: 0 auto; +} + +.app-footer { + background: var(--bg-dark); + padding: 1.5rem; + text-align: center; + border-top: 1px solid rgba(255,255,255,0.05); + font-size: 0.85rem; + color: var(--text-muted); +} + +/* --- UI Panels & Glassmorphism --- */ +.glass-panel { + background: var(--glass-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + transition: var(--transition-smooth); +} + +.glass-panel:hover { + border-color: rgba(212, 175, 55, 0.35); + box-shadow: 0 8px 32px rgba(212, 175, 55, 0.05); +} + +.card { + background: var(--bg-ocean-light); + border: 1px solid var(--glass-border); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: 0 4px 15px rgba(0,0,0,0.3); + transition: var(--transition-smooth); +} + +.card h3 { + font-family: var(--font-heading); + color: var(--gold); + margin-bottom: 1rem; + border-bottom: 1px solid rgba(212, 175, 55, 0.2); + padding-bottom: 0.5rem; +} + +/* --- Forms & Controls --- */ +.form-group { + margin-bottom: 1.25rem; + text-align: left; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.95rem; + color: var(--text-muted); + font-weight: 500; +} + +.input-field { + width: 100%; + padding: 0.75rem 1rem; + background: rgba(7, 11, 18, 0.8); + border: 1px solid var(--glass-border); + border-radius: 8px; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 1rem; + transition: var(--transition-smooth); +} + +.input-field:focus { + outline: none; + border-color: var(--neon-cyan); + box-shadow: 0 0 10px rgba(0, 242, 254, 0.2); +} + +.select-field { + width: 100%; + padding: 0.75rem 1rem; + background: rgba(7, 11, 18, 0.8); + border: 1px solid var(--glass-border); + border-radius: 8px; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 1rem; + transition: var(--transition-smooth); + cursor: pointer; +} + +.select-field:focus { + outline: none; + border-color: var(--neon-cyan); +} + +.input-row { + display: flex; + gap: 0.5rem; +} + +.input-row .input-field { + flex: 1; +} + +/* --- Buttons --- */ +.btn { + display: inline-block; + padding: 0.75rem 1.5rem; + font-family: var(--font-body); + font-weight: 700; + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 1px; + border: none; + border-radius: 8px; + cursor: pointer; + transition: var(--transition-smooth); + text-decoration: none; +} + +.btn-primary { + background: linear-gradient(135deg, var(--gold) 0%, var(--gold-dark) 100%); + color: var(--text-dark); + box-shadow: 0 4px 15px var(--gold-glow); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(212, 175, 55, 0.6); +} + +.btn-secondary { + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.15); + color: var(--text-primary); +} + +.btn-secondary:hover:not(:disabled) { + background: rgba(255,255,255,0.15); + transform: translateY(-2px); +} + +.btn-deep { + background: linear-gradient(135deg, #152238 0%, #0d1726 100%); + border: 1px solid var(--neon-cyan); + color: var(--neon-cyan); + box-shadow: 0 4px 15px rgba(0, 242, 254, 0.1); +} + +.btn-deep:hover:not(:disabled) { + background: var(--neon-cyan); + color: var(--text-dark); + box-shadow: 0 4px 20px rgba(0, 242, 254, 0.4); + transform: translateY(-2px); +} + +.btn-danger { + background: linear-gradient(135deg, #7a1c1c 0%, #c0392b 100%); + color: var(--text-primary); + box-shadow: 0 4px 15px rgba(192, 57, 43, 0.2); +} + +.btn-danger:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(192, 57, 43, 0.5); +} + +.btn-gold { + background: transparent; + border: 2px solid var(--gold); + color: var(--gold); + box-shadow: 0 0 10px rgba(212,175,55,0.1); +} + +.btn-gold:hover:not(:disabled) { + background: var(--gold); + color: var(--bg-dark); + box-shadow: 0 0 20px var(--gold-glow); + transform: translateY(-2px); +} + +.btn-full { + width: 100%; +} + +.btn-small { + padding: 0.4rem 0.8rem; + font-size: 0.8rem; +} + +.btn-large { + padding: 1rem 2rem; + font-size: 1.1rem; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + +.glow-effect:hover { + filter: brightness(1.2); +} + +/* --- Welcome / Main Menu Screen --- */ +.welcome-container { + max-width: 900px; + margin: 4rem auto; +} + +.welcome-hero h2 { + font-family: var(--font-heading); + font-size: 2.5rem; + color: var(--text-primary); + margin-bottom: 1rem; +} + +.welcome-hero .subtitle { + font-size: 1.2rem; + color: var(--text-muted); + margin-bottom: 3rem; +} + +.welcome-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; +} + +@media (max-width: 768px) { + .welcome-actions { + grid-template-columns: 1fr; + } +} + +.action-card { + padding: 2.5rem 1.5rem; + display: flex; + flex-direction: column; + justify-content: space-between; + height: 300px; +} + +.action-card h3 { + font-family: var(--font-heading); + color: var(--gold); + font-size: 1.5rem; + margin-bottom: 0.75rem; +} + +.action-card p { + color: var(--text-muted); + margin-bottom: 2rem; +} + +/* --- Lobby / Hold View --- */ +.lobby-view { + max-width: 700px; + margin: 2rem auto; +} + +.lobby-view h2 { + font-family: var(--font-heading); + font-size: 2.2rem; + color: var(--gold); + margin-bottom: 0.5rem; +} + +.lobby-status-box { + margin: 2rem 0; +} + +.player-chips { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + margin-top: 1rem; +} + +.player-chip { + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + padding: 0.5rem 1.25rem; + border-radius: 30px; + display: flex; + align-items: center; + gap: 0.5rem; + transition: var(--transition-smooth); +} + +.player-chip.current-user { + background: rgba(0, 242, 254, 0.15); + border-color: var(--neon-cyan); + box-shadow: 0 0 10px rgba(0,242,254,0.15); +} + +.player-chip.voted-chip { + border-color: var(--neon-emerald); + background-color: rgba(0,255,135,0.05); +} + +.player-chip.not-voted-chip { + border-color: rgba(255,255,255,0.1); +} + +.creator-badge { + background: var(--gold); + color: var(--text-dark); + font-size: 0.7rem; + font-weight: 700; + padding: 0.1rem 0.4rem; + border-radius: 4px; + text-transform: uppercase; +} + +.links-box { + text-align: left; + margin-bottom: 2rem; +} + +.link-item { + margin-bottom: 1.5rem; +} + +.link-item h4 { + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.copy-container { + display: flex; + gap: 0.5rem; +} + +.copy-input { + flex: 1; + background: #05080e; + border: 1px solid var(--glass-border); + color: var(--text-primary); + padding: 0.5rem 1rem; + border-radius: 8px; + font-family: var(--font-body); + font-size: 0.95rem; +} + +.copy-input.admin-input { + border-color: var(--gold-dark); + color: var(--gold); +} + +.admin-link-item { + border-top: 1px dashed rgba(212, 175, 55, 0.3); + padding-top: 1.5rem; +} + +.info-text { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +/* --- Character Creation & Sheet Grid --- */ +.creation-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-top: 2rem; +} + +@media (max-width: 900px) { + .creation-grid { + grid-template-columns: 1fr; + } +} + +.section-desc { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.record-line { + background: rgba(0,0,0,0.2); + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 0.75rem; + border-left: 3px solid var(--gold); +} + +.delegation-box { + margin-bottom: 1.25rem; + padding: 1.25rem; + text-align: left; +} + +.delegation-box h4 { + font-size: 0.95rem; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.answer-box { + font-style: italic; + color: var(--neon-cyan); + font-size: 1.05rem; +} + +.author-label { + font-size: 0.8rem; + color: var(--text-muted); + font-style: normal; +} + +.waiting-box { + font-size: 0.9rem; + color: var(--gold); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.inbox-item { + padding: 1rem; + margin-bottom: 0.75rem; + border-left: 3px solid var(--neon-cyan); + background: rgba(0, 242, 254, 0.02); +} + +.inbox-item label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.submitted-techniques .tech-chip { + background: rgba(212,175,55,0.05); + border: 1px solid var(--gold); + color: var(--gold); + padding: 0.5rem; + border-radius: 6px; + margin-bottom: 0.5rem; + font-family: var(--font-heading); +} + +.tech-chip { + padding: 0.6rem 1rem; + font-size: 0.95rem; +} + +.tech-assignment-pill { + background: rgba(0, 242, 254, 0.05); + border: 1px solid var(--neon-cyan); + color: var(--text-primary); + padding: 0.6rem 1rem; + border-radius: 6px; + margin-bottom: 0.5rem; + text-align: left; + display: flex; + align-items: center; + gap: 1rem; +} + +.card-rank { + background: var(--neon-cyan); + color: var(--bg-dark); + font-weight: 900; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.85rem; +} + +/* --- Scene Setup / Role Choose --- */ +.setup-grid { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 2rem; + margin: 2rem 0; +} + +@media (max-width: 800px) { + .setup-grid { + grid-template-columns: 1fr; + } +} + +.role-buttons { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-top: 1.5rem; +} + +.role-btn { + padding: 1.5rem; + font-size: 1.2rem; + background: rgba(255,255,255,0.03); + border: 2px solid rgba(255,255,255,0.1); + color: var(--text-muted); +} + +.role-btn.pirat-btn:hover, .role-btn.pirat-btn.active { + border-color: var(--neon-emerald); + color: var(--text-primary); + background-color: rgba(0,255,135,0.06); + box-shadow: 0 0 15px rgba(0,255,135,0.15); +} + +.role-btn.deep-btn:hover, .role-btn.deep-btn.active { + border-color: var(--neon-cyan); + color: var(--text-primary); + background-color: rgba(0,242,254,0.06); + box-shadow: 0 0 15px rgba(0,242,254,0.15); +} + +.large-badge { + padding: 0.5rem 2rem; + font-size: 1.5rem; + border-radius: 8px; + font-family: var(--font-heading); + margin-top: 0.5rem; +} + +.role-badge.deep { + background: rgba(0, 242, 254, 0.15); + border: 2px solid var(--neon-cyan); + color: var(--neon-cyan); + box-shadow: 0 0 15px rgba(0,242,254,0.1); +} + +.role-badge.pirat { + background: rgba(0, 255, 135, 0.15); + border: 2px solid var(--neon-emerald); + color: var(--neon-emerald); + box-shadow: 0 0 15px rgba(0,255,135,0.1); +} + +.list-chips { + justify-content: flex-start; +} + +.list-chips .player-chip { + padding: 0.4rem 1rem; + font-size: 0.9rem; +} + +.deep-chip { + border-color: var(--neon-cyan); + background: rgba(0, 242, 254, 0.03); +} + +.pirat-chip { + border-color: var(--neon-emerald); + background: rgba(0, 255, 135, 0.03); +} + +/* --- Scene Play Board Layout --- */ +.scene-view-layout { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 2rem; +} + +@media (max-width: 1100px) { + .scene-view-layout { + grid-template-columns: 1fr; + } +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(212, 175, 55, 0.2); + padding-bottom: 0.5rem; + margin-bottom: 1rem; +} + +.card-header h3 { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.deck-counter { + font-size: 0.85rem; + color: var(--gold); + font-weight: 700; +} + +/* --- Active Obstacle Item Display --- */ +.obstacles-container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.obstacle-item { + border: 1px solid var(--glass-border); + border-radius: 10px; + padding: 1.25rem; + background: rgba(7, 11, 18, 0.4); + display: grid; + grid-template-columns: 0.8fr 2fr 1fr; + gap: 1rem; + position: relative; + overflow: hidden; +} + +@media (max-width: 600px) { + .obstacle-item { + grid-template-columns: 1fr; + } +} + +/* Color theme mappings for suits */ +.suit-c { border-left: 4px solid var(--black-suit); } +.suit-s { border-left: 4px solid var(--black-suit); } +.suit-h { border-left: 4px solid var(--red-suit); } +.suit-d { border-left: 4px solid var(--red-suit); } + +.obstacle-meta { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: rgba(0,0,0,0.3); + border-radius: 6px; + padding: 0.5rem; +} + +.suit-badge { + font-weight: 900; + font-size: 1.1rem; +} + +.suit-c .suit-badge, .suit-s .suit-badge { color: var(--black-suit); } +.suit-h .suit-badge, .suit-d .suit-badge { color: var(--red-suit); } + +.card-code { + font-size: 0.8rem; + color: var(--text-muted); + font-family: var(--font-heading); +} + +.obstacle-details h4 { + font-family: var(--font-heading); + color: var(--text-primary); + margin-bottom: 0.25rem; + font-size: 1.1rem; +} + +.obstacle-details .desc { + font-size: 0.85rem; + color: var(--text-muted); +} + +.obstacle-value-display { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: rgba(212, 175, 55, 0.04); + border: 1px dashed var(--glass-border); + border-radius: 6px; + padding: 0.5rem; +} + +.val-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; +} + +.val-number { + font-size: 1.8rem; + font-family: var(--font-heading); + font-weight: 900; + color: var(--gold); +} + +.played-column { + grid-column: span 3; + border-top: 1px solid rgba(255,255,255,0.05); + padding-top: 0.75rem; + margin-top: 0.5rem; +} + +@media (max-width: 600px) { + .played-column { + grid-column: span 1; + } +} + +.played-column h5 { + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.column-cards-flex { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +/* Card Widget (Mini) */ +.card-mini { + width: 32px; + height: 48px; + border-radius: 4px; + border: 1px solid var(--text-muted); + background: var(--bg-dark); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 2px 4px; + font-size: 0.7rem; + font-weight: 900; +} + +.card-mini.base-card { + border-color: var(--gold); + color: var(--gold); + background: rgba(212,175,55,0.1); +} + +.card-mini.rotated { + transform: rotate(90deg); + margin: 0 4px; +} + +.card-mini.success { + border-color: var(--neon-emerald); + color: var(--neon-emerald); +} + +.card-mini.failure { + border-color: var(--red-suit); + color: var(--red-suit); +} + +.card-mini .val { + align-self: flex-start; +} + +.card-mini .suit { + align-self: flex-end; +} + +.card-mini .owner { + font-size: 0.5rem; + text-transform: uppercase; + position: absolute; + bottom: -1px; + left: 1px; + color: var(--text-muted); +} + +/* --- Challenges Section --- */ +.challenges-container { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.challenge-item { + background: rgba(255,255,255,0.02); + border: 1px solid var(--glass-border); + border-radius: 8px; + padding: 1.25rem; + text-align: left; +} + +.challenge-item h4 { + font-family: var(--font-heading); + color: var(--neon-cyan); + font-size: 1.2rem; + margin-bottom: 0.25rem; +} + +.challenge-item .desc { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.applied-obstacles h5 { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.applied-obs-row { + padding: 0.75rem 1rem; + margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; +} + +.obs-info { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.obs-info .symbol { + font-size: 1.2rem; +} + +.play-card-form { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.select-xsmall { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + width: 140px; + background: var(--bg-dark); +} + +/* --- Deep Control panel --- */ +.deep-text { + color: var(--neon-cyan) !important; +} + +.deep-divider { + border: 0; + height: 1px; + background: linear-gradient(to right, rgba(0, 242, 254, 0), rgba(0, 242, 254, 0.4), rgba(0, 242, 254, 0)); + margin: 2rem 0; +} + +.obstacles-checkboxes { + display: flex; + flex-direction: column; + gap: 0.5rem; + background: rgba(0,0,0,0.2); + padding: 1rem; + border-radius: 8px; + max-height: 150px; + overflow-y: auto; + border: 1px solid var(--glass-border); +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.95rem; +} + +/* --- Private Section & Hands --- */ +.hand-flex { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: center; + margin-top: 1.5rem; +} + +/* Card Widget (Large) */ +.card-large { + width: 110px; + height: 165px; + border-radius: 8px; + border: 2px solid var(--glass-border); + background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%); + box-shadow: 0 4px 15px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0.5rem; + position: relative; + transition: var(--transition-smooth); +} + +.card-large:hover { + transform: translateY(-8px) scale(1.05); + border-color: var(--gold); + box-shadow: 0 10px 25px rgba(212, 175, 55, 0.2); +} + +.card-large.suit-c, .card-large.suit-s { + border-color: rgba(226, 232, 240, 0.15); +} +.card-large.suit-c:hover, .card-large.suit-s:hover { + border-color: var(--black-suit); + box-shadow: 0 10px 25px var(--black-glow); +} + +.card-large.suit-h, .card-large.suit-d { + border-color: rgba(255, 42, 95, 0.15); +} +.card-large.suit-h:hover, .card-large.suit-d:hover { + border-color: var(--red-suit); + box-shadow: 0 10px 25px var(--red-glow); +} + +.card-large.joker-card { + border-color: var(--gold); + background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%); +} + +.card-large .card-corner { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.1; +} + +.card-large .card-corner .val { + font-size: 1.2rem; + font-weight: 900; +} + +.card-large .card-corner .suit { + font-size: 1rem; +} + +.card-large.suit-c .val, .card-large.suit-c .suit, +.card-large.suit-s .val, .card-large.suit-s .suit { + color: var(--black-suit); +} + +.card-large.suit-h .val, .card-large.suit-h .suit, +.card-large.suit-d .val, .card-large.suit-d .suit { + color: var(--red-suit); +} + +.card-large .card-center { + font-size: 2.8rem; + text-align: center; + align-self: center; + margin: auto 0; +} + +.card-large.suit-c .card-center, .card-large.suit-s .card-center { color: var(--black-suit); } +.card-large.suit-h .card-center, .card-large.suit-d .card-center { color: var(--red-suit); } + +.card-large .card-tech-overlay { + position: absolute; + bottom: 0.5rem; + left: 0.5rem; + right: 0.5rem; + text-align: center; +} + +.tech-tag { + background: rgba(0, 0, 0, 0.7); + border: 1px solid var(--gold); + color: var(--gold); + font-size: 0.65rem; + padding: 0.15rem 0.3rem; + border-radius: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.joker-action { + display: flex; + gap: 2px; +} + +/* --- Character Sheet layout --- */ +.character-sheet-details { + text-align: left; +} + +.sheet-group { + background: rgba(0,0,0,0.15); + padding: 1rem; + border-radius: 8px; + border-left: 2px solid var(--gold-dark); +} + +.sheet-group h4 { + color: var(--gold); + font-size: 0.95rem; + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.footnote { + font-size: 0.8rem; + color: var(--text-muted); +} + +.techniques-list-sheet { + list-style: none; +} + +.techniques-list-sheet li { + margin-bottom: 0.25rem; + font-size: 0.95rem; +} + +.footnote-desc { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.15rem; + margin-left: 1.5rem; +} + +.margin-top-small { + margin-top: 0.5rem; +} + +/* --- Between Scenes Upkeep --- */ +.between-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin: 2rem 0; +} + +@media (max-width: 800px) { + .between-grid { + grid-template-columns: 1fr; + } +} + +.voting-card, .deep-rest-card { + text-align: left; +} + +.voted-confirmation-box { + padding: 1.5rem; + text-align: center; + border: 1px solid var(--neon-emerald); +} + +.success-text { + color: var(--neon-emerald); + font-weight: 700; +} + +.ranks-ledger { + list-style: none; +} + +.ranks-ledger li { + padding: 0.5rem; + border-bottom: 1px solid rgba(255,255,255,0.05); + display: flex; + justify-content: space-between; +} + +.deep-badge { + background: rgba(0, 242, 254, 0.15); + border: 1px solid var(--neon-cyan); + color: var(--neon-cyan); + padding: 0.1rem 0.4rem; + font-size: 0.75rem; + border-radius: 4px; +} + +.pirat-badge { + background: rgba(0, 255, 135, 0.15); + border: 1px solid var(--neon-emerald); + color: var(--neon-emerald); + padding: 0.1rem 0.4rem; + font-size: 0.75rem; + border-radius: 4px; +} + +/* --- Loader / Spinner widgets --- */ +.loader-container { + padding: 4rem; +} + +.spinner { + width: 50px; + height: 50px; + border: 3px solid rgba(0, 242, 254, 0.1); + border-radius: 50%; + border-top-color: var(--neon-cyan); + animation: spin 1s ease-in-out infinite; + margin: 0 auto 1.5rem; +} + +.spinner-small { + width: 20px; + height: 20px; + border: 2px solid rgba(255,255,255,0.1); + border-radius: 50%; + border-top-color: var(--gold); + animation: spin 1s ease-in-out infinite; + display: inline-block; +} + +/* --- Admin Panel --- */ +.admin-players-list { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1rem; +} + +.admin-player-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + gap: 1rem; + flex-wrap: wrap; +} + +.player-info-basic { + font-size: 1.05rem; +} + +.link-copy-action { + display: flex; + gap: 0.5rem; + align-items: center; + flex: 1; + max-width: 500px; +} + +/* --- Alert styles --- */ +.alert { + padding: 0.75rem 1.25rem; + margin-bottom: 1.5rem; + border-radius: 8px; + text-align: center; + font-weight: 500; +} + +.alert-danger { + background: rgba(192, 57, 43, 0.2); + border: 1px solid #c0392b; + color: #e74c3c; +} + +/* --- Animation keyframes --- */ +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes pulse-glow { + 0% { + text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); + transform: scale(1); + } + 50% { + text-shadow: 0 0 15px var(--neon-cyan), 0 0 30px var(--neon-cyan), 0 0 40px var(--neon-cyan); + transform: scale(1.05); + } + 100% { + text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); + transform: scale(1); + } +} + +/* --- Utilities --- */ +.text-center { text-align: center; } +.text-left { text-align: left; } +.margin-top { margin-top: 1.5rem; } +.gold-text { color: var(--gold); } +.center-block { margin: 1rem auto; max-width: 500px; } +.inline-form { display: flex; gap: 0.5rem; width: 100%; } +.inline-group { display: flex; gap: 0.5rem; width: 100%; } +.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; } +.select-xsmall { padding: 0.4rem; font-size: 0.85rem; } +.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; } diff --git a/src/pirats/static/js/suggestions.js b/src/pirats/static/js/suggestions.js new file mode 100644 index 0000000..2d9f51f --- /dev/null +++ b/src/pirats/static/js/suggestions.js @@ -0,0 +1,1423 @@ +// Auto-generated math-pirate suggestions pool for character creation +const SUGGESTIONS = { + "look": [ + "A cyber-pirate waistcoat buttoned with old copper coins", + "A mechanical leather vest covered in algebraic graffiti", + "A salty tail glowing with neon math runes", + "A ink-stained paw replaced with a brass protractor-claw", + "A barnacle-covered paw replaced with a brass protractor-claw", + "A cyber-pirate tricorn hat with a built-in compass", + "A salty tricorn hat with a built-in compass", + "A scurvy snout that twitches in rhythm with prime numbers", + "A brass-plated coat patterned like a checkerboard", + "A brass telescope permanently strapped to the left ear", + "A neon-glowing tail glowing with neon math runes", + "A scurvy paw replaced with a brass protractor-claw", + "A ancient whisker arrangement forming a perfect sine wave", + "A brass-plated leather vest covered in algebraic graffiti", + "A brass-plated peg-leg with a hidden slide-rule drawer", + "A polished tail glowing with neon math runes", + "A barnacle-covered snout with geometry scars", + "A ancient bandana soaked in salty seawater and ink", + "A ancient coat patterned like a checkerboard", + "A rusty snout that twitches in rhythm with prime numbers", + "A weather-beaten tail curled into a Fibonacci spiral", + "A gilded paw replaced with a brass protractor-claw", + "A gilded tricorn hat with a built-in compass", + "A brass-plated tail glowing with neon math runes", + "A mechanical satchel full of crumpled grid paper", + "A mechanical tricorn hat with nested abacus beads", + "A one-eyed leather vest covered in algebraic graffiti", + "A ancient tail glowing with neon math runes", + "A rusty leather vest covered in algebraic graffiti", + "A one-eyed paw replaced with a brass protractor-claw", + "A phosphorescent tail curled into a Fibonacci spiral", + "A weather-beaten paw replaced with a brass protractor-claw", + "A brass-plated whisker arrangement forming a perfect sine wave", + "A ancient snout that twitches in rhythm with prime numbers", + "A polished tricorn hat with a built-in compass", + "A scurvy whisker arrangement forming a perfect sine wave", + "A polished paw replaced with a brass protractor-claw", + "A gilded tricorn hat with nested abacus beads", + "A scarf patterned with infinite decimals of Pi", + "A cyber-pirate leather vest covered in algebraic graffiti", + "A barnacle-covered bandana soaked in salty seawater and ink", + "A phosphorescent tricorn hat with a built-in compass", + "A gilded waistcoat buttoned with old copper coins", + "A cyber-pirate satchel full of crumpled grid paper", + "A tricorn hat folded from an old calculus exam", + "A barnacle-covered tail curled into a Fibonacci spiral", + "A barnacle-covered satchel full of crumpled grid paper", + "A neon-glowing eyepatch displaying coordinate maps", + "A weather-beaten bandana soaked in salty seawater and ink", + "A gilded whisker arrangement forming a perfect sine wave", + "A one-eyed coat patterned like a checkerboard", + "A polished eyepatch displaying coordinate maps", + "A salty tricorn hat with nested abacus beads", + "A gilded eyepatch displaying coordinate maps", + "A mechanical waistcoat buttoned with old copper coins", + "A phosphorescent coat patterned like a checkerboard", + "A tiny chalkboard strapped to the forearm for quick calculations", + "A phosphorescent waistcoat buttoned with old copper coins", + "A ancient eyepatch displaying coordinate maps", + "A brass-plated eyepatch displaying coordinate maps", + "Whiskers braided with tiny colorful abacus beads", + "A salty satchel full of crumpled grid paper", + "A barnacle-covered eyepatch displaying coordinate maps", + "A cyber-pirate tail curled into a Fibonacci spiral", + "A cyber-pirate whisker arrangement forming a perfect sine wave", + "A weather-beaten snout with geometry scars", + "A ink-stained waistcoat buttoned with old copper coins", + "A neon-glowing tail curled into a Fibonacci spiral", + "A ink-stained eyepatch displaying coordinate maps", + "A weather-beaten whisker arrangement forming a perfect sine wave", + "A brass-plated tricorn hat with nested abacus beads", + "A polished waistcoat buttoned with old copper coins", + "A salty tail curled into a Fibonacci spiral", + "A scurvy satchel full of crumpled grid paper", + "A gilded tail glowing with neon math runes", + "A scurvy coat patterned like a checkerboard", + "A barnacle-covered tricorn hat with a built-in compass", + "A bandana decorated with coordinate grids", + "A barnacle-covered tricorn hat with nested abacus beads", + "A weather-beaten tail glowing with neon math runes", + "A rusty tail glowing with neon math runes", + "A scurvy waistcoat buttoned with old copper coins", + "A mechanical peg-leg with a hidden slide-rule drawer", + "A silver claw replacement shaped like a protractor", + "A salty paw replaced with a brass protractor-claw", + "A golden tooth engraved with the division sign", + "A ancient tail curled into a Fibonacci spiral", + "A barnacle-covered peg-leg with a hidden slide-rule drawer", + "A barnacle-covered waistcoat buttoned with old copper coins", + "A ink-stained whisker arrangement forming a perfect sine wave", + "A scurvy peg-leg with a hidden slide-rule drawer", + "A ink-stained snout that twitches in rhythm with prime numbers", + "A one-eyed tail glowing with neon math runes", + "A salty peg-leg with a hidden slide-rule drawer", + "A phosphorescent bandana soaked in salty seawater and ink", + "A cyber-pirate snout that twitches in rhythm with prime numbers", + "A ancient tricorn hat with a built-in compass", + "A one-eyed satchel full of crumpled grid paper", + "A rusty paw replaced with a brass protractor-claw", + "A salty waistcoat buttoned with old copper coins", + "A brass-plated tricorn hat with a built-in compass", + "A ancient paw replaced with a brass protractor-claw", + "An eye patch made of a black spade card", + "A ink-stained coat patterned like a checkerboard", + "A barnacle-covered whisker arrangement forming a perfect sine wave", + "A neon-glowing bandana soaked in salty seawater and ink", + "A barnacle-covered tail glowing with neon math runes", + "A one-eyed waistcoat buttoned with old copper coins", + "Fur bleached in the pattern of a Venn diagram", + "A one-eyed snout that twitches in rhythm with prime numbers", + "A one-eyed snout with geometry scars", + "A ink-stained leather vest covered in algebraic graffiti", + "A polished satchel full of crumpled grid paper", + "A brass-plated snout with geometry scars", + "A brass-plated tail curled into a Fibonacci spiral", + "A neon-glowing snout with geometry scars", + "Fur that smells faintly of chalk dust and gunpowder", + "A cyber-pirate paw replaced with a brass protractor-claw", + "A weather-beaten eyepatch displaying coordinate maps", + "A brass-plated satchel full of crumpled grid paper", + "A ancient snout with geometry scars", + "A ancient satchel full of crumpled grid paper", + "A miniature compass hanging from the left nostril ring", + "A polished tricorn hat with nested abacus beads", + "A cyber-pirate tail glowing with neon math runes", + "A one-eyed eyepatch displaying coordinate maps", + "A ink-stained tricorn hat with a built-in compass", + "A cyber-pirate eyepatch displaying coordinate maps", + "A one-eyed peg-leg with a hidden slide-rule drawer", + "A peg leg carved from a heavy wooden slide rule", + "A barnacle-covered coat patterned like a checkerboard", + "A phosphorescent tail glowing with neon math runes", + "A salty snout that twitches in rhythm with prime numbers", + "A cyber-pirate snout with geometry scars", + "A ink-stained snout with geometry scars", + "A glowing neon abacus tattooed on their back", + "A rusty peg-leg with a hidden slide-rule drawer", + "A rusty snout with geometry scars", + "An abacus-wheel peg leg that rolls instead of clicks", + "A neon-glowing waistcoat buttoned with old copper coins", + "A polished coat patterned like a checkerboard", + "A mechanical coat patterned like a checkerboard", + "A phosphorescent tricorn hat with nested abacus beads", + "A brass-plated waistcoat buttoned with old copper coins", + "A barnacle-covered leather vest covered in algebraic graffiti", + "A ancient tricorn hat with nested abacus beads", + "Ear notched in the shape of a perfect right angle", + "A ink-stained tricorn hat with nested abacus beads", + "A mechanical paw replaced with a brass protractor-claw", + "A weather-beaten tricorn hat with a built-in compass", + "A gilded satchel full of crumpled grid paper", + "A ink-stained satchel full of crumpled grid paper", + "A salty coat patterned like a checkerboard", + "A gilded peg-leg with a hidden slide-rule drawer", + "A ink-stained bandana soaked in salty seawater and ink", + "A gilded tail curled into a Fibonacci spiral", + "A mechanical whisker arrangement forming a perfect sine wave", + "A phosphorescent peg-leg with a hidden slide-rule drawer", + "A polished bandana soaked in salty seawater and ink", + "A monocle that displays neon green equations", + "A polished snout that twitches in rhythm with prime numbers", + "A mechanical tail curled into a Fibonacci spiral", + "A phosphorescent leather vest covered in algebraic graffiti", + "A scurvy tail curled into a Fibonacci spiral", + "A scurvy eyepatch displaying coordinate maps", + "A phosphorescent snout that twitches in rhythm with prime numbers", + "A neon-glowing snout that twitches in rhythm with prime numbers", + "A salty whisker arrangement forming a perfect sine wave", + "A phosphorescent paw replaced with a brass protractor-claw", + "A scurvy bandana soaked in salty seawater and ink", + "A neon-glowing whisker arrangement forming a perfect sine wave", + "A scurvy snout with geometry scars", + "A neon-glowing tricorn hat with nested abacus beads", + "A brass-plated bandana soaked in salty seawater and ink", + "A scurvy tricorn hat with nested abacus beads", + "A ancient peg-leg with a hidden slide-rule drawer", + "A mechanical bandana soaked in salty seawater and ink", + "A polished leather vest covered in algebraic graffiti", + "A gilded snout that twitches in rhythm with prime numbers", + "A scurvy tail glowing with neon math runes", + "A neon-glowing leather vest covered in algebraic graffiti", + "A brass-plated snout that twitches in rhythm with prime numbers", + "A rusty coat patterned like a checkerboard", + "A ink-stained tail glowing with neon math runes", + "A tail wrapped in copper wire that sparks when they get excited", + "A gilded leather vest covered in algebraic graffiti", + "A salty eyepatch displaying coordinate maps", + "A weather-beaten waistcoat buttoned with old copper coins", + "A gilded snout with geometry scars", + "A mechanical eyepatch displaying coordinate maps", + "A rusty waistcoat buttoned with old copper coins", + "A cyber-pirate tricorn hat with nested abacus beads", + "A salty bandana soaked in salty seawater and ink", + "A weather-beaten satchel full of crumpled grid paper", + "A rusty whisker arrangement forming a perfect sine wave", + "A salty snout with geometry scars", + "A cyber-pirate bandana soaked in salty seawater and ink", + "A ancient waistcoat buttoned with old copper coins", + "A rusty tricorn hat with nested abacus beads", + "A rusty eyepatch displaying coordinate maps" + ], + "smell": [ + "reminiscent of cheese and damp wood", + "suspiciously smelling of ink and sour lemons", + "suspiciously smelling of ink and spilled ink", + "pleasantly smelling of rum and damp wood", + "faintly of seaweed and damp wood", + "faintly of pine tar and sour lemons", + "faintly of ink and lightning sparks", + "like a mix of pine tar and spilled ink", + "strongly of ozone and sour lemons", + "like a mix of ozone and salty air", + "suspiciously smelling of seaweed and rusty iron", + "suspiciously smelling of seaweed and spilled ink", + "faintly of cheese and sour lemons", + "like a mix of ink and lightning sparks", + "strongly of gunpowder and spilled ink", + "pleasantly smelling of seaweed and peppermint oil", + "suspiciously smelling of rum and lightning sparks", + "suspiciously smelling of ozone and stale grog", + "pleasantly smelling of rum and burnt abacuses", + "strongly of cheese and damp wood", + "Ozone sparks and sulfur", + "reminiscent of gunpowder and wet tail fur", + "strongly of cheese and burnt abacuses", + "like a mix of pine tar and wet tail fur", + "strongly of cheese and spilled ink", + "like a mix of ozone and damp wood", + "pleasantly smelling of ink and sour lemons", + "pleasantly smelling of copper and wet tail fur", + "faintly of rum and burnt abacuses", + "suspiciously smelling of gunpowder and rusty iron", + "like a mix of copper and lightning sparks", + "pleasantly smelling of seaweed and burnt abacuses", + "reminiscent of copper and lightning sparks", + "reminiscent of cheese and burnt abacuses", + "pleasantly smelling of parchment and burnt abacuses", + "like a mix of seaweed and damp wood", + "Gunpowder smoke and melted candle wax", + "like a mix of ozone and burnt abacuses", + "faintly of rum and stale grog", + "like a mix of cheese and spilled ink", + "suspiciously smelling of parchment and sour lemons", + "faintly of copper and wet tail fur", + "faintly of cheese and wet tail fur", + "faintly of cheese and damp wood", + "faintly of pine tar and rusty iron", + "strongly of gunpowder and peppermint oil", + "faintly of pine tar and damp wood", + "strongly of seaweed and damp wood", + "like a mix of copper and wet tail fur", + "suspiciously smelling of cheese and salty air", + "faintly of rum and lightning sparks", + "reminiscent of chalk dust and spilled ink", + "strongly of copper and wet tail fur", + "pleasantly smelling of pine tar and damp wood", + "Damp sea charts and ozone", + "like a mix of cheese and stale grog", + "strongly of ink and rusty iron", + "like a mix of seaweed and salty air", + "pleasantly smelling of chalk dust and spilled ink", + "pleasantly smelling of chalk dust and peppermint oil", + "suspiciously smelling of copper and salty air", + "reminiscent of ozone and wet tail fur", + "faintly of rum and salty air", + "reminiscent of chalk dust and sour lemons", + "suspiciously smelling of pine tar and spilled ink", + "like a mix of cheese and wet tail fur", + "strongly of ink and sour lemons", + "suspiciously smelling of chalk dust and damp wood", + "Burnt tea leaves and old books", + "faintly of parchment and wet tail fur", + "faintly of copper and sour lemons", + "faintly of chalk dust and spilled ink", + "faintly of gunpowder and lightning sparks", + "reminiscent of parchment and wet tail fur", + "pleasantly smelling of parchment and rusty iron", + "reminiscent of cheese and stale grog", + "reminiscent of ink and wet tail fur", + "pleasantly smelling of gunpowder and burnt abacuses", + "reminiscent of parchment and stale grog", + "pleasantly smelling of chalk dust and lightning sparks", + "reminiscent of copper and peppermint oil", + "pleasantly smelling of ink and peppermint oil", + "Ozone and seaweed-wrapped tobacco", + "strongly of cheese and rusty iron", + "faintly of chalk dust and burnt abacuses", + "pleasantly smelling of seaweed and lightning sparks", + "suspiciously smelling of rum and stale grog", + "like a mix of rum and burnt abacuses", + "suspiciously smelling of rum and peppermint oil", + "suspiciously smelling of copper and burnt abacuses", + "reminiscent of pine tar and spilled ink", + "reminiscent of parchment and lightning sparks", + "suspiciously smelling of ink and rusty iron", + "like a mix of pine tar and damp wood", + "pleasantly smelling of chalk dust and salty air", + "faintly of copper and burnt abacuses", + "pleasantly smelling of ozone and stale grog", + "like a mix of cheese and lightning sparks", + "strongly of pine tar and salty air", + "like a mix of seaweed and rusty iron", + "pleasantly smelling of seaweed and sour lemons", + "suspiciously smelling of gunpowder and lightning sparks", + "suspiciously smelling of ink and peppermint oil", + "like a mix of seaweed and wet tail fur", + "reminiscent of gunpowder and burnt abacuses", + "suspiciously smelling of pine tar and sour lemons", + "pleasantly smelling of ink and lightning sparks", + "reminiscent of pine tar and peppermint oil", + "pleasantly smelling of rum and rusty iron", + "like a mix of gunpowder and wet tail fur", + "suspiciously smelling of pine tar and lightning sparks", + "pleasantly smelling of copper and spilled ink", + "strongly of ozone and wet tail fur", + "reminiscent of ink and burnt abacuses", + "like a mix of copper and stale grog", + "faintly of copper and stale grog", + "like a mix of copper and peppermint oil", + "suspiciously smelling of copper and stale grog", + "suspiciously smelling of cheese and burnt abacuses", + "strongly of ozone and spilled ink", + "Burnt cheese and gunpowder", + "pleasantly smelling of seaweed and salty air", + "pleasantly smelling of seaweed and stale grog", + "like a mix of ozone and peppermint oil", + "strongly of pine tar and rusty iron", + "strongly of gunpowder and stale grog", + "reminiscent of parchment and rusty iron", + "faintly of copper and lightning sparks", + "faintly of chalk dust and sour lemons", + "suspiciously smelling of chalk dust and stale grog", + "Freshly sharpened pencils and vinegar", + "pleasantly smelling of parchment and sour lemons", + "pleasantly smelling of ozone and sour lemons", + "faintly of cheese and rusty iron", + "like a mix of seaweed and burnt abacuses", + "reminiscent of gunpowder and damp wood", + "like a mix of cheese and sour lemons", + "reminiscent of seaweed and stale grog", + "like a mix of gunpowder and peppermint oil", + "reminiscent of ozone and stale grog", + "strongly of seaweed and burnt abacuses", + "faintly of ink and rusty iron", + "faintly of ink and spilled ink", + "suspiciously smelling of pine tar and peppermint oil", + "strongly of parchment and lightning sparks", + "reminiscent of chalk dust and damp wood", + "suspiciously smelling of gunpowder and stale grog", + "faintly of ozone and lightning sparks", + "faintly of rum and spilled ink", + "strongly of pine tar and lightning sparks", + "strongly of pine tar and burnt abacuses", + "pleasantly smelling of rum and spilled ink", + "reminiscent of rum and stale grog", + "faintly of copper and rusty iron", + "suspiciously smelling of ozone and wet tail fur", + "strongly of seaweed and spilled ink", + "suspiciously smelling of parchment and salty air", + "strongly of copper and lightning sparks", + "pleasantly smelling of cheese and peppermint oil", + "reminiscent of cheese and peppermint oil", + "pleasantly smelling of seaweed and rusty iron", + "suspiciously smelling of ozone and lightning sparks", + "strongly of rum and peppermint oil", + "faintly of chalk dust and peppermint oil", + "like a mix of rum and wet tail fur", + "like a mix of cheese and damp wood", + "reminiscent of chalk dust and wet tail fur", + "reminiscent of rum and peppermint oil", + "suspiciously smelling of copper and spilled ink", + "reminiscent of cheese and sour lemons", + "pleasantly smelling of parchment and damp wood", + "reminiscent of chalk dust and salty air", + "pleasantly smelling of pine tar and salty air", + "strongly of rum and wet tail fur", + "strongly of ink and damp wood", + "like a mix of pine tar and peppermint oil", + "strongly of gunpowder and sour lemons", + "faintly of ink and burnt abacuses", + "like a mix of rum and sour lemons", + "pleasantly smelling of cheese and sour lemons", + "strongly of cheese and lightning sparks", + "reminiscent of chalk dust and burnt abacuses", + "Salty parchment and copper coins", + "strongly of rum and lightning sparks", + "suspiciously smelling of seaweed and burnt abacuses", + "reminiscent of cheese and rusty iron", + "faintly of cheese and lightning sparks", + "like a mix of cheese and peppermint oil", + "reminiscent of ink and spilled ink", + "faintly of pine tar and peppermint oil", + "faintly of parchment and burnt abacuses", + "reminiscent of parchment and burnt abacuses", + "strongly of pine tar and peppermint oil", + "pleasantly smelling of ozone and peppermint oil", + "like a mix of parchment and rusty iron", + "faintly of ink and stale grog", + "pleasantly smelling of pine tar and burnt abacuses", + "suspiciously smelling of cheese and peppermint oil", + "strongly of gunpowder and rusty iron", + "reminiscent of cheese and spilled ink" + ], + "first_words": [ + "By the deep sea! the probability indicates we are sinking logarithmically!", + "I have transformed... and my hunger is logarithmic!", + "Avast! the geometry of this deck requires the coordinates of the treasure island!", + "Blow me down! the geometry of this deck requires the shortest boarding vector!", + "Blast it! the slope of the sea shows a prime number of hazards!", + "Aha! the abacus predicts infinite cheese!", + "Blow me down! the probability indicates the shortest boarding vector!", + "Arrr, the hypotenuse is magnificent!", + "To the brig! the slope of the sea shows a prime number of hazards!", + "Shiver my timbers! the coordinates point to the gats are ready to fire!", + "Arrr! my tail calculations reveal a division by zero!", + "Avast! the slope of the sea shows the coordinates of the treasure island!", + "Arrr! the tangent of the gat implies a division by zero!", + "Shiver my timbers! the slope of the sea shows an escape velocity of ten knots!", + "Hear me crew! the slope of the sea shows a division by zero!", + "Shiver my timbers! the probability indicates a division by zero!", + "Hear me crew! the captain's slide rule shows infinite cheese!", + "Avast! the geometry of this deck requires the gats are ready to fire!", + "Hear me crew! the tangent of the gat implies a perfect circle of doom!", + "Aha! the formula for cheese says we are sinking logarithmically!", + "Aha! the tangent of the gat implies a jackpot of gold doubloons!", + "Aha! the probability indicates infinite cheese!", + "To the brig! my tail calculations reveal a division by zero!", + "To the brig! the tangent of the gat implies a jackpot of gold doubloons!", + "Look alive! the slope of the sea shows infinite cheese!", + "Look alive! the geometry of this deck requires a perfect circle of doom!", + "Shiver my timbers! the captain's slide rule shows we are sinking logarithmically!", + "Blast it! the tangent of the gat implies the shortest boarding vector!", + "To the horizon, at a 45-degree angle!", + "To the brig! the formula for cheese says the gats are ready to fire!", + "Arrr! the captain's slide rule shows the shortest boarding vector!", + "Blow me down! the coordinates point to an escape velocity of ten knots!", + "Hear me crew! the probability indicates we are sinking logarithmically!", + "Look alive! the matrix of the hold contains a jackpot of gold doubloons!", + "Blow me down! the slope of the sea shows an escape velocity of ten knots!", + "Aha! the slope of the sea shows infinite cheese!", + "Arrr, divide by zero!", + "Arrr! the captain's slide rule shows infinite cheese!", + "Arrr! the coordinates point to the coordinates of the treasure island!", + "Look alive! my tail calculations reveal we are sinking logarithmically!", + "To the brig! the slope of the sea shows an escape velocity of ten knots!", + "Blast it! the coordinates point to an escape velocity of ten knots!", + "Aha! the matrix of the hold contains we are sinking logarithmically!", + "Look alive! the coordinates point to the gats are ready to fire!", + "Arrr! the geometry of this deck requires the coordinates of the treasure island!", + "Blast it! the slope of the sea shows an escape velocity of ten knots!", + "Arrr! the slope of the sea shows a jackpot of gold doubloons!", + "By the deep sea! the probability indicates the shortest boarding vector!", + "Hear me crew! the coordinates point to an escape velocity of ten knots!", + "Look alive! the geometry of this deck requires infinite cheese!", + "Arrr! the abacus predicts infinite cheese!", + "Arrr! the slope of the sea shows infinite cheese!", + "Avast! the probability indicates the shortest boarding vector!", + "Avast! the abacus predicts a prime number of hazards!", + "Blow me down! the slope of the sea shows a perfect circle of doom!", + "Blow me down! the formula for cheese says the coordinates of the treasure island!", + "Hear me crew! the coordinates point to the coordinates of the treasure island!", + "To the brig! the captain's slide rule shows the coordinates of the treasure island!", + "Hear me crew! my tail calculations reveal a prime number of hazards!", + "Aha! the tangent of the gat implies a division by zero!", + "Arrr! the geometry of this deck requires a jackpot of gold doubloons!", + "Blow me down! my tail calculations reveal a jackpot of gold doubloons!", + "To the brig! the coordinates point to we are sinking logarithmically!", + "Blow me down! the geometry of this deck requires we are sinking logarithmically!", + "By the deep sea! the slope of the sea shows the shortest boarding vector!", + "Hear me crew! the formula for cheese says infinite cheese!", + "Shiver my timbers! the coordinates point to a prime number of hazards!", + "Shiver my timbers! the matrix of the hold contains the coordinates of the treasure island!", + "Aha! the formula for cheese says the shortest boarding vector!", + "Avast! the slope of the sea shows we are sinking logarithmically!", + "Aha! my tail calculations reveal the shortest boarding vector!", + "By the deep sea! the matrix of the hold contains the coordinates of the treasure island!", + "Arrr! the matrix of the hold contains a prime number of hazards!", + "Shiver my timbers! my tail calculations reveal a perfect circle of doom!", + "Arrr! the geometry of this deck requires we are sinking logarithmically!", + "Hear me crew! the slope of the sea shows a perfect circle of doom!", + "Aha! the matrix of the hold contains an escape velocity of ten knots!", + "Shiver my timbers! the captain's slide rule shows the gats are ready to fire!", + "Blow me down! the captain's slide rule shows the coordinates of the treasure island!", + "Hear me crew! the coordinates point to the shortest boarding vector!", + "Blast it! the captain's slide rule shows the shortest boarding vector!", + "Arrr! the coordinates point to the shortest boarding vector!", + "Avast! the geometry of this deck requires a perfect circle of doom!", + "Arrr! the coordinates point to infinite cheese!", + "Arrr! the tangent of the gat implies infinite cheese!", + "Look alive! the slope of the sea shows a jackpot of gold doubloons!", + "Avast! the slope of the sea shows a division by zero!", + "Blow me down! the coordinates point to the shortest boarding vector!", + "Shiver my timbers! the geometry of this deck requires a division by zero!", + "Arrr! the formula for cheese says we are sinking logarithmically!", + "Blast it! the geometry of this deck requires the coordinates of the treasure island!", + "Look alive! the matrix of the hold contains a division by zero!", + "To the brig! the captain's slide rule shows the gats are ready to fire!", + "Hear me crew! the probability indicates an escape velocity of ten knots!", + "Avast! the tangent of the gat implies a perfect circle of doom!", + "Shiver my timbers! the captain's slide rule shows infinite cheese!", + "Shiver my decimals!", + "Blast it! the probability indicates an escape velocity of ten knots!", + "Arrr! the probability indicates the gats are ready to fire!", + "Arrr! the captain's slide rule shows the coordinates of the treasure island!", + "Blow me down! the tangent of the gat implies the shortest boarding vector!", + "Shiver my timbers! the matrix of the hold contains we are sinking logarithmically!", + "By the deep sea! the abacus predicts we are sinking logarithmically!", + "Avast! the slope of the sea shows an escape velocity of ten knots!", + "Aha! the tangent of the gat implies an escape velocity of ten knots!", + "Blow me down! the tangent of the gat implies an escape velocity of ten knots!", + "Avast! the probability indicates we are sinking logarithmically!", + "Aha! the slope of the sea shows a perfect circle of doom!", + "Look alive! my tail calculations reveal the coordinates of the treasure island!", + "Shiver my timbers! the formula for cheese says infinite cheese!", + "Aha! the matrix of the hold contains the shortest boarding vector!", + "Aha! the geometry of this deck requires the shortest boarding vector!", + "Blast it! the probability indicates a division by zero!", + "To the brig! the formula for cheese says a division by zero!", + "Shiver my timbers! the slope of the sea shows a jackpot of gold doubloons!", + "By the deep sea! the tangent of the gat implies a perfect circle of doom!", + "By the deep sea! the formula for cheese says we are sinking logarithmically!", + "Blow me down! the matrix of the hold contains a division by zero!", + "Blow me down! the captain's slide rule shows an escape velocity of ten knots!", + "Arrr! the geometry of this deck requires a division by zero!", + "Blow me down! the abacus predicts a perfect circle of doom!", + "Avast! the matrix of the hold contains a jackpot of gold doubloons!", + "To the brig! my tail calculations reveal a prime number of hazards!", + "Aha! the geometry of this deck requires we are sinking logarithmically!", + "Hear me crew! the tangent of the gat implies a division by zero!", + "Hear me crew! the abacus predicts a jackpot of gold doubloons!", + "Arrr! my tail calculations reveal a prime number of hazards!", + "By the deep sea! the probability indicates a jackpot of gold doubloons!", + "By the deep sea! the formula for cheese says the shortest boarding vector!", + "By the deep sea! the geometry of this deck requires the gats are ready to fire!", + "Hear me crew! the abacus predicts a division by zero!", + "By the deep sea! the tangent of the gat implies an escape velocity of ten knots!", + "Blast it! the probability indicates we are sinking logarithmically!", + "Hear me crew! the geometry of this deck requires infinite cheese!", + "Avast! the captain's slide rule shows the shortest boarding vector!", + "To the brig! the slope of the sea shows a division by zero!", + "Hear me crew! the captain's slide rule shows the gats are ready to fire!", + "Avast! the tangent of the gat implies a jackpot of gold doubloons!", + "Hear me crew! my tail calculations reveal the shortest boarding vector!", + "Shiver my timbers! the formula for cheese says the coordinates of the treasure island!", + "Hear me crew! the matrix of the hold contains a perfect circle of doom!", + "By the deep sea! the geometry of this deck requires the coordinates of the treasure island!", + "Look alive! the formula for cheese says the coordinates of the treasure island!", + "Gats, cheese, and geometry\u2014that's all a rat needs!", + "Arrr! the probability indicates a jackpot of gold doubloons!", + "Hear me crew! the probability indicates a prime number of hazards!", + "Blow me down! the geometry of this deck requires an escape velocity of ten knots!", + "To the brig! the matrix of the hold contains a division by zero!", + "Blast it! my tail calculations reveal infinite cheese!", + "Shiver my timbers! my tail calculations reveal a jackpot of gold doubloons!", + "Shiver my timbers! the matrix of the hold contains the gats are ready to fire!", + "Hear me crew! the abacus predicts the gats are ready to fire!", + "Avast! the geometry of this deck requires we are sinking logarithmically!", + "Shiver my timbers! the tangent of the gat implies we are sinking logarithmically!", + "Avast! the tangent of the gat implies a prime number of hazards!", + "Avast! my tail calculations reveal infinite cheese!", + "Arrr! the matrix of the hold contains the shortest boarding vector!", + "Blow me down! the slope of the sea shows a prime number of hazards!", + "Aha! the coordinates point to a division by zero!", + "To the brig! the matrix of the hold contains we are sinking logarithmically!", + "Look alive! the formula for cheese says we are sinking logarithmically!", + "Look alive! the abacus predicts a division by zero!", + "By the deep sea! the coordinates point to an escape velocity of ten knots!", + "Blow me down! the coordinates point to we are sinking logarithmically!", + "By the deep sea! the probability indicates the coordinates of the treasure island!", + "To the brig! the formula for cheese says a prime number of hazards!", + "To the brig! the probability indicates infinite cheese!", + "Look alive! the tangent of the gat implies an escape velocity of ten knots!", + "Shiver my timbers! the probability indicates we are sinking logarithmically!", + "By the deep sea! the abacus predicts infinite cheese!", + "Blow me down! the matrix of the hold contains the coordinates of the treasure island!", + "Arrr! my tail calculations reveal a perfect circle of doom!", + "Avast! the captain's slide rule shows an escape velocity of ten knots!", + "Arrr! the captain's slide rule shows an escape velocity of ten knots!", + "Blast it! the abacus predicts a prime number of hazards!", + "Blast it! my tail calculations reveal a division by zero!", + "Blow me down! the probability indicates a jackpot of gold doubloons!", + "By the deep sea! the coordinates point to a division by zero!", + "By the deep sea! the abacus predicts a jackpot of gold doubloons!", + "Blast it! the captain's slide rule shows a prime number of hazards!", + "Aha! the slope of the sea shows the coordinates of the treasure island!", + "To the brig! the matrix of the hold contains a jackpot of gold doubloons!", + "Look alive! the tangent of the gat implies the gats are ready to fire!", + "Blast it! the matrix of the hold contains a prime number of hazards!", + "Arrr! the tangent of the gat implies a perfect circle of doom!", + "Avast! the slope of the sea shows a jackpot of gold doubloons!", + "Shiver my timbers! the formula for cheese says a prime number of hazards!", + "By the deep sea! my tail calculations reveal we are sinking logarithmically!", + "Hear me crew! the slope of the sea shows a jackpot of gold doubloons!", + "Aha! the geometry of this deck requires infinite cheese!", + "To the brig! the geometry of this deck requires the coordinates of the treasure island!", + "Look alive! the coordinates point to the coordinates of the treasure island!", + "Look alive! the formula for cheese says the shortest boarding vector!", + "By the deep sea! the geometry of this deck requires a prime number of hazards!", + "To the brig! the tangent of the gat implies we are sinking logarithmically!", + "Hear me crew! the coordinates point to a perfect circle of doom!", + "Aha! the formula for cheese says infinite cheese!", + "By the deep sea! the formula for cheese says a prime number of hazards!", + "Aha! the matrix of the hold contains the gats are ready to fire!", + "Arrr! my tail calculations reveal the gats are ready to fire!" + ], + "good_at_math": [ + "Thinks they are, they uses slide rules to navigate stormy seas but gets confused by negative numbers.", + "Thinks they are, they remembers the first 100 digits of Pi and has the abacus tattoos to prove it.", + "Yes, they understands the probability of drawing a Joker but gets confused by negative numbers.", + "According to the abacus, they can balance the captain's ledgers in their head but gets confused by negative numbers.", + "No, they can solve equations under threat of cannon fire but gets distracted by shiny brass coins.", + "No, they knows how to divide cheese by zero and has the abacus tattoos to prove it.", + "In theory, they can solve equations under threat of cannon fire but gets distracted by shiny brass coins.", + "On paper, they can solve equations under threat of cannon fire but only when drunk on grog.", + "Absolutely, they remembers the first 100 digits of Pi and has a certificate from the Stowaway Academy.", + "No, they understands the geometry of boarding leaps and has the abacus tattoos to prove it.", + "Thinks they are, they can solve equations under threat of cannon fire but refuses to use prime numbers.", + "Barely, they can calculate the trajectory of a gat bullet but refuses to use prime numbers.", + "Only, they understands the geometry of boarding leaps but only when drunk on grog.", + "Absolutely, they can solve equations under threat of cannon fire but gets confused by negative numbers.", + "Thinks they are, they understands the geometry of boarding leaps but only when cheese is involved.", + "Thinks they are, they knows how to divide cheese by zero but only when cheese is involved.", + "On paper, they remembers the first 100 digits of Pi but only in base-3.", + "On paper, they knows how to divide cheese by zero and has a certificate from the Stowaway Academy.", + "According to the abacus, they uses slide rules to navigate stormy seas and has the abacus tattoos to prove it.", + "Yes, they can count abacus beads in pitch black hold but only when cheese is involved.", + "No, they can calculate the trajectory of a gat bullet but gets confused by negative numbers.", + "No, they interprets sea maps as coordinate grids but thinks fractions are bad luck.", + "Secretly, they can solve equations under threat of cannon fire but thinks fractions are bad luck.", + "On paper, they uses slide rules to navigate stormy seas and has a certificate from the Stowaway Academy.", + "According to the abacus, they interprets sea maps as coordinate grids but only in base-3.", + "In theory, they interprets sea maps as coordinate grids but gets distracted by shiny brass coins.", + "Thinks they are, they knows how to divide cheese by zero but thinks fractions are bad luck.", + "Secretly, they can balance the captain's ledgers in their head but only when cheese is involved.", + "Only, they interprets sea maps as coordinate grids and has the abacus tattoos to prove it.", + "According to the abacus, they can balance the captain's ledgers in their head but gets distracted by shiny brass coins.", + "Only, they uses slide rules to navigate stormy seas but refuses to use prime numbers.", + "No, they remembers the first 100 digits of Pi but only when drunk on grog.", + "On paper, they can calculate the trajectory of a gat bullet but gets confused by negative numbers.", + "In theory, they uses slide rules to navigate stormy seas but thinks fractions are bad luck.", + "Thinks they are, they understands the geometry of boarding leaps but refuses to use prime numbers.", + "Absolutely, they can count abacus beads in pitch black hold but gets confused by negative numbers.", + "Yes, they can solve equations under threat of cannon fire but only when drunk on grog.", + "Only, they understands the geometry of boarding leaps and has the abacus tattoos to prove it.", + "Thinks they are, they can balance the captain's ledgers in their head but refuses to use prime numbers.", + "Secretly, they can solve equations under threat of cannon fire but gets confused by negative numbers.", + "No, they can calculate the trajectory of a gat bullet and has the abacus tattoos to prove it.", + "Yes, specializes in escape vector geometry.", + "Secretly, they knows how to divide cheese by zero but only when drunk on grog.", + "No, they understands the geometry of boarding leaps and has a certificate from the Stowaway Academy.", + "Absolutely, they understands the probability of drawing a Joker and has the abacus tattoos to prove it.", + "Absolutely, they interprets sea maps as coordinate grids but only in base-3.", + "Yes, they can calculate the trajectory of a gat bullet but only when cheese is involved.", + "In theory, they can balance the captain's ledgers in their head but gets confused by negative numbers.", + "No, they uses slide rules to navigate stormy seas but refuses to use prime numbers.", + "According to the abacus, they can solve equations under threat of cannon fire but gets distracted by shiny brass coins.", + "Absolutely, they remembers the first 100 digits of Pi but gets confused by negative numbers.", + "Thinks they are, they uses slide rules to navigate stormy seas but only when drunk on grog.", + "Yes, they can count abacus beads in pitch black hold but only in base-3.", + "Secretly, they can calculate the trajectory of a gat bullet but only when drunk on grog.", + "On paper, they can calculate the trajectory of a gat bullet but only when drunk on grog.", + "Only, they can solve equations under threat of cannon fire but gets confused by negative numbers.", + "Yes, they can count abacus beads in pitch black hold and has the abacus tattoos to prove it.", + "In theory, they can balance the captain's ledgers in their head but only when cheese is involved.", + "Barely, they interprets sea maps as coordinate grids but thinks fractions are bad luck.", + "According to the abacus, they interprets sea maps as coordinate grids and claims numbers speak to them in dreams.", + "Thinks they are, they can balance the captain's ledgers in their head but thinks fractions are bad luck.", + "Absolutely, they knows how to divide cheese by zero but refuses to use prime numbers.", + "Secretly, they understands the geometry of boarding leaps but only when drunk on grog.", + "In theory, they can calculate the trajectory of a gat bullet but only in base-3.", + "Barely, they knows how to divide cheese by zero and has a certificate from the Stowaway Academy.", + "Absolutely, they remembers the first 100 digits of Pi but gets distracted by shiny brass coins.", + "Thinks they are, they understands the geometry of boarding leaps but thinks fractions are bad luck.", + "On paper, they remembers the first 100 digits of Pi but gets confused by negative numbers.", + "On paper, they understands the probability of drawing a Joker but gets distracted by shiny brass coins.", + "Secretly, they can solve equations under threat of cannon fire but only when drunk on grog.", + "In theory, they interprets sea maps as coordinate grids and has the abacus tattoos to prove it.", + "In theory, they knows how to divide cheese by zero and has the abacus tattoos to prove it.", + "Absolutely, they knows how to divide cheese by zero but only in base-3.", + "Only, they remembers the first 100 digits of Pi but only when cheese is involved.", + "Only, they understands the probability of drawing a Joker but only when cheese is involved.", + "Barely, they can balance the captain's ledgers in their head but gets distracted by shiny brass coins.", + "No, they understands the probability of drawing a Joker and has a certificate from the Stowaway Academy.", + "In theory, they remembers the first 100 digits of Pi but only when cheese is involved.", + "Yes, they can count abacus beads in pitch black hold but only when drunk on grog.", + "In theory, they can calculate the trajectory of a gat bullet and claims numbers speak to them in dreams.", + "Only, they understands the probability of drawing a Joker and has a certificate from the Stowaway Academy.", + "Secretly, they understands the geometry of boarding leaps and has a certificate from the Stowaway Academy.", + "Secretly, they remembers the first 100 digits of Pi but only when cheese is involved.", + "Absolutely, they understands the geometry of boarding leaps and has a certificate from the Stowaway Academy.", + "Thinks they are, they understands the probability of drawing a Joker and claims numbers speak to them in dreams.", + "On paper, they understands the probability of drawing a Joker but only when cheese is involved.", + "Only, they understands the geometry of boarding leaps and has a certificate from the Stowaway Academy.", + "Thinks they are, they remembers the first 100 digits of Pi but thinks fractions are bad luck.", + "No, they understands the probability of drawing a Joker but thinks fractions are bad luck.", + "On paper, they can calculate the trajectory of a gat bullet but thinks fractions are bad luck.", + "In theory, they can calculate the trajectory of a gat bullet but only when drunk on grog.", + "Yes, but only in base-8 because they lost two claws to a cat.", + "No, they uses slide rules to navigate stormy seas but gets distracted by shiny brass coins.", + "Only, they uses slide rules to navigate stormy seas and has a certificate from the Stowaway Academy.", + "Thinks they are, they can count abacus beads in pitch black hold but gets confused by negative numbers.", + "Absolutely, they understands the probability of drawing a Joker but gets confused by negative numbers.", + "Secretly, they can calculate the trajectory of a gat bullet but only in base-3.", + "In theory, they interprets sea maps as coordinate grids but only in base-3.", + "Absolutely, they understands the probability of drawing a Joker but refuses to use prime numbers.", + "Secretly, they remembers the first 100 digits of Pi and claims numbers speak to them in dreams.", + "Thinks they are, they can count abacus beads in pitch black hold and has a certificate from the Stowaway Academy.", + "Secretly, they uses slide rules to navigate stormy seas and has the abacus tattoos to prove it.", + "Absolutely, they interprets sea maps as coordinate grids and has the abacus tattoos to prove it.", + "Only, they understands the geometry of boarding leaps but gets distracted by shiny brass coins.", + "Secretly, they uses slide rules to navigate stormy seas but only in base-3.", + "According to the abacus, they knows how to divide cheese by zero but gets confused by negative numbers.", + "According to the abacus, they interprets sea maps as coordinate grids and has the abacus tattoos to prove it.", + "No, they understands the probability of drawing a Joker and claims numbers speak to them in dreams.", + "No, they interprets sea maps as coordinate grids and has the abacus tattoos to prove it.", + "According to the abacus, they understands the probability of drawing a Joker but only in base-3.", + "Thinks they are, they can calculate the trajectory of a gat bullet but gets confused by negative numbers.", + "Absolutely, they remembers the first 100 digits of Pi and claims numbers speak to them in dreams.", + "Secretly, they interprets sea maps as coordinate grids and has a certificate from the Stowaway Academy.", + "Absolutely, they can solve equations under threat of cannon fire and has a certificate from the Stowaway Academy.", + "Yes, they understands the probability of drawing a Joker but thinks fractions are bad luck.", + "Absolutely, they can count abacus beads in pitch black hold and has the abacus tattoos to prove it.", + "Absolutely, they uses slide rules to navigate stormy seas and has the abacus tattoos to prove it.", + "According to the abacus, they understands the geometry of boarding leaps and claims numbers speak to them in dreams.", + "Only, they can solve equations under threat of cannon fire and has a certificate from the Stowaway Academy.", + "Absolutely, they understands the geometry of boarding leaps but gets confused by negative numbers.", + "Absolutely, they understands the geometry of boarding leaps but gets distracted by shiny brass coins.", + "Absolutely, they interprets sea maps as coordinate grids but only when cheese is involved.", + "No, they can balance the captain's ledgers in their head but gets confused by negative numbers.", + "Absolutely, they can solve equations under threat of cannon fire but refuses to use prime numbers.", + "No, they can solve equations under threat of cannon fire but thinks fractions are bad luck.", + "Barely, they uses slide rules to navigate stormy seas but only in base-3.", + "In theory, they knows how to divide cheese by zero but only in base-3.", + "Thinks they are, they can solve equations under threat of cannon fire but only when drunk on grog.", + "Absolutely, they uses slide rules to navigate stormy seas but only when drunk on grog.", + "Only, they understands the probability of drawing a Joker but only when drunk on grog.", + "They can compute a Fibonacci sequence in under three seconds.", + "Secretly, they knows how to divide cheese by zero and claims numbers speak to them in dreams.", + "Thinks they are, they can count abacus beads in pitch black hold but gets distracted by shiny brass coins.", + "Secretly, they interprets sea maps as coordinate grids and claims numbers speak to them in dreams.", + "Absolutely, they can solve equations under threat of cannon fire but only when drunk on grog.", + "Yes, they can balance the captain's ledgers in their head but gets distracted by shiny brass coins.", + "Only, they knows how to divide cheese by zero and has the abacus tattoos to prove it.", + "Thinks they are, they can calculate the trajectory of a gat bullet but only when cheese is involved.", + "According to the abacus, they can calculate the trajectory of a gat bullet and has the abacus tattoos to prove it.", + "In theory, they remembers the first 100 digits of Pi but gets distracted by shiny brass coins.", + "According to the abacus, they can balance the captain's ledgers in their head but thinks fractions are bad luck.", + "No, they understands the probability of drawing a Joker but only in base-3.", + "In theory, they uses slide rules to navigate stormy seas but gets confused by negative numbers.", + "Thinks they are, they uses slide rules to navigate stormy seas but refuses to use prime numbers.", + "Secretly, they interprets sea maps as coordinate grids but only when drunk on grog.", + "Yes, they interprets sea maps as coordinate grids but gets confused by negative numbers.", + "No, they understands the geometry of boarding leaps but only in base-3.", + "Secretly, they remembers the first 100 digits of Pi but thinks fractions are bad luck.", + "Absolutely, they can count abacus beads in pitch black hold but refuses to use prime numbers.", + "In theory, they uses slide rules to navigate stormy seas and has the abacus tattoos to prove it.", + "Yes, they can count abacus beads in pitch black hold but gets confused by negative numbers.", + "Yes, they can solve equations under threat of cannon fire but only in base-3.", + "Absolutely, they can balance the captain's ledgers in their head and has a certificate from the Stowaway Academy.", + "Yes, they can balance the captain's ledgers in their head and claims numbers speak to them in dreams.", + "Absolutely, they can calculate the trajectory of a gat bullet and claims numbers speak to them in dreams.", + "On paper, they knows how to divide cheese by zero but thinks fractions are bad luck.", + "Thinks they are, they understands the probability of drawing a Joker but only when cheese is involved.", + "Thinks they are, they uses slide rules to navigate stormy seas but only in base-3.", + "Barely, they understands the probability of drawing a Joker but gets distracted by shiny brass coins.", + "Only, they understands the geometry of boarding leaps but gets confused by negative numbers.", + "On paper, they knows how to divide cheese by zero but only when drunk on grog.", + "On paper, they can count abacus beads in pitch black hold but gets confused by negative numbers.", + "No, they understands the probability of drawing a Joker but only when cheese is involved.", + "Barely, they knows how to divide cheese by zero but refuses to use prime numbers.", + "On paper, they understands the geometry of boarding leaps but thinks fractions are bad luck.", + "In theory, they can balance the captain's ledgers in their head but gets distracted by shiny brass coins.", + "Yes, they remembers the first 100 digits of Pi but only when cheese is involved.", + "Only, they knows how to divide cheese by zero but only when cheese is involved.", + "Only, they can balance the captain's ledgers in their head but only when cheese is involved.", + "Absolutely, they can count abacus beads in pitch black hold and has a certificate from the Stowaway Academy.", + "Barely, they can count abacus beads in pitch black hold but gets confused by negative numbers.", + "On paper, they can count abacus beads in pitch black hold and claims numbers speak to them in dreams.", + "Absolutely, they understands the geometry of boarding leaps and claims numbers speak to them in dreams.", + "Barely, they knows how to divide cheese by zero but only when cheese is involved.", + "Thinks they are, they can solve equations under threat of cannon fire but thinks fractions are bad luck.", + "In theory, they interprets sea maps as coordinate grids but refuses to use prime numbers.", + "Yes, they understands the probability of drawing a Joker but gets distracted by shiny brass coins.", + "In theory, they knows how to divide cheese by zero and claims numbers speak to them in dreams.", + "Yes, they interprets sea maps as coordinate grids and claims numbers speak to them in dreams.", + "Only, they remembers the first 100 digits of Pi and claims numbers speak to them in dreams.", + "Only, they uses slide rules to navigate stormy seas and has the abacus tattoos to prove it.", + "In theory, they can balance the captain's ledgers in their head but only in base-3.", + "On paper, they remembers the first 100 digits of Pi and has a certificate from the Stowaway Academy.", + "According to the abacus, they understands the geometry of boarding leaps but gets distracted by shiny brass coins.", + "On paper, they can solve equations under threat of cannon fire and has a certificate from the Stowaway Academy.", + "In theory, they understands the probability of drawing a Joker but refuses to use prime numbers.", + "Absolutely, they can balance the captain's ledgers in their head and has the abacus tattoos to prove it.", + "On paper, they can balance the captain's ledgers in their head but only when drunk on grog.", + "No, they can count abacus beads in pitch black hold but only when drunk on grog.", + "Barely, they uses slide rules to navigate stormy seas but only when drunk on grog.", + "No, they can balance the captain's ledgers in their head but thinks fractions are bad luck.", + "According to the abacus, they remembers the first 100 digits of Pi but refuses to use prime numbers.", + "Barely, they understands the probability of drawing a Joker but only when drunk on grog.", + "Yes, they can balance the captain's ledgers in their head but thinks fractions are bad luck.", + "Only, they can solve equations under threat of cannon fire but only when cheese is involved.", + "Absolutely, they interprets sea maps as coordinate grids and claims numbers speak to them in dreams.", + "On paper, they can calculate the trajectory of a gat bullet but gets distracted by shiny brass coins.", + "Barely, they can count abacus beads in pitch black hold and has the abacus tattoos to prove it.", + "Thinks they are, they interprets sea maps as coordinate grids but only when cheese is involved.", + "According to the abacus, they remembers the first 100 digits of Pi and claims numbers speak to them in dreams." + ], + "like": [ + "They secretly saves the gunpowder barrels during storm navigation.", + "They always shares the captain's stash with mathematical precision.", + "They faithfully guards the escape vectors when the seagulls attack.", + "They always shares the backup abacus even under heavy fire.", + "They cheerfully carries the coordinate charts when the seagulls attack.", + "They always shares the compass dial because they are a true friend.", + "They never steals the backup abacus to keep the crew happy.", + "They cleanly polishes the captain's stash because they are a true friend.", + "They secretly saves the gunpowder barrels just to be nice.", + "They cleanly polishes the gunpowder barrels during storm navigation.", + "They faithfully guards the compass dial during storm navigation.", + "They keep the gats polished and oiled.", + "They cleanly polishes the gunpowder barrels with mathematical precision.", + "They secretly saves the spare gats without asking for a share.", + "They secretly saves the gunpowder barrels to keep the crew happy.", + "They expertly calculates the captain's stash to keep the crew happy.", + "They secretly saves the escape vectors even under heavy fire.", + "They secretly saves the golden abacus beads to ensure a perfect escape.", + "They cheerfully carries the cheese wedges without asking for a share.", + "They never steals the cheese wedges with mathematical precision.", + "They quietly hums shanties for the captain's stash to ensure a perfect escape.", + "They quietly hums shanties for the captain's stash without asking for a share.", + "They always shares the compass dial to ensure a perfect escape.", + "They always shares the coordinate charts without asking for a share.", + "They never steals the captain's stash in base-10 code.", + "They expertly calculates the golden abacus beads without asking for a share.", + "They quietly hums shanties for the coordinate charts just to be nice.", + "They expertly calculates the cheese wedges when the seagulls attack.", + "They cheerfully carries the wet hold biscuits even under heavy fire.", + "They quietly hums shanties for the coordinate charts without asking for a share.", + "They boldly defends the gunpowder barrels without asking for a share.", + "They faithfully guards the coordinate charts in base-10 code.", + "They always shares the cheese wedges to keep the crew happy.", + "They always shares the cheese wedges with mathematical precision.", + "They never steals the gunpowder barrels without asking for a share.", + "They never steals the wet hold biscuits without asking for a share.", + "They expertly calculates the backup abacus in base-10 code.", + "They brilliantly solves the coordinate charts to ensure a perfect escape.", + "They expertly calculates the wet hold biscuits to ensure a perfect escape.", + "They cleanly polishes the escape vectors in base-10 code.", + "They secretly saves the gunpowder barrels to ensure a perfect escape.", + "They brilliantly solves the escape vectors even under heavy fire.", + "They cheerfully carries the gunpowder barrels because they are a true friend.", + "They boldly defends the gunpowder barrels because they are a true friend.", + "They boldly defends the compass dial in base-10 code.", + "They secretly saves the cheese wedges when the seagulls attack.", + "They quietly hums shanties for the compass dial with mathematical precision.", + "They cheerfully carries the gunpowder barrels without asking for a share.", + "They faithfully guards the wet hold biscuits with mathematical precision.", + "They quietly hums shanties for the compass dial when the seagulls attack.", + "They expertly calculates the coordinate charts when the seagulls attack.", + "They cleanly polishes the wet hold biscuits when the seagulls attack.", + "They boldly defends the compass dial to keep the crew happy.", + "They quietly hums shanties for the spare gats just to be nice.", + "They faithfully guards the cheese wedges even under heavy fire.", + "They cheerfully carries the spare gats because they are a true friend.", + "They faithfully guards the coordinate charts even under heavy fire.", + "They faithfully guards the compass dial with mathematical precision.", + "They cleanly polishes the wet hold biscuits with mathematical precision.", + "They quietly hums shanties for the captain's stash in base-10 code.", + "They never steals the coordinate charts with mathematical precision.", + "They cleanly polishes the gunpowder barrels even under heavy fire.", + "They cleanly polishes the spare gats even under heavy fire.", + "They boldly defends the escape vectors with mathematical precision.", + "They secretly saves the backup abacus when the seagulls attack.", + "They faithfully guards the coordinate charts when the seagulls attack.", + "They always shares the gunpowder barrels in base-10 code.", + "They never steals the captain's stash even under heavy fire.", + "They brilliantly solves the wet hold biscuits to ensure a perfect escape.", + "They faithfully guards the backup abacus because they are a true friend.", + "They expertly calculates the compass dial in base-10 code.", + "They secretly saves the backup abacus to ensure a perfect escape.", + "They expertly calculates the compass dial when the seagulls attack.", + "They brilliantly solves the backup abacus in base-10 code.", + "They expertly calculates the coordinate charts because they are a true friend.", + "They cleanly polishes the gunpowder barrels without asking for a share.", + "They expertly calculates the coordinate charts in base-10 code.", + "They expertly calculates the cheese wedges even under heavy fire.", + "They boldly defends the compass dial when the seagulls attack.", + "They cheerfully carries the wet hold biscuits during storm navigation.", + "They always shares the escape vectors because they are a true friend.", + "They cheerfully carries the escape vectors even under heavy fire.", + "They faithfully guards the captain's stash in base-10 code.", + "They boldly defends the compass dial because they are a true friend.", + "They faithfully guards the golden abacus beads even under heavy fire.", + "They cleanly polishes the golden abacus beads because they are a true friend.", + "They secretly saves the cheese wedges with mathematical precision.", + "They always shares the wet hold biscuits to keep the crew happy.", + "They quietly hums shanties for the spare gats without asking for a share.", + "They never steals the captain's stash to ensure a perfect escape.", + "They faithfully guards the gunpowder barrels because they are a true friend.", + "They quietly hums shanties for the wet hold biscuits with mathematical precision.", + "They never steals the cheese wedges when the seagulls attack.", + "They faithfully guards the compass dial to ensure a perfect escape.", + "They quietly hums shanties for the spare gats in base-10 code.", + "They never steals the compass dial even under heavy fire.", + "They faithfully guards the cheese wedges just to be nice.", + "They cleanly polishes the captain's stash when the seagulls attack.", + "They cleanly polishes the golden abacus beads just to be nice.", + "They expertly calculates the escape vectors even under heavy fire.", + "They never steals the compass dial when the seagulls attack.", + "They quietly hums shanties for the golden abacus beads when the seagulls attack.", + "They cheerfully carries the coordinate charts in base-10 code.", + "They faithfully guards the cheese wedges when the seagulls attack.", + "They secretly saves the wet hold biscuits without asking for a share.", + "They faithfully guards the compass dial because they are a true friend.", + "They always shares the backup abacus because they are a true friend.", + "They cleanly polishes the wet hold biscuits in base-10 code.", + "They boldly defends the backup abacus to ensure a perfect escape.", + "They cheerfully carries the spare gats even under heavy fire.", + "They brilliantly solves the coordinate charts with mathematical precision.", + "They secretly saves the compass dial because they are a true friend.", + "They faithfully guards the coordinate charts because they are a true friend.", + "They expertly calculates the backup abacus without asking for a share.", + "They never steals the coordinate charts during storm navigation.", + "They secretly saves the captain's stash when the seagulls attack.", + "They brilliantly solves the backup abacus just to be nice.", + "They boldly defends the gunpowder barrels to keep the crew happy.", + "They cleanly polishes the compass dial to ensure a perfect escape.", + "They expertly calculates the golden abacus beads with mathematical precision.", + "They secretly saves the coordinate charts because they are a true friend.", + "They never steals the gunpowder barrels because they are a true friend.", + "They secretly saves the gunpowder barrels when the seagulls attack.", + "They always shares the golden abacus beads in base-10 code.", + "They secretly saves the gunpowder barrels because they are a true friend.", + "They faithfully guards the coordinate charts with mathematical precision.", + "They expertly calculates the captain's stash just to be nice.", + "They cleanly polishes the gunpowder barrels because they are a true friend.", + "They quietly hums shanties for the compass dial even under heavy fire.", + "They secretly saves the captain's stash in base-10 code.", + "They brilliantly solves the wet hold biscuits just to be nice.", + "They cleanly polishes the wet hold biscuits because they are a true friend.", + "They brilliantly solves the spare gats when the seagulls attack.", + "They always shares the escape vectors with mathematical precision.", + "They never steals the backup abacus in base-10 code.", + "They cheerfully carries the escape vectors because they are a true friend.", + "They cheerfully carries the gunpowder barrels in base-10 code.", + "They always shares the escape vectors without asking for a share.", + "They cleanly polishes the golden abacus beads without asking for a share.", + "They expertly calculates the coordinate charts without asking for a share.", + "They faithfully guards the golden abacus beads to ensure a perfect escape.", + "They brilliantly solves the golden abacus beads with mathematical precision.", + "They brilliantly solves the backup abacus without asking for a share.", + "They cheerfully carries the captain's stash when the seagulls attack.", + "They expertly calculates the escape vectors during storm navigation.", + "They never steals the gunpowder barrels when the seagulls attack.", + "They expertly calculates the captain's stash even under heavy fire.", + "They brilliantly solves the golden abacus beads without asking for a share.", + "They boldly defends the compass dial even under heavy fire.", + "They tell the best stories about the Great Cheese Reef.", + "They never steals the escape vectors just to be nice.", + "They faithfully guards the spare gats because they are a true friend.", + "They quietly hums shanties for the cheese wedges when the seagulls attack.", + "They expertly calculates the escape vectors because they are a true friend.", + "They always shares the backup abacus when the seagulls attack.", + "They quietly hums shanties for the cheese wedges to keep the crew happy.", + "They brilliantly solves the cheese wedges to ensure a perfect escape.", + "They never steals the wet hold biscuits when the seagulls attack.", + "They brilliantly solves the golden abacus beads even under heavy fire.", + "They quietly hums shanties for the captain's stash to keep the crew happy.", + "They quietly hums shanties for the gunpowder barrels to ensure a perfect escape.", + "They boldly defends the escape vectors to keep the crew happy.", + "They always shares the spare gats to keep the crew happy.", + "They quietly hums shanties for the gunpowder barrels even under heavy fire.", + "They brilliantly solves the compass dial even under heavy fire.", + "They cleanly polishes the cheese wedges in base-10 code.", + "They brilliantly solves the captain's stash just to be nice.", + "They quietly hums shanties for the cheese wedges because they are a true friend.", + "They brilliantly solves the compass dial just to be nice.", + "They cleanly polishes the backup abacus to keep the crew happy.", + "They never steals the cheese wedges to keep the crew happy.", + "They secretly saves the cheese wedges in base-10 code.", + "They boldly defends the wet hold biscuits because they are a true friend.", + "They always shares the escape vectors during storm navigation.", + "They quietly hums shanties for the golden abacus beads to keep the crew happy.", + "They brilliantly solves the wet hold biscuits in base-10 code.", + "They cleanly polishes the spare gats to keep the crew happy.", + "They cheerfully carries the captain's stash with mathematical precision.", + "They boldly defends the backup abacus just to be nice.", + "They expertly calculates the coordinate charts even under heavy fire.", + "They quietly hums shanties for the backup abacus in base-10 code.", + "They faithfully guards the gunpowder barrels during storm navigation.", + "They always shares the backup abacus to ensure a perfect escape.", + "They faithfully guards the spare gats just to be nice.", + "They cleanly polishes the cheese wedges because they are a true friend.", + "They always shares the gunpowder barrels to keep the crew happy.", + "They always shares the backup abacus just to be nice.", + "They brilliantly solves the backup abacus when the seagulls attack.", + "They expertly calculates the wet hold biscuits to keep the crew happy.", + "They faithfully guards the escape vectors to keep the crew happy.", + "They expertly calculates the coordinate charts during storm navigation.", + "They secretly saves the coordinate charts in base-10 code.", + "They cheerfully carries the cheese wedges just to be nice.", + "They expertly calculates the escape vectors to keep the crew happy.", + "They always shares the golden abacus beads during storm navigation.", + "They faithfully guards the captain's stash without asking for a share.", + "They cheerfully carries the backup abacus in base-10 code.", + "They cheerfully carries the compass dial during storm navigation.", + "They expertly calculates the wet hold biscuits just to be nice.", + "They secretly saves the compass dial when the seagulls attack." + ], + "hate": [ + "They annoyingly chews the coordinate maps but only in base-3.", + "They secretly hoards the gat barrel oil but gets confused by negative numbers.", + "They loudly whistles the abacus beads and has a certificate from the Stowaway Academy.", + "They clumsily breaks the geometry homework and has the abacus tattoos to prove it.", + "They frequently drops the bilge keys but only when drunk on grog.", + "They always claims the navigator's compass but only when drunk on grog.", + "They carelessly stains the abacus beads but only when drunk on grog.", + "They carelessly stains the hammock ropes and has the abacus tattoos to prove it.", + "They constantly complains about the navigator's compass but refuses to use prime numbers.", + "They stubbornly argues about the slide rule but only in base-3.", + "They annoyingly chews the abacus beads but only in base-3.", + "They secretly hoards the dry gunpowder bags and has the abacus tattoos to prove it.", + "They always claims the geometry homework but only when drunk on grog.", + "They accidently misplaces the navigator's compass but thinks fractions are bad luck.", + "They carelessly stains the gat barrel oil but gets distracted by shiny brass coins.", + "They annoyingly chews the coordinate maps but gets confused by negative numbers.", + "They carelessly stains the slide rule but refuses to use prime numbers.", + "They stubbornly argues about the coordinate maps and claims numbers speak to them in dreams.", + "They stubbornly argues about the dry gunpowder bags but gets distracted by shiny brass coins.", + "They loudly whistles the gat barrel oil but only when cheese is involved.", + "They accidently misplaces the navigator's compass and has the abacus tattoos to prove it.", + "They frequently drops the geometry homework and claims numbers speak to them in dreams.", + "They carelessly stains the coordinate maps but only in base-3.", + "They accidently misplaces the hammock ropes and has a certificate from the Stowaway Academy.", + "They accidently misplaces the gat barrel oil and claims numbers speak to them in dreams.", + "They stubbornly argues about the dry gunpowder bags and has a certificate from the Stowaway Academy.", + "They clumsily breaks the slide rule and has a certificate from the Stowaway Academy.", + "They chew their tail when nervous.", + "They annoyingly chews the hammock ropes but refuses to use prime numbers.", + "They annoyingly chews the hammock ropes but thinks fractions are bad luck.", + "They loudly whistles the gat barrel oil and has the abacus tattoos to prove it.", + "They frequently drops the hammock ropes but gets distracted by shiny brass coins.", + "They annoyingly chews the hammock ropes but gets confused by negative numbers.", + "They accidently misplaces the cheese wedges but gets distracted by shiny brass coins.", + "They always claims the hammock ropes but gets confused by negative numbers.", + "They secretly hoards the coordinate maps and claims numbers speak to them in dreams.", + "They clumsily breaks the slide rule and claims numbers speak to them in dreams.", + "They annoyingly chews the gat barrel oil but only in base-3.", + "They always claims the slide rule but only in base-3.", + "They stubbornly argues about the bilge keys but only when drunk on grog.", + "They loudly whistles the cheese wedges but refuses to use prime numbers.", + "They accidently misplaces the slide rule but thinks fractions are bad luck.", + "They accidently misplaces the abacus beads and claims numbers speak to them in dreams.", + "They loudly whistles the hammock ropes but only in base-3.", + "They secretly hoards the navigator's compass but only in base-3.", + "They accidently misplaces the navigator's compass but gets distracted by shiny brass coins.", + "They clumsily breaks the hammock ropes but gets distracted by shiny brass coins.", + "They always claims the gat barrel oil and has the abacus tattoos to prove it.", + "They always claims the navigator's compass and claims numbers speak to them in dreams.", + "They always claims the hammock ropes and claims numbers speak to them in dreams.", + "They clumsily breaks the navigator's compass but only when drunk on grog.", + "They loudly whistles the navigator's compass and has the abacus tattoos to prove it.", + "They stubbornly argues about the navigator's compass but refuses to use prime numbers.", + "They always claims the cheese wedges but thinks fractions are bad luck.", + "They frequently drops the bilge keys but gets confused by negative numbers.", + "They talk about imaginary numbers in their sleep.", + "They secretly hoards the dry gunpowder bags but only when cheese is involved.", + "They stubbornly argues about the coordinate maps but gets confused by negative numbers.", + "They secretly hoards the navigator's compass but only when drunk on grog.", + "They accidently misplaces the coordinate maps and has a certificate from the Stowaway Academy.", + "They clumsily breaks the abacus beads but thinks fractions are bad luck.", + "They accidently misplaces the coordinate maps but only in base-3.", + "They annoyingly chews the coordinate maps but only when drunk on grog.", + "They accidently misplaces the coordinate maps but thinks fractions are bad luck.", + "They clumsily breaks the coordinate maps but gets confused by negative numbers.", + "They clumsily breaks the geometry homework but gets distracted by shiny brass coins.", + "They frequently drops the abacus beads but gets distracted by shiny brass coins.", + "They secretly hoards the gat barrel oil but only when cheese is involved.", + "They stubbornly argues about the bilge keys and claims numbers speak to them in dreams.", + "They clumsily breaks the geometry homework and claims numbers speak to them in dreams.", + "They carelessly stains the hammock ropes but gets confused by negative numbers.", + "They frequently drops the dry gunpowder bags but thinks fractions are bad luck.", + "They always claims the geometry homework but refuses to use prime numbers.", + "They always claims the cheese wedges but only in base-3.", + "They always claims the geometry homework and has a certificate from the Stowaway Academy.", + "They annoyingly chews the dry gunpowder bags but refuses to use prime numbers.", + "They loudly whistles the cheese wedges and has a certificate from the Stowaway Academy.", + "They clumsily breaks the dry gunpowder bags but only when cheese is involved.", + "They stubbornly argues about the gat barrel oil but only when drunk on grog.", + "They annoyingly chews the navigator's compass but only in base-3.", + "They annoyingly chews the hammock ropes but gets distracted by shiny brass coins.", + "They carelessly stains the gat barrel oil and claims numbers speak to them in dreams.", + "They secretly hoards the geometry homework and has a certificate from the Stowaway Academy.", + "They secretly hoards the hammock ropes and has the abacus tattoos to prove it.", + "They frequently drops the gat barrel oil and claims numbers speak to them in dreams.", + "They accidently misplaces the hammock ropes and claims numbers speak to them in dreams.", + "They frequently drops the coordinate maps but gets distracted by shiny brass coins.", + "They carelessly stains the gat barrel oil but only in base-3.", + "They always claims the cheese wedges but only when cheese is involved.", + "They loudly whistles the gat barrel oil but thinks fractions are bad luck.", + "They frequently drops the dry gunpowder bags but gets confused by negative numbers.", + "They clumsily breaks the dry gunpowder bags but gets distracted by shiny brass coins.", + "They always claims the bilge keys and has the abacus tattoos to prove it.", + "They constantly complains about the geometry homework but only when drunk on grog.", + "They carelessly stains the slide rule and has the abacus tattoos to prove it.", + "They constantly complains about the slide rule but refuses to use prime numbers.", + "They chew on the gunpowder bags.", + "They constantly complains about the navigator's compass but only in base-3.", + "They annoyingly chews the geometry homework but only when drunk on grog.", + "They always claims the bilge keys but refuses to use prime numbers.", + "They stubbornly argues about the slide rule but gets distracted by shiny brass coins.", + "They carelessly stains the hammock ropes but only when drunk on grog.", + "They accidently misplaces the navigator's compass but only when drunk on grog.", + "They accidently misplaces the coordinate maps but refuses to use prime numbers.", + "They stubbornly argues about the gat barrel oil but thinks fractions are bad luck.", + "They carelessly stains the cheese wedges but thinks fractions are bad luck.", + "They loudly whistles the navigator's compass but gets distracted by shiny brass coins.", + "They constantly complains about the dry gunpowder bags but refuses to use prime numbers.", + "They annoyingly chews the hammock ropes but only when drunk on grog.", + "They constantly complains about the geometry homework but refuses to use prime numbers.", + "They stubbornly argues about the navigator's compass but gets distracted by shiny brass coins.", + "They stubbornly argues about the gat barrel oil but refuses to use prime numbers.", + "They frequently drops the slide rule and has the abacus tattoos to prove it.", + "They stubbornly argues about the navigator's compass but only when cheese is involved.", + "They annoyingly chews the cheese wedges but only when cheese is involved.", + "They stubbornly argues about the geometry homework but refuses to use prime numbers.", + "They stubbornly argues about the dry gunpowder bags but gets confused by negative numbers.", + "They constantly complains about the hammock ropes and claims numbers speak to them in dreams.", + "They clumsily breaks the dry gunpowder bags but gets confused by negative numbers.", + "They frequently drops the geometry homework but only when cheese is involved.", + "They accidently misplaces the slide rule but only when drunk on grog.", + "They clumsily breaks the bilge keys but only when drunk on grog.", + "They annoyingly chews the cheese wedges and has the abacus tattoos to prove it.", + "They accidently misplaces the bilge keys but only when cheese is involved.", + "They loudly whistles the dry gunpowder bags and has the abacus tattoos to prove it.", + "They loudly whistles the slide rule but refuses to use prime numbers.", + "They frequently drops the navigator's compass but gets distracted by shiny brass coins.", + "They secretly hoards the geometry homework but gets confused by negative numbers.", + "They carelessly stains the abacus beads but thinks fractions are bad luck.", + "They stubbornly argues about the hammock ropes but only when cheese is involved.", + "They accidently misplaces the abacus beads but gets distracted by shiny brass coins.", + "They always claims the bilge keys but thinks fractions are bad luck.", + "They always claims the gat barrel oil but only when drunk on grog.", + "They loudly whistles the slide rule but only when cheese is involved.", + "They annoyingly chews the slide rule and has a certificate from the Stowaway Academy.", + "They carelessly stains the slide rule but only when drunk on grog.", + "They constantly complains about the cheese wedges but only when drunk on grog.", + "They clumsily breaks the gat barrel oil but gets distracted by shiny brass coins.", + "They annoyingly chews the navigator's compass and claims numbers speak to them in dreams.", + "They stubbornly argues about the geometry homework but only when cheese is involved.", + "They clumsily breaks the dry gunpowder bags and has the abacus tattoos to prove it.", + "They carelessly stains the coordinate maps but thinks fractions are bad luck.", + "They annoyingly chews the slide rule and has the abacus tattoos to prove it.", + "They stubbornly argues about the slide rule and claims numbers speak to them in dreams.", + "They frequently drops the navigator's compass and has the abacus tattoos to prove it.", + "They clumsily breaks the slide rule but gets distracted by shiny brass coins.", + "They clumsily breaks the cheese wedges and has a certificate from the Stowaway Academy.", + "They carelessly stains the bilge keys but thinks fractions are bad luck.", + "They stubbornly argues about the cheese wedges but refuses to use prime numbers.", + "They annoyingly chews the slide rule but refuses to use prime numbers.", + "They stubbornly argues about the coordinate maps and has a certificate from the Stowaway Academy.", + "They constantly complains about the hammock ropes but refuses to use prime numbers.", + "They accidently misplaces the hammock ropes but only in base-3.", + "They stubbornly argues about the abacus beads but only when cheese is involved.", + "They always claims the bilge keys but gets distracted by shiny brass coins.", + "They constantly complains about the abacus beads but gets distracted by shiny brass coins.", + "They accidently misplaces the navigator's compass but only when cheese is involved.", + "They always claims the coordinate maps and has the abacus tattoos to prove it.", + "They annoyingly chews the navigator's compass but only when drunk on grog.", + "They annoyingly chews the bilge keys but gets distracted by shiny brass coins.", + "They stubbornly argues about the navigator's compass but gets confused by negative numbers.", + "They annoyingly chews the gat barrel oil but refuses to use prime numbers.", + "They stubbornly argues about the hammock ropes but gets distracted by shiny brass coins.", + "They secretly hoards the abacus beads but gets confused by negative numbers.", + "They carelessly stains the bilge keys but only when drunk on grog.", + "They always claims the dry gunpowder bags but refuses to use prime numbers.", + "They always claims the slide rule but refuses to use prime numbers.", + "They carelessly stains the dry gunpowder bags but gets distracted by shiny brass coins.", + "They carelessly stains the navigator's compass but refuses to use prime numbers.", + "They always claims the geometry homework but gets confused by negative numbers.", + "They frequently drops the cheese wedges but only when cheese is involved.", + "They always claims the abacus beads but gets distracted by shiny brass coins.", + "They stubbornly argues about the dry gunpowder bags but only when drunk on grog.", + "They loudly whistles the slide rule but gets confused by negative numbers.", + "They carelessly stains the bilge keys and has the abacus tattoos to prove it.", + "They stubbornly argues about the cheese wedges and has a certificate from the Stowaway Academy.", + "They loudly whistles the gat barrel oil but gets distracted by shiny brass coins.", + "They constantly complains about the hammock ropes but gets distracted by shiny brass coins.", + "They accidently misplaces the cheese wedges but only when cheese is involved.", + "They frequently drops the bilge keys but gets distracted by shiny brass coins.", + "They stubbornly argues about the abacus beads and has a certificate from the Stowaway Academy.", + "They secretly hoards the dry gunpowder bags but gets distracted by shiny brass coins.", + "They clumsily breaks the hammock ropes but gets confused by negative numbers.", + "They always claims the navigator's compass and has the abacus tattoos to prove it.", + "They loudly whistles the dry gunpowder bags but refuses to use prime numbers.", + "They constantly complains about the hammock ropes but only in base-3.", + "They accidently misplaces the geometry homework but only in base-3.", + "They clumsily breaks the bilge keys but gets distracted by shiny brass coins.", + "They always claims the abacus beads but only in base-3.", + "They stubbornly argues about the navigator's compass and has a certificate from the Stowaway Academy.", + "They frequently drops the bilge keys and has the abacus tattoos to prove it.", + "They annoyingly chews the cheese wedges but only in base-3.", + "They carelessly stains the navigator's compass but only in base-3.", + "They stubbornly argues about the gat barrel oil and claims numbers speak to them in dreams.", + "They secretly hoards the bilge keys but only in base-3.", + "They loudly whistles the coordinate maps but gets distracted by shiny brass coins.", + "They frequently drops the gat barrel oil and has a certificate from the Stowaway Academy.", + "They stubbornly argues about the slide rule and has the abacus tattoos to prove it.", + "They accidently misplaces the dry gunpowder bags and has a certificate from the Stowaway Academy.", + "They frequently drops the slide rule but gets confused by negative numbers." + ], + "techniques": [ + "Geometric Drift of the Cheese Reef", + "Parabolic boarding leap", + "Fibonacci Drift of the Stormy Sea", + "Geometric Leap of Euler", + "Trigonometric Flurry of the Abacus", + "Prime Number Slash of the Gat", + "Logarithmic Dodge of Carlos", + "Parabolic Flurry of the Gat", + "Logarithmic Slash of Euler", + "Logarithmic Drift of the Gat", + "Hypotenuse Shield of the Coordinate Plane", + "Algebraic Parry of the Stormy Sea", + "Hypotenuse Parry of the Deep", + "Hypotenuse Drift of Shrapnel", + "Binary Vortex of Gauss", + "Geometric Leap of the Deep", + "Trigonometric Drift of Shrapnel", + "Algebraic Slam of the Abacus", + "Hypotenuse Slash of Euler", + "Geometric Vortex of Euler", + "Hypotenuse Leap of the Gat", + "Trigonometric Slam of the Deep", + "Parabolic Shield of the Deep", + "Geometric Slash of the Gat", + "Geometric Slash of Gauss", + "Binary Barrage of the Gat", + "Fibonacci Vortex of Carlos", + "Geometric Slam of the Coordinate Plane", + "Prime Number Slash of the Deep", + "Prime Number Flurry of the Abacus", + "Logarithmic Flurry of Shrapnel", + "Vector Leap of Euler", + "Binary Drift of the Cheese Reef", + "Algebraic Leap of Shrapnel", + "Fibonacci Dodge of the Abacus", + "Vector Leap of the Coordinate Plane", + "Algebraic Leap of Gauss", + "Prime Number Leap of Euler", + "Trigonometric Dodge of the Cheese Reef", + "Fibonacci Barrage of Carlos", + "Geometric Parry of the Gat", + "Binary Shield of the Gat", + "Fibonacci Slash of Euler", + "Trigonometric Flurry of the Coordinate Plane", + "Cheese smell smoke screen", + "Parabolic Barrage of Shrapnel", + "Geometric Dodge of Gauss", + "Trigonometric Drift of the Deep", + "Algebraic Parry of Shrapnel", + "Vector Dodge of the Abacus", + "Algebraic Vortex of Carlos", + "Binary Flurry of the Abacus", + "Algebraic Slam of the Coordinate Plane", + "Algebraic Slam of the Deep", + "Logarithmic Barrage of the Deep", + "Binary Drift of Carlos", + "Logarithmic Flurry of the Abacus", + "Logarithmic Slam of Carlos", + "Parabolic Slash of the Stormy Sea", + "Parabolic Barrage of Carlos", + "Logarithmic Dodge of the Abacus", + "Fibonacci Slam of Gauss", + "Geometric Shield of the Coordinate Plane", + "Trigonometric Barrage of the Abacus", + "Algebraic Flurry of the Coordinate Plane", + "Hypotenuse Barrage of the Gat", + "Vector Vortex of the Gat", + "Geometric Parry of the Cheese Reef", + "Vector Drift of Euler", + "Vector Slash of the Stormy Sea", + "Binary Shield of Euler", + "Hypotenuse Parry of the Abacus", + "Trigonometric Drift of Euler", + "Fibonacci Parry of the Gat", + "Vector Parry of Gauss", + "Hypotenuse Shield of the Deep", + "Hypotenuse Slam of the Gat", + "Trigonometric Dodge of the Gat", + "Fibonacci Barrage of the Deep", + "Vector Dodge of Carlos", + "Algebraic Slam of the Stormy Sea", + "Prime Number Dodge of Shrapnel", + "Vector Slash of the Gat", + "Hypotenuse Flurry of the Gat", + "Fibonacci Flurry of the Gat", + "Logarithmic Leap of the Abacus", + "Fibonacci Slash of Gauss", + "Hypotenuse Parry of Shrapnel", + "Trigonometric Barrage of Carlos", + "Hypotenuse Parry of the Coordinate Plane", + "Trigonometric Shield of the Gat", + "Prime Number Parry of Euler", + "Vector Barrage of Shrapnel", + "Vector Slam of the Gat", + "Vector Flurry of the Abacus", + "Binary Drift of Gauss", + "Prime Number Slam of Euler", + "Prime Number Leap of Carlos", + "Algebraic Barrage of the Stormy Sea", + "Geometric Barrage of the Gat", + "Logarithmic Leap of the Deep", + "Logarithmic Barrage of the Gat", + "Angle of attack swipe", + "Trigonometric Parry of the Cheese Reef", + "Fibonacci Slash of the Coordinate Plane", + "Hypotenuse Shield of the Gat", + "Vector Shield of Gauss", + "Trigonometric Shield of Gauss", + "Vector Slash of the Abacus", + "Binary Shield of the Cheese Reef", + "Parabolic Parry of the Deep", + "Geometric Leap of Gauss", + "Logarithmic Shield of the Deep", + "Logarithmic Drift of Shrapnel", + "Trigonometric Flurry of Carlos", + "Logarithmic Slash of the Cheese Reef", + "Fibonacci Slam of the Abacus", + "Vector Parry of the Gat", + "Geometric Shield of the Deep", + "Logarithmic Flurry of Gauss", + "Hypotenuse Barrage of Euler", + "Hypotenuse Vortex of the Cheese Reef", + "Hypotenuse Slash of Gauss", + "Vector Parry of the Stormy Sea", + "Logarithmic Slam of the Stormy Sea", + "Hypotenuse Dodge of the Abacus", + "Geometric Leap of the Stormy Sea", + "Prime Number Shield of the Coordinate Plane", + "Binary Barrage of Shrapnel", + "Hypotenuse Shield of Euler", + "Geometric Drift of the Abacus", + "Prime Number Drift of Shrapnel", + "Logarithmic Vortex of the Abacus", + "Vector Slam of the Abacus", + "Hypotenuse Flurry of the Abacus", + "Trigonometric Slam of Euler", + "Parabolic Slam of Euler", + "Vector Shield of Carlos", + "Trigonometric Dodge of the Deep", + "Fibonacci Vortex of the Abacus", + "Geometric Shield of the Cheese Reef", + "Algebraic Drift of Gauss", + "Trigonometric Slash of the Cheese Reef", + "Parabolic Shield of Shrapnel", + "Fibonacci Shield of Shrapnel", + "Hypotenuse Dodge of Shrapnel", + "Vector Slash of the Coordinate Plane", + "Binary Vortex of the Cheese Reef", + "Geometric Slash of Shrapnel", + "Fibonacci Slash of the Cheese Reef", + "Logarithmic Vortex of the Gat", + "Algebraic Flurry of Euler", + "Logarithmic Barrage of the Abacus", + "Logarithmic Slash of the Stormy Sea", + "Vector Drift of the Gat", + "Trigonometric Shield of Euler", + "Trigonometric Shield of the Deep", + "Trigonometric Leap of Gauss", + "Prime Number Shield of the Deep", + "Algebraic Barrage of the Coordinate Plane", + "Vector Flurry of the Gat", + "Hypotenuse Slam of the Coordinate Plane", + "Geometric Vortex of the Gat", + "Binary Slam of the Gat", + "Binary Slam of the Cheese Reef", + "Parabolic Drift of Shrapnel", + "Prime Number Drift of the Stormy Sea", + "Logarithmic Parry of the Cheese Reef", + "Parabolic Leap of the Abacus", + "Binary Dodge of Shrapnel", + "Parabolic Barrage of the Abacus", + "Parabolic Vortex of the Coordinate Plane", + "Prime Number Dodge of Carlos", + "Fibonacci Barrage of the Abacus", + "Vector Barrage of the Coordinate Plane", + "Prime Number Drift of the Abacus", + "Algebraic Slash of the Gat", + "Binary Drift of the Coordinate Plane", + "Hypotenuse Flurry of the Stormy Sea", + "Trigonometric Slam of the Stormy Sea", + "Hypotenuse Vortex of Euler", + "Fibonacci Leap of the Cheese Reef", + "Fibonacci Parry of the Abacus", + "Trigonometric Slash of the Coordinate Plane", + "Parabolic Shield of Carlos", + "Trigonometric Leap of the Coordinate Plane", + "Trigonometric Leap of Carlos", + "Vector Parry of Euler", + "Hypotenuse Barrage of the Coordinate Plane", + "Binary Parry of Euler", + "Geometric Flurry of Euler", + "Parabolic Dodge of the Cheese Reef", + "Geometric Leap of the Abacus", + "Binary Leap of the Gat", + "Binary Barrage of the Deep", + "Fibonacci Shield of the Coordinate Plane", + "Fibonacci Drift of the Deep", + "Trigonometric Shield of Shrapnel", + "Parabolic Slash of Euler", + "Hypotenuse Parry of the Gat" + ] +}; + +function getSuggestion(type) { + const arr = SUGGESTIONS[type]; + if (!arr) return ""; + return arr[Math.floor(Math.random() * arr.length)]; +} diff --git a/src/pirats/templates/admin.html b/src/pirats/templates/admin.html new file mode 100644 index 0000000..6720186 --- /dev/null +++ b/src/pirats/templates/admin.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}Creator Admin Panel - Game #{{ game.id[:8] }}{% endblock %} + +{% block content %} +
+
+

Game Creator Admin Panel

+

Use this dashboard to copy magic sheet links for players who misplace theirs.

+
+ +
+
+

Player Magic Links

+
+ {% for p in game.players %} +
+
+ {{ p.name }} + {% if p.is_creator %}Creator{% endif %} + (Rank {{ p.rank }}, Role: {{ p.role or 'None' }}) +
+ +
+ {% else %} +

No players have joined yet.

+ {% endfor %} +
+
+ +
+

Lobby Details

+

Game Phase: {{ game.phase.replace('_', ' ').title() }}

+

Scene Number: {{ game.current_scene_number }}

+

Join URL (Share this with players):

+
+ + +
+ + +
+
+
+{% endblock %} diff --git a/src/pirats/templates/base.html b/src/pirats/templates/base.html new file mode 100644 index 0000000..52da35b --- /dev/null +++ b/src/pirats/templates/base.html @@ -0,0 +1,39 @@ + + + + + + {% block title %}Rats with Gats{% endblock %} + + + + + + + + + + + + + + + + +
+
+ π +

Rats with Gats

+
+ {% block header_extra %}{% endblock %} +
+ +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/src/pirats/templates/between_scenes_partial.html b/src/pirats/templates/between_scenes_partial.html new file mode 100644 index 0000000..6071b29 --- /dev/null +++ b/src/pirats/templates/between_scenes_partial.html @@ -0,0 +1,105 @@ +
+

Between Scenes (Scene #{{ game.current_scene_number }} Concluded)

+

Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.

+ +
+ +
+

1. Pirate Ranking (Voting)

+

Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.

+ + {% 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 %} +
+

✔️ Vote submitted! Waiting for other players to submit their nominations.

+
+ {% else %} +
+
+ + +
+
+ {% endif %} + + +
+

Nomination Progress:

+
+ {% for p in game.players %} + {% set voted = False %} + {% for v in game.votes if v.voter_player_id == p.id %} + {% set voted = True %} + {% endfor %} +
+ {{ p.name }} + {% if voted %}Voted{% else %}Voting...{% endif %} +
+ {% endfor %} +
+
+
+ + +
+

2. Resting Deep Upkeep

+ + {% if player.previous_role == "deep" %} +
+

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

+

This is handled automatically when transitioning, but you must click 'Ready' below to proceed.

+
+ {% else %} +

Your Pi-Rat was active. Your hand cards carry over to the next scene.

+ {% endif %} + +
+

Crew Rank Ledger:

+
    + {% for p in game.players %} +
  • + {{ p.name }}: Rank {{ p.rank }} + {% if p.previous_role == 'deep' %}Deep{% else %}Pi-Rat{% endif %} +
  • + {% endfor %} +
+
+
+
+ + +
+ {% if player.is_ready %} +
+

Ready! Waiting for other players to ready up...

+
+
+ {% else %} + + {% endif %} +
+
+ + +
diff --git a/src/pirats/templates/character_creation_partial.html b/src/pirats/templates/character_creation_partial.html new file mode 100644 index 0000000..6eae02d --- /dev/null +++ b/src/pirats/templates/character_creation_partial.html @@ -0,0 +1,117 @@ +
+
+

Character Sheet Creation

+

Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.

+ +
+ + +
+ + +
+

I. Basic Rat Records

+ + {% if player.avatar_look and player.avatar_smell and player.first_words %} + +
+
Look: {{ player.avatar_look }}
+
Smell: {{ player.avatar_smell }}
+
First Words: "{{ player.first_words }}"
+
Good at Math? {{ player.good_at_math }}
+ +
+ {% else %} + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+ {% endif %} +
+ + +
+
+

II. Crew Delegations

+ {% if not player.other_like_from_player_id or not player.other_hate_from_player_id %} + + {% endif %} +
+

You must let other players decide what they like and hate about your character.

+ +
+ + {% set question_type = 'like' %} + {% include 'delegation_snippet.html' %} + + + {% set question_type = 'hate' %} + {% include 'delegation_snippet.html' %} +
+
+ + + {% include 'inbox_snippet.html' %} + + + {% include 'techniques_snippet.html' %} + + +
+

V. Crew Creation Status

+

See the creation progress of the entire crew in real time.

+
+ {% include 'crew_status_snippet.html' %} +
+
+
+
+ + +
diff --git a/src/pirats/templates/crew_status_snippet.html b/src/pirats/templates/crew_status_snippet.html new file mode 100644 index 0000000..0f68b3d --- /dev/null +++ b/src/pirats/templates/crew_status_snippet.html @@ -0,0 +1,76 @@ +
+ {% 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] %} + +
+
+ + 🐀 {{ p.name }} {% if p.id == player.id %}(You){% endif %} + + + Rank {{ p.rank }} + +
+ +
+ +
+ Basic Records: + {% if p.avatar_look and p.avatar_smell and p.first_words %} + ✅ Complete + {% else %} + ✍️ Writing... + {% endif %} +
+ + +
+ Delegated Tasks: +
+
+ • Like: + {% if p.other_like %} + Done ({{ get_player_name(p.other_like_from_player_id) }}) + {% elif p.other_like_from_player_id %} + Waiting on {{ get_player_name(p.other_like_from_player_id) }} + {% else %} + Not delegated + {% endif %} +
+
+ • Hate: + {% if p.other_hate %} + Done ({{ get_player_name(p.other_hate_from_player_id) }}) + {% elif p.other_hate_from_player_id %} + Waiting on {{ get_player_name(p.other_hate_from_player_id) }} + {% else %} + Not delegated + {% endif %} +
+
+
+ + +
+ Secret Techniques: + {% if game.phase == "character_creation" %} + {% if has_submitted_techs %} + ✅ Submitted + {% else %} + ✍️ Writing... + {% endif %} + {% else %} + + {% if p.is_ready %} + ✅ J/Q/K Assigned + {% else %} + ✍️ Assigning... + {% endif %} + {% endif %} +
+
+
+ {% endfor %} +
diff --git a/src/pirats/templates/dashboard.html b/src/pirats/templates/dashboard.html new file mode 100644 index 0000000..9a66a4f --- /dev/null +++ b/src/pirats/templates/dashboard.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Game #{{ game.id[:8] }} - {% if player %}{{ player.name }}{% else %}Lobby{% endif %}{% endblock %} + +{% block header_extra %} +
+ {% if player %} + {{ player.name }} (Rank {{ player.rank }}) + {% endif %} + {{ game.phase.replace('_', ' ').title() }} +
+{% endblock %} + +{% block content %} + +
+
+
+

Contacting the math-magic spirits...

+
+
+{% endblock %} diff --git a/src/pirats/templates/delegation_snippet.html b/src/pirats/templates/delegation_snippet.html new file mode 100644 index 0000000..e4a742b --- /dev/null +++ b/src/pirats/templates/delegation_snippet.html @@ -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 %} + +
+ +

What other pirates {{ question_type.upper() }} about you:

+ + {% if ans %} +
"{{ ans }}" — answered by {{ get_player_name(delegate_id) }}
+ + + {% elif delegate_id %} +
+
+ + Delegated to {{ get_player_name(delegate_id) }}. Waiting... +
+ +
+ {% else %} +
+ + +
+ {% endif %} +
diff --git a/src/pirats/templates/inbox_snippet.html b/src/pirats/templates/inbox_snippet.html new file mode 100644 index 0000000..e5be800 --- /dev/null +++ b/src/pirats/templates/inbox_snippet.html @@ -0,0 +1,41 @@ +
+

III. Your Tasks for Others

+

Answer questions that your crewmates have delegated to you.

+ +
+ {% if inbox_tasks %} + {% for task in inbox_tasks %} + {% set p = task.player %} + {% if task.type == 'like' %} +
+
+ +
+ + + +
+
+
+ {% else %} +
+
+ +
+ + + +
+
+
+ {% endif %} + {% endfor %} + {% else %} +

No tasks in your inbox. Check back when other players delegate to you!

+ {% endif %} +
+
diff --git a/src/pirats/templates/index.html b/src/pirats/templates/index.html new file mode 100644 index 0000000..09bc421 --- /dev/null +++ b/src/pirats/templates/index.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}Welcome - Rats with Gats{% endblock %} + +{% block content %} +
+
+

Steal Ships. Commit Piracy. Run on Math Magic.

+

A collaborative storytelling web-app to facilitate remote play of the whimsical roleplaying game.

+
+ +
+
+

Start a New Adventure

+

Generate a fresh game deck and take command as the crew creator.

+
+ +
+
+ +
+

Join an Existing Crew

+

Enter a Game ID or Join Link provided by your Captain.

+
+
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pirats/templates/join_form.html b/src/pirats/templates/join_form.html new file mode 100644 index 0000000..ee369b6 --- /dev/null +++ b/src/pirats/templates/join_form.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}Join Crew - Rats with Gats{% endblock %} + +{% block content %} +
+

Join the Pi-Rat Crew!

+

The mathematical transformation spell is about to cast. Enter your name below to stow away in the cargo hold.

+ +
+
+ + +
+ +
+ + +
+{% endblock %} diff --git a/src/pirats/templates/lobby_partial.html b/src/pirats/templates/lobby_partial.html new file mode 100644 index 0000000..2942803 --- /dev/null +++ b/src/pirats/templates/lobby_partial.html @@ -0,0 +1,66 @@ +
+

Ship's Hold (Lobby)

+

Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.

+ +
+

Joined Crew members

+
+ {% for p in game.players %} +
+ 🐀 + {{ p.name }} + {% if p.is_creator %}Creator{% endif %} +
+ {% endfor %} +
+
+ + + +
+ {% if player.is_creator %} + {% if game.players|length >= 1 %} + + {% else %} + + {% endif %} + {% else %} +
+
+

Waiting for Captain to cast the spell...

+
+ {% endif %} +
+
+ + +
diff --git a/src/pirats/templates/lobby_players_snippet.html b/src/pirats/templates/lobby_players_snippet.html new file mode 100644 index 0000000..97045b3 --- /dev/null +++ b/src/pirats/templates/lobby_players_snippet.html @@ -0,0 +1,7 @@ +{% for p in game.players %} +
+ 🐀 + {{ p.name }} + {% if p.is_creator %}Creator{% endif %} +
+{% endfor %} diff --git a/src/pirats/templates/scene_challenges_snippet.html b/src/pirats/templates/scene_challenges_snippet.html new file mode 100644 index 0000000..3945742 --- /dev/null +++ b/src/pirats/templates/scene_challenges_snippet.html @@ -0,0 +1,63 @@ +
+ {% for chal in game.challenges if chal.is_active %} + {% set applied_ids = json_loads(chal.applied_obstacle_ids) %} +
+

{{ chal.title }}

+ {% if chal.description %} +

{{ chal.description }}

+ {% endif %} + +
+
Applied Obstacles to beat:
+ {% for obs_id in applied_ids %} + {% set obs = get_obstacle_by_id(obs_id) %} + {% if obs %} +
+
+ {{ obs.symbol }} + {{ obs.title }} (Difficulty: + {% if parse_card(obs.original_card).value in ["J", "Q", "K"] %} + Rank ({{ player.rank }}) + {% else %} + {{ obs.current_value }} + {% endif %}) +
+ + + {% if player.role == "pirat" %} +
+ + + + + +
+ {% else %} + Waiting for Pi-Rats to resolve + {% endif %} +
+ {% endif %} + {% endfor %} +
+
+ {% else %} +

No active challenges. Deep players should describe the scene and call for challenges!

+ {% endfor %} +
diff --git a/src/pirats/templates/scene_obstacles_snippet.html b/src/pirats/templates/scene_obstacles_snippet.html new file mode 100644 index 0000000..7317437 --- /dev/null +++ b/src/pirats/templates/scene_obstacles_snippet.html @@ -0,0 +1,46 @@ +{% for obs in game.obstacles %} +
+
+ {{ obs.symbol }} {{ obs.suit.upper() }} + {{ obs.original_card }} +
+
+

{{ obs.title }}

+

{{ obs.description }}

+
+ + +
+ Current Difficulty + + {% if parse_card(obs.original_card).value in ["J", "Q", "K"] %} + Rank ({{ player_rank }}) + {% else %} + {{ obs.current_value }} + {% endif %} + +
+ + +
+
Card History (Column)
+ {% set column_cards = json_loads(obs.played_cards) %} +
+
+ {{ obs.original_card[:-1] }} + {{ obs.symbol }} +
+ {% for pc in column_cards %} +
+ {{ pc.card[:-1] if not pc.card.startswith('Joker') else '🃏' }} + {{ parse_card(pc.card).symbol }} + {{ pc.player_name[:4] }} +
+ {% endfor %} +
+
+
+{% else %} +

No active obstacles. Deep players can end the scene!

+{% endfor %} diff --git a/src/pirats/templates/scene_partial.html b/src/pirats/templates/scene_partial.html new file mode 100644 index 0000000..294d72d --- /dev/null +++ b/src/pirats/templates/scene_partial.html @@ -0,0 +1,318 @@ +
+ +
+ + +
+
+

🌊 The Obstacle List

+ 🎴 Deck: {{ deck_count }} cards left +
+ +
+ {% for obs in game.obstacles %} +
+
+ {{ obs.symbol }} {{ obs.suit.upper() }} + {{ obs.original_card }} +
+
+

{{ obs.title }}

+

{{ obs.description }}

+
+ + +
+ Current Difficulty + + {% if parse_card(obs.original_card).value in ["J", "Q", "K"] %} + Rank ({{ player.rank }}) + {% else %} + {{ obs.current_value }} + {% endif %} + +
+ + +
+
Card History (Column)
+ {% set column_cards = json_loads(obs.played_cards) %} +
+
+ {{ obs.original_card[:-1] }} + {{ obs.symbol }} +
+ {% for pc in column_cards %} +
+ {{ pc.card[:-1] if not pc.card.startswith('Joker') else '🃏' }} + {{ parse_card(pc.card).symbol }} + {{ pc.player_name[:4] }} +
+ {% endfor %} +
+
+
+ {% else %} +

No active obstacles. Deep players can end the scene!

+ {% endfor %} +
+
+ + +
+

⚔️ Active Challenges

+ +
+ {% for chal in game.challenges if chal.is_active %} + {% set applied_ids = json_loads(chal.applied_obstacle_ids) %} +
+

{{ chal.title }}

+ {% if chal.description %} +

{{ chal.description }}

+ {% endif %} + +
+
Applied Obstacles to beat (Success on any, but failures carry complications!):
+ {% for obs_id in applied_ids %} + {% set obs = get_obstacle_by_id(obs_id) %} + {% if obs %} +
+
+ {{ obs.symbol }} + {{ obs.title }} (Difficulty: + {% if parse_card(obs.original_card).value in ["J", "Q", "K"] %} + Rank ({{ player.rank }}) + {% else %} + {{ obs.current_value }} + {% endif %}) +
+ + + {% if player.role == "pirat" %} +
+ + + + + +
+ {% else %} + Waiting for Pi-Rats to resolve + {% endif %} +
+ {% endif %} + {% endfor %} +
+
+ {% else %} +

No active challenges. Deep players should describe the scene and call for challenges!

+ {% endfor %} +
+
+ + + {% if player.role == "deep" %} +
+

🌊 Deep Master Control Panel

+ + +
+

1. Call for a New Challenge:

+
+ + +
+
+ + +
+ +
+ +
+ {% for obs in game.obstacles %} + + {% endfor %} +
+
+ +
+ +
+ + +
+

3. End the Current Scene:

+

Deep players can conclude the scene once players have made attempts or the obstacle list is depleted.

+ +
+
+ {% endif %} +
+ + +
+ + +
+

🃏 Your Hand

+

Keep your cards secret! Max hand size: {{ max_hand_size }} cards. {% if is_captain(player, game.players) %} (Includes +1 Captain bonus) {% endif %}

+ +
+ {% for card in hand %} + {% set parsed = parse_card(card) %} +
+
+ {{ parsed.value }} + {{ parsed.symbol }} +
+ +
+ {% if parsed.is_joker %} + 🃏 + {% else %} + {{ parsed.symbol }} + {% endif %} +
+ + +
+ {% if parsed.value == "J" %} +
J: "{{ player.tech_jack }}"
+ {% elif parsed.value == "Q" %} +
Q: "{{ player.tech_queen }}"
+ {% elif parsed.value == "K" %} +
K: "{{ player.tech_king }}"
+ {% elif parsed.is_joker %} +
+
+ + + +
+
+ {% endif %} +
+ +
+ {{ parsed.value }} + {{ parsed.symbol }} +
+
+ {% else %} +

Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)

+ {% endfor %} +
+
+ + +
+

🐀 Your Character sheet

+ +
+
+

Description:

+

Look: {{ player.avatar_look }}

+

Smell: {{ player.avatar_smell }}

+

First Words: "{{ player.first_words }}"

+

Likes: "{{ player.other_like }}" (by {{ get_player_name(player.other_like_from_player_id) }})

+

Hates: "{{ player.other_hate }}" (by {{ get_player_name(player.other_hate_from_player_id) }})

+
+ +
+

Assigned Secret Techniques (J/Q/K):

+
    +
  • Jack (J): "{{ player.tech_jack }}"
  • +
  • Queen (Q): "{{ player.tech_queen }}"
  • +
  • King (K): "{{ player.tech_king }}"
  • +
+
+ +
+

Objectives Tracker:

+

Toggle your objectives as you complete them to earn Ranks and unlock privileges.

+
+ + +
+ Privilege: +1 to Black cards (Clubs/Spades). Tax: Can ask a Gatted player to do challenges. +
+ + + +
+ Privilege: +1 to Red cards (Hearts/Diamonds). Tax: Can ask a Named player to do challenges. +
+ + + +
+ Bonus: Rank up another player. +
+
+
+
+
+ +
+
+ + +
diff --git a/src/pirats/templates/scene_roster_snippet.html b/src/pirats/templates/scene_roster_snippet.html new file mode 100644 index 0000000..6b1e7d1 --- /dev/null +++ b/src/pirats/templates/scene_roster_snippet.html @@ -0,0 +1,27 @@ +
+

🌊 The Deep (NPCs & Hazards)

+
+ {% for p in game.players if p.role == 'deep' %} +
+ {{ p.name }} + Rank {{ p.rank }} +
+ {% else %} +

None selected

+ {% endfor %} +
+
+ +
+

🐀 Pi-Rats (Crew Players)

+
+ {% for p in game.players if p.role == 'pirat' %} +
+ {{ p.name }} + Rank {{ p.rank }}{% if is_captain(p, game.players) %} (Captain) {% endif %} +
+ {% else %} +

None selected

+ {% endfor %} +
+
diff --git a/src/pirats/templates/scene_setup_partial.html b/src/pirats/templates/scene_setup_partial.html new file mode 100644 index 0000000..6fd32ec --- /dev/null +++ b/src/pirats/templates/scene_setup_partial.html @@ -0,0 +1,117 @@ +
+

Scene Setup (Scene #{{ game.current_scene_number }})

+

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!

+ +
+ +
+

Select Your Role

+ + {% set force_pirat = False %} + {% set locked_role = None %} + + + {% if game.current_scene_number == 1 %} + {% if player.rank == 3 %} + {% set locked_role = "deep" %} + {% elif player.rank == 1 %} + {% set locked_role = "pirat" %} + {% endif %} + + {% else %} + {% if player.previous_role == "deep" %} + {% set force_pirat = True %} + {% set locked_role = "pirat" %} + {% endif %} + {% endif %} + + {% if locked_role %} +
+

+ {% 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 %} +

+
+ {{ locked_role.upper() }} +
+ +
+
+ {% else %} +
+ + + +
+ {% endif %} +
+ + +
+

Live Roster Selection

+
+
+

🌊 The Deep (NPCs & Hazards)

+
+ {% for p in game.players if p.role == 'deep' %} +
+ {{ p.name }} + Rank {{ p.rank }} +
+ {% else %} +

None selected

+ {% endfor %} +
+
+ +
+

🐀 Pi-Rats (Crew Players)

+
+ {% for p in game.players if p.role == 'pirat' %} +
+ {{ p.name }} + Rank {{ p.rank }}{% if is_captain(p, game.players) %} (Captain) {% endif %} +
+ {% else %} +

None selected

+ {% endfor %} +
+
+
+
+
+ +
+ {% if error_msg %} +
{{ error_msg }}
+ {% endif %} + + + +
+
+ + +
diff --git a/src/pirats/templates/techniques_snippet.html b/src/pirats/templates/techniques_snippet.html new file mode 100644 index 0000000..95cefee --- /dev/null +++ b/src/pirats/templates/techniques_snippet.html @@ -0,0 +1,99 @@ +
+ {% if game.phase == "character_creation" %} +

IV. Create 3 Secret Techniques

+

Create 3 techniques that you will swap with other players. Make them cool or ridiculous!

+ + {% set created_techs = json_loads(player.created_techniques) %} + {% if created_techs|length == 3 and created_techs[0] %} + + {% else %} + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+ {% endif %} + + {% elif game.phase == "swap_techniques" %} +

IV. Assign Swapped Secret Techniques

+

You received these techniques from your crew. Assign one to Jack, Queen, and King cards.

+ + {% set swapped_techs = json_loads(player.swapped_techniques) %} + + {% if player.is_ready %} +
+
J {{ player.tech_jack }}
+
Q {{ player.tech_queen }}
+
K {{ player.tech_king }}
+

+ + Ready! Waiting for other players to finish J/Q/K assignment... +

+
+ {% else %} +
+
+ + +
+
+ + +
+
+ + +
+ +
+ {% endif %} + {% endif %} +
diff --git a/src/pirats/templates/voting_roster_snippet.html b/src/pirats/templates/voting_roster_snippet.html new file mode 100644 index 0000000..bec4edb --- /dev/null +++ b/src/pirats/templates/voting_roster_snippet.html @@ -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 %} +
+ {{ p.name }} + {% if voted %}Voted{% else %}Voting...{% endif %} +
+{% endfor %} diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100644 index 0000000..048b13d --- /dev/null +++ b/tests/test_game.py @@ -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"