Compare commits
98 Commits
82ece0de10
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d68a80a3 | |||
| b9cbbda1a2 | |||
| 3e912b9458 | |||
| 929941b8ff | |||
| 9a5a319cb9 | |||
| a5bfb7905f | |||
| b253a06b45 | |||
| 8470da4aa9 | |||
| 2de4648288 | |||
| 3fe3333768 | |||
| feb595af16 | |||
| 1a4bc86139 | |||
| 2210bcd48e | |||
| bf800a06db | |||
| 692c6d26c1 | |||
| 122e66b817 | |||
| ccb32e7103 | |||
| 5e9f9bfb13 | |||
| b1a559cf39 | |||
| 6fa7073f10 | |||
| b18bf683a9 | |||
| e1779292e5 | |||
| 1c8db78c43 | |||
| c03efd4a65 | |||
| e4c78391fc | |||
| 922fc38c0a | |||
| cf9ee33b52 | |||
| 17160f9d9b | |||
| 44f7fd939b | |||
| 5b5087ac23 | |||
| daeac91d07 | |||
| 045857a8fd | |||
| 2ea91c066a | |||
| 8614a6c820 | |||
| 44bbb88836 | |||
| 7825ad2cca | |||
| 473fd74a00 | |||
| ceaf88ef22 | |||
| 39dc07d6b6 | |||
| 5a8919d967 | |||
| b37b655118 | |||
| bfe982251e | |||
| 5a9d712c95 | |||
| 45fefc0c71 | |||
| f40724c9d2 | |||
| 3bb4940df7 | |||
| 55f4085d7a | |||
| 56cdebdeda | |||
| 6964aeabb6 | |||
| 0bce291ab9 | |||
| a7f174b214 | |||
| e7db19f27e | |||
| 428af784e3 | |||
| d04e5013fa | |||
| 345ef4088f | |||
| d68333ec05 | |||
| fb17c632b0 | |||
| e502155af7 | |||
| 952b1be872 | |||
| ba32c96b7b | |||
| fa37cffe47 | |||
| 121f2f7498 | |||
| 5ad9c9db07 | |||
| e24e6d3b46 | |||
| 3395b127b4 | |||
| f61201144f | |||
| df7a0cc83f | |||
| 4e262a5345 | |||
| 5f647d1940 | |||
| 96eeb442fd | |||
| 0535c68647 | |||
| 0744893e7b | |||
| ba6bf35579 | |||
| b72aea9747 | |||
| 4fcd2b693c | |||
| 683c60fff9 | |||
| 69e7d24e10 | |||
| ed7900656d | |||
| bc867c95a1 | |||
| 3a00774b50 | |||
| 6dcb2a9ae9 | |||
| a074ff77b1 | |||
| a7cab7bcb6 | |||
| 443acd8400 | |||
| 2af98a3eeb | |||
| 58a7e4d4f1 | |||
| 29860061c4 | |||
| 1b8452bf56 | |||
| d7f8483a41 | |||
| 2ddcb86cc0 | |||
| 2d57973cd8 | |||
| 41731459a7 | |||
| e312d8efc1 | |||
| 8650ebe5b2 | |||
| 274ae3fb07 | |||
| a5d8ba6709 | |||
| 66777ab1bc | |||
| af9133258c |
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "pirats",
|
||||||
|
"runtimeExecutable": ".venv/bin/python",
|
||||||
|
"runtimeArgs": ["-m", "uvicorn", "pirats.main:app", "--host", "127.0.0.1", "--port", "8123"],
|
||||||
|
"port": 8123,
|
||||||
|
"env": { "DATABASE_URL": "sqlite:///smoke_test.db", "PYTHONPATH": "src" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.venv
|
||||||
|
rats_with_gats.db
|
||||||
|
smoke_test.db
|
||||||
|
result
|
||||||
|
*.pdf
|
||||||
|
# Built Svelte frontend (copied in from frontend/dist by the Nix build)
|
||||||
|
src/pirats/static/
|
||||||
|
# Machine-local Claude Code permission approvals
|
||||||
|
.claude/settings.local.json
|
||||||
|
# Emacs autosaves
|
||||||
|
\#*
|
||||||
35
AGENTS.md
Normal file
35
AGENTS.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
Use the version of Python/Pytest in the .venv directory when attempting to run commands, as that's where the pip dependencies (and pytest) are installed.
|
||||||
|
|
||||||
|
## Database migrations
|
||||||
|
|
||||||
|
Schema changes use Alembic (migrations live in `src/pirats/migrations/`, shipped inside the package). The app applies them automatically at startup via `pirats.database.run_migrations()`; databases predating Alembic are normalized and stamped at the `0001` baseline first.
|
||||||
|
|
||||||
|
After editing `src/pirats/models.py`, generate a migration and sanity-check it:
|
||||||
|
|
||||||
|
```
|
||||||
|
.venv/bin/alembic revision --autogenerate -m "describe the change"
|
||||||
|
.venv/bin/alembic upgrade head # or just start the app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on adding NOT NULL columns in SQLite:**
|
||||||
|
SQLite does not support adding a `NOT NULL` column without a default value to an existing table. When Alembic auto-generates a migration that adds a `nullable=False` column, you MUST manually edit the migration to include `server_default="..."` in the `sa.Column` definition (e.g., `server_default=""` for strings) before applying it. Otherwise, the migration will crash with `Cannot add a NOT NULL column with default value NULL`.
|
||||||
|
|
||||||
|
Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list exists only to upgrade pre-Alembic databases to the baseline and must not grow.
|
||||||
|
|
||||||
|
## Learning during testing
|
||||||
|
|
||||||
|
When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions.
|
||||||
|
|
||||||
|
- If Alembic autogeneration says the target database is not up to date, create a temporary database with `DATABASE_URL=sqlite:////tmp/<name>.db .venv/bin/alembic upgrade head`, then run the autogeneration command with that same `DATABASE_URL`.
|
||||||
|
|
||||||
|
## Versioning & changelog
|
||||||
|
|
||||||
|
The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
|
||||||
|
|
||||||
|
- Bump `VERSION` by one on **every** commit.
|
||||||
|
- When a commit changes something players can see, add an entry to the **top** of `CHANGELOG` (`{ version, date, changes: [...] }`) describing it in player-facing terms. Group everything shipping under one version into a single entry.
|
||||||
|
- The changelog is for players: skip refactors, tests, tooling, and other internal-only changes. A commit with no user-facing change bumps `VERSION` but adds no entry.
|
||||||
|
|
||||||
|
## Work order
|
||||||
|
|
||||||
|
You will be working on tasks in [TODO.md](./TODO.md]. Work on at most two tasks at a time (one is preferable, but two is fine if they dove tail really nicely), and after you finish each task, make a git commit and update the TODO list to check off the completed task.
|
||||||
189
EXAMPLESCENE.md
Normal file
189
EXAMPLESCENE.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Rats with Gats - Full Example Scene: The Cheesy Heist
|
||||||
|
|
||||||
|
This is a complete example of a Scene you might play out in a game of *Rats with Gats*!
|
||||||
|
|
||||||
|
For the core rules of the game, see [RULEBOOK.md](file:///Users/ttm/Projects/pirats/RULEBOOK.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Players and their Pi-Rats
|
||||||
|
* **Gabe**: playing "Whiskey Shrinkage" (Nameless, Rank 3)
|
||||||
|
* **Sally**: playing the Deep for this scene (normally plays "Piss Whiskers")
|
||||||
|
* **Jake**: playing "Carlos" (Named, Rank 3, Captain)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setting the Scene
|
||||||
|
|
||||||
|
### Step 1: Declare Roles!
|
||||||
|
* **Gabe**: "I'll play my Pi-Rat in this scene, since I was the Deep last Scene."
|
||||||
|
* **Sally**: "I'll be the Deep this time. Piss Whiskers needs a bath, anyway."
|
||||||
|
* **Jake**: "Carlos is in! And I'm still the Captain, ya hear? So you rats better listen up!"
|
||||||
|
|
||||||
|
### Step 2: Shuffle and Draw Obstacles!
|
||||||
|
Sally returns all discarded cards to the deck and shuffles thoroughly. There's currently one Obstacle on the Obstacle List from the last scene (**7 of Hearts: Bad Breakups**). Since there's only one Deep Player (Sally) in this scene, she needs to draw cards until there are a total of two Obstacles on the list.
|
||||||
|
|
||||||
|
Sally draws from the top of the deck: **10 of Hearts: Cheese Thief**
|
||||||
|
|
||||||
|
The Obstacle List now has:
|
||||||
|
1. **7 of Hearts: Bad Breakups** (Value: 7)
|
||||||
|
2. **10 of Hearts: Cheese Thief** (Value: 10)
|
||||||
|
|
||||||
|
### Step 3: Frame the Scene!
|
||||||
|
* **Sally (Deep)**: "Alright, so you rowdy rats are in the galley below deck, drinking grog, and shootin' the breeze. The crew's been tense ever since that breakup between Salty Socks Pete and Long-Tail Lamar split everyone into factions. But NOW, someone's been stealing the cheese rations, and the crew is absolutely losing their minds about it. Pete's faction blames Lamar's, and Lamar's faction blames Pete's! You all can hear angry shouting from the deck above."
|
||||||
|
* **Gabe**: "Is there any cheese left?"
|
||||||
|
* **Sally (Deep)**: "There's one wheel of aged cheddar sitting on the galley table, guarded by a rotating shift of Pi-Rats from both factions."
|
||||||
|
* **Jake**: "Perfect. Carlos wants that cheese. That's a captain's cheese."
|
||||||
|
* **Gabe**: "I'm on it, Boss!"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Playing Out the Scene!
|
||||||
|
|
||||||
|
* **Jake**: "Carlos struts into the galley, adjusting his fancy captain's hat. 'Aye, what's all this commotion about cheese, ya yeasty beasties?'"
|
||||||
|
* **Sally (Deep)**: "The two guards immediately turn on you. 'Captain! Someone's been stealin' our cheese! How could you let this happen? In this economy, no less!' They're getting pretty heated. I'm calling for a Challenge—can Carlos calm them down before this turns into a mutiny?"
|
||||||
|
|
||||||
|
### Challenge 1: Carlos Calms the Crew
|
||||||
|
|
||||||
|
#### Step 1: Establish Stakes!
|
||||||
|
* **Sally (Deep)**: "If you succeed, you calm the crew and maybe figure out who the Cheese Thief is. If you fail, the crew's anger starts growing out of control, and this could get violent."
|
||||||
|
|
||||||
|
#### Step 2: Apply Obstacles!
|
||||||
|
* **Sally (Deep)**: "I'm applying both Obstacles. The **Bad Breakups** Obstacle means half the crew won't listen to anything the other half says, and the **Cheese Thief** Obstacle represents how absolutely furious everyone is about the missing cheese. Bad Breakups is currently value 7, and Cheese Thief is value 10."
|
||||||
|
|
||||||
|
#### Step 3: Play Cards!
|
||||||
|
Jake looks at his hand: **8 of Spades**, **3 of Hearts**, **Queen of Diamonds**, **Jack of Clubs**.
|
||||||
|
* **Jake**: "Oof, that 10 is rough, but I got this. I'll play my 3 of Hearts against the Cheese Thief Obstacle, and my 8 of Spades against the Bad Breakups Obstacle."
|
||||||
|
|
||||||
|
#### Step 4: Place Cards!
|
||||||
|
Jake places his 3 of Hearts face-up in a column below the **10 of Hearts: Cheese Thief** card, rotating it 90°. He does the same with his 8 of Spades below the **7 of Hearts: Bad Breakups** card.
|
||||||
|
|
||||||
|
The Obstacle List now shows:
|
||||||
|
1. **7 of Hearts: Bad Breakups**
|
||||||
|
* *Card underneath*: 8 of Spades (rotated 90°)
|
||||||
|
* *New Value*: 8 (1 Success)
|
||||||
|
2. **10 of Hearts: Cheese Thief**
|
||||||
|
* *Card underneath*: 3 of Hearts (rotated 90°)
|
||||||
|
* *New Value*: 3 (0 Success - 3 is less than 10)
|
||||||
|
|
||||||
|
#### Step 5: Narrate Results!
|
||||||
|
* **Jake**: "Since I played Spades—Sneakin' and Schemin'—Carlos cleverly pulls aside the guards from both breakup factions and whispers to each that the OTHER side is clearly innocent. He gets them to make peace, at least temporarily. BUT, I did fail against the Cheese Thief obstacle..."
|
||||||
|
* **Sally (Deep)**: "Yeah, the crew unites under their Captain—but they also seem to be itching for some mob justice. Things could still escalate quickly!"
|
||||||
|
|
||||||
|
#### Step 6: Draw Cards!
|
||||||
|
Jake played a red card (3 of Hearts) against a red Obstacle (10 of Hearts). Since both were red, Jake draws 1 card and adds it to his hand.
|
||||||
|
* **Sally (Deep)**: "Challenge resolved! Good job. Gabe, what are you up to during this time?"
|
||||||
|
* **Gabe**: "While everyone's yelling at Carlos, I sneak toward the cheese wheel on the table, looking for clues, and maybe to steal it."
|
||||||
|
* **Sally (Deep)**: "Oh no you don't! Challenge time! Can you get close enough to examine and maybe swipe that cheese without getting caught?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Challenge 2: Whiskey Shrinkage Sneaks the Cheese
|
||||||
|
|
||||||
|
#### Step 1: Establish Stakes!
|
||||||
|
* **Sally (Deep)**: "Success means you get the cheese and some answers. Failure means you get caught red-handed and the crew knows YOU'RE the real Cheese Thief."
|
||||||
|
|
||||||
|
#### Step 2: Apply Obstacles!
|
||||||
|
* **Sally (Deep)**: "I'm only applying the Cheese Thief Obstacle. The crew's watching you like rat-hawks. Its current value is 3 (updated from the previous challenge)."
|
||||||
|
|
||||||
|
#### Step 3: Play Cards!
|
||||||
|
Gabe looks at his hand: **2 of Clubs**, **9 of Hearts**, **King of Spades**.
|
||||||
|
* **Gabe**: "I'm going all out. I'll play my King of Spades! That's a Secret Pirate Technique!"
|
||||||
|
* **Sally (Deep)**: "Nice! What is your King Technique?"
|
||||||
|
* **Gabe** (checks his Rat Records): "My Kings are... *Secret Pirate Technique #79: 'There's always a secret entrance sometimes.'* Whiskey Shrinkage doesn't even go for the cheese on the table. He knows there's a secret compartment under the floorboards where the REAL cheese stash is hidden. He pops it open and pulls out an entire log of fancy Havarti!"
|
||||||
|
* **Sally (Deep)**: "You automatically succeed!"
|
||||||
|
|
||||||
|
#### Step 4: Place Cards!
|
||||||
|
Gabe places his King of Spades in the column below the Cheese Thief Obstacle, rotating it 90°.
|
||||||
|
|
||||||
|
The Obstacle List now shows:
|
||||||
|
1. **7 of Hearts: Bad Breakups**
|
||||||
|
* *Cards underneath*: 8 of Spades (rotated 90°)
|
||||||
|
* *Value*: 8 (1 Success)
|
||||||
|
2. **10 of Hearts: Cheese Thief**
|
||||||
|
* *Cards underneath*: 3 of Hearts (rotated 90°) $\rightarrow$ King of Spades (rotated 90°)
|
||||||
|
* *New Value*: 3 (Gabe's current Rank) (1 Success - Face card value equals player's Rank)
|
||||||
|
|
||||||
|
#### Step 5: Narrate Results!
|
||||||
|
* **Gabe**: "After opening the secret compartment, Whiskey Shrinkage casually walks out of the galley with a wheel of cheese under each arm while everyone's still yelling at Carlos."
|
||||||
|
|
||||||
|
#### Step 6: Draw Cards!
|
||||||
|
Gabe played a King of Spades (Black) against an Obstacle that's Red (10 of Hearts), so he doesn't draw any cards.
|
||||||
|
* **Gabe**: "Whiskey Shrinkage is up on deck now, eating cheese openly. He's taunting the crew with it, hoping to draw out the Cheese Thief. 'Will the owner of this delicious havarti come to the lost and found?' I'll make sure everyone can see me, too."
|
||||||
|
* **Sally (Deep)**: "Uh oh! Crew members from across the deck take notice, and everyone is looking around to see who steps up for the cheese! Just as it seems no one is willing to accept ownership, a Pi-Rat jumps down from the crow's nest with her knives drawn, and barrels down on Whiskey Shrinkage. Challenge time!"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Challenge 3: The Crows Nest Confrontation
|
||||||
|
|
||||||
|
#### Step 1: Establish Stakes!
|
||||||
|
* **Sally (Deep)**: "If you succeed, you fight off the Cheese Thief and keep the cheese you found. If you fail, the Cheese Thief beats you up and accuses you of being the mastermind behind the cheese theft."
|
||||||
|
|
||||||
|
#### Step 2: Apply Obstacles!
|
||||||
|
* **Sally (Deep)**: "I'm applying the **Cheese Thief** Obstacle again—they're absolutely enraged. Its current value is 3. I'm also applying the **Bad Breakups** Obstacle again, since tensions are so high right now. Its current value is 8."
|
||||||
|
* **Jake (Captain)**: "Wait! I want to help! Can Carlos assist?"
|
||||||
|
* **Sally (Deep)**: "Sure! You can play a card against one of the Obstacles applied to the Challenge."
|
||||||
|
|
||||||
|
#### Step 3: Play Cards!
|
||||||
|
* **Gabe**: "I'll play my 9 of Hearts against the Cheese Thief Obstacle. That beats the 3!"
|
||||||
|
* **Jake**: "And I'll play my Queen of Hearts against the Bad Breakups Obstacle to help out. That's my *'when in doubt, flirt your way out!'* Secret Pirate Technique, which automatically beats the Bad Breakups Obstacle value of 8."
|
||||||
|
|
||||||
|
#### Step 4: Place Cards!
|
||||||
|
Gabe places his 9 of Hearts below the Cheese Thief Obstacle. Jake places their Queen of Hearts below the Bad Breakups Obstacle.
|
||||||
|
|
||||||
|
The Obstacle List now shows:
|
||||||
|
1. **7 of Hearts: Bad Breakups**
|
||||||
|
* *Cards underneath*: 8 of Spades (rotated 90°) $\rightarrow$ Queen of Hearts (rotated 90°)
|
||||||
|
* *New Value*: Rank (Jake's Rank of 3) (2 Successes)
|
||||||
|
2. **10 of Hearts: Cheese Thief**
|
||||||
|
* *Cards underneath*: 3 of Hearts (rotated 90°) $\rightarrow$ King of Spades (rotated 90°) $\rightarrow$ 9 of Hearts (rotated 90°)
|
||||||
|
* *New Value*: 9 (2 Successes)
|
||||||
|
|
||||||
|
#### Step 5: Narrate Results!
|
||||||
|
* **Gabe**: "I played Hearts—Shoutin' and Singin'—so Whiskey Shrinkage starts singing a sea shanty about cheese so beautiful and ridiculous that the sailors stop in their tracks, mesmerized!"
|
||||||
|
* **Jake**: "Carlos joins in with a sexy rat dance, and does their best to give off 'hot captain' vibes."
|
||||||
|
* **Sally (Deep)**: "Weird, but whatever. You both share in the success—the cheese is safe, and the original culprit puts her knives away and surrenders to the crew. 'I would have gotten away with it too if it wasn't for your stupid musical number!' the Cheese Thief says."
|
||||||
|
|
||||||
|
#### Step 6: Draw Cards!
|
||||||
|
* Gabe played a 9 of Hearts (Red) against a red Obstacle, so he draws 1 card.
|
||||||
|
* Jake played a Queen of Hearts (Red) against a red Obstacle, so he would draw 1 card, but because he was assisting, he does *not* draw.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending the Scene
|
||||||
|
|
||||||
|
* **Sally (Deep)**: "Alright, let's check the requirements for ending the Scene. Both Pi-Rat Players have been challenged at least once. Has anyone made progress on their Objectives?"
|
||||||
|
* **Gabe**: "Whiskey Shrinkage doesn't have a real name yet, but maybe I earned one with my clever detective skills and singing?"
|
||||||
|
* **Sally (Deep)**: "Sure, but I don't know if that was name-worthy. We can always have a vote to see if you completed this Objective, if you'd like?"
|
||||||
|
* **Gabe**: "Yeah, let's do it! Who thinks I earned a real Pirate name and completed my second Personal Objective?"
|
||||||
|
* **Sally (Deep)**: "I vote yes."
|
||||||
|
* **Jake**: "Same here!"
|
||||||
|
* **Gabe**: "Well, I'm definitely voting in favor of myself!"
|
||||||
|
* **Sally (Deep)**: "It looks like you got a name! What is it?"
|
||||||
|
* **Gabe**: "You can call me… **Tim**."
|
||||||
|
* **Sally (Deep)**: "Yeah, yeah, alright 'Tim'. Make sure to write down your new name and mark off that you completed your second Objective. What about Jake?"
|
||||||
|
* **Jake**: "Carlos is the Captain, so that's Crew Objective 2 already completed. And we control the ship, so Crew Objective 1 is done. But we haven't committed any piracy yet. I don't think I completed any Personal Objectives during this Scene, but I did my duties as Captain!"
|
||||||
|
* **Gabe**: "Agreed!"
|
||||||
|
* **Sally (Deep)**: "Fair enough. And we still have both Obstacles on the list, so they're not cleared, but both are close to being discarded (2 successes each, in a 3-player game they need 3 successes to be discarded). I think there's more story here. But actually, I'm going to end the scene here anyway. This was a good warm-up Scene! You rats have had enough fun for now, and we can always come back to this cheese situation later."
|
||||||
|
* **Jake**: "Okay!"
|
||||||
|
* **Gabe**: "Sounds good!"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Between Scenes
|
||||||
|
|
||||||
|
### Step 1: Pirate Ranking (Voting)
|
||||||
|
* **Gabe**: "I vote for Carlos. They stepped up to defend me when it could have gone really bad."
|
||||||
|
* **Jake**: "I vote for Whiskey Shrinkage—sorry, I mean Tim. That Secret Pirate Technique with the hidden cheese was amazing."
|
||||||
|
* **Sally**: "I'm giving my vote to Gabe as well. Tim gets a Rank!"
|
||||||
|
* *Tim's Rank increases from 3 to 4.* (Updated in Rat Records)
|
||||||
|
|
||||||
|
### Step 2: Additional Rank Bonuses
|
||||||
|
Gabe completed his second Personal Objective too, so he increases his Rank again, putting him at **5**! Tim is now tied with Carlos for the highest-ranking Pi-Rat, which means Gabe's Hand size increases by 1 (to 4 cards). Because his Hand size increased, Gabe immediately draws a card from the deck.
|
||||||
|
|
||||||
|
### Step 3: Deep Players Draw Cards
|
||||||
|
Sally was the Deep, so she may discard any cards in her hand, and then draws back up to her maximum Hand size, which is now **2** (since she has the lowest Rank of all Pi-Rats, at Rank 2).
|
||||||
|
|
||||||
|
### Step 4: Start a New Scene!
|
||||||
|
* **Sally**: "Okay, next scene! Let's see what other chaos these rats get into..."
|
||||||
|
*(Someone shuffles the deck and everyone prepares for the next scene.)*
|
||||||
92
OBSTACLES.md
Normal file
92
OBSTACLES.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Rats with Gats - Obstacle Type Charts
|
||||||
|
|
||||||
|
This file contains the full list of Obstacle Type Charts for *Rats with Gats*, categorized by card suit. During the game, when the Deep draws an Obstacle card, its suit and value determine which threat from these tables is introduced.
|
||||||
|
|
||||||
|
For the core rules of the game, see [RULEBOOK.md](file:///Users/ttm/Projects/pirats/RULEBOOK.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Clubs: Unwanted Passengers
|
||||||
|
These types of Obstacles represent unexpected visitors, stowaways, and boarding parties.
|
||||||
|
|
||||||
|
| Card | Name | Description |
|
||||||
|
| :---: | :--- | :--- |
|
||||||
|
| **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. For whatever reason, the Pi-Rats have captured the Goblins' attention, and now they won't leave the ship alone! At least the goblins are mostly harmless, even if they are 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 Mermaids do this professionally as saboteurs, while others simply do it for the love of the game. |
|
||||||
|
| **6** | **Mergang** | A group of Mermaid Privateers have emerged from the ocean below to board the ship in a good old-fashioned robbery. While these Mermaids aren't necessarily out for blood, 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 Deep Mage is currently inspecting and investigating all seafaring vessels in the area, looking for traitors, turncoats, and spies working directly against the Deep. It's probably best to cooperate and hope we get a pass! |
|
||||||
|
| **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. |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **Ghosts**: Sometimes the Pi-Rats must face an unwanted passenger again, even if they had killed them in a previous Scene. When this happens, the unwanted passenger will return as a ghost, haunting the crew in obstructive ways and creating challenges like any other Obstacle would.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spades: Oceanic Occurrences
|
||||||
|
These types of Obstacles represent environmental dangers and external threats from the sea.
|
||||||
|
|
||||||
|
| Card | Name | Description |
|
||||||
|
| :---: | :--- | :--- |
|
||||||
|
| **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 most pirates end up surviving it, so no hard feelings! |
|
||||||
|
| **6** | **Wicked Whirlpool** | A Whirlpool of epic proportions has captured every vessel in the area, sending everyone spiraling. The Whirlpool threatens to pull the ship down, along with everything else. 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 Kingdom's coastline. The fortress has decimated Legion ships for a season, and most pirates have fled the area. 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, and crew members are freezing in place across the ship. Worst 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! Get below deck! |
|
||||||
|
| **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 ships, stacked and strewn across the sea. Each ship in the graveyard was torn apart by the hands of the massive Mermaid, as she scoured the vessels for any tasty crew to eat. Don't let her see us, or we'll be next! |
|
||||||
|
| **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 the island for days, and a few have even claimed to have heard whispers and voices beckoning the crew to investigate. We need answers! |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hearts: Pi-Rat Problems
|
||||||
|
These types of Obstacles represent internal crew conflicts and personal issues.
|
||||||
|
|
||||||
|
| Card | Name | Description |
|
||||||
|
| :---: | :--- | :--- |
|
||||||
|
| **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 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. Crew members have lost all their possessions, and there's even been talk about "collecting kidneys" next. 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, and now it's come for the crew. 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. If this keeps going, 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. Crimes like these are too important to let slide. Everyone stop what you're doing! 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! |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diamonds: Ship Troubles
|
||||||
|
These types of Obstacles represent damage and malfunctions affecting the vessel.
|
||||||
|
|
||||||
|
| Card | Name | Description |
|
||||||
|
| :---: | :--- | :--- |
|
||||||
|
| **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. Those fools! Now everyone is sliding around. Some have even slipped overboard! |
|
||||||
|
| **3** | **Broken Straps** | Loose cargo has broken from its secured position and is rolling around dangerously all over the ship. The cargo must be collected and 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 a bunch of starving, cranky, and sober mutineers on board! |
|
||||||
|
| **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 Rudder** | Something has jammed or damaged the rudder (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, wreckage, or seabed, or it won't deploy at all, causing the ship to drift in the wind and waves! |
|
||||||
|
| **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. Some of the crew have already fled, but the ship can still be saved if everyone works together! |
|
||||||
|
| **J** | **Mystery Leak** | Somewhere on board is a leak, and no one can seem to find it. Maybe it's random magical phenomena, or maybe it's something more nefarious? If the leak isn't dealt with, we're all going to wake up underwater! |
|
||||||
|
| **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. |
|
||||||
188
README.md
Normal file
188
README.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Rats with Gats Remote Play (`pirats`)
|
||||||
|
|
||||||
|
A web-based remote play companion app for the tabletop roleplaying game **Rats with Gats** (where players roleplay as gunslinging pirate rats, or "Pi-Rats", in the magical land of Yeld). The application manages lobbies, character creation, card mechanics, scene phases, obstacles, challenges, and player voting. See [RULEBOOK.md](RULEBOOK.md) for the game rules.
|
||||||
|
|
||||||
|
It is built with a **FastAPI** + **SQLModel** (SQLite) backend and a **Svelte 5** single-page frontend (in `frontend/`), which polls the backend's JSON state endpoint for near-real-time updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Game Lobby:** Create games and join as players via a unique game ID.
|
||||||
|
- **Character Creation:**
|
||||||
|
- Fill out the character profile (appearance, smell, catchphrase).
|
||||||
|
- Delegate custom "Like/Hate" relationship questionnaire cards to other crewmates (answering them for each other!).
|
||||||
|
- Draft three "Secret Pirate Techniques" which are automatically shuffled and swapped among players.
|
||||||
|
- Bind swapped techniques to Face Cards (Jack, Queen, King) for play.
|
||||||
|
- **Dynamic Card Mechanics:**
|
||||||
|
- Automated 54-card deck shuffling and card drawing.
|
||||||
|
- Automatically matches card suits to thematic obstacles (Clubs for combat, Spades for stealth/cunning, Hearts for social/morale, Diamonds for nautical/athletics).
|
||||||
|
- **Scene Management:**
|
||||||
|
- Assign roles (Pi-Rats or "The Deep" narrator).
|
||||||
|
- Dynamic challenge creation linking multiple active obstacles.
|
||||||
|
- Automatically handles card matching colors/values, automatic success when playing Face Card techniques, and Joker card triggers to discard/replace obstacles.
|
||||||
|
- **Between-Scenes Voting:**
|
||||||
|
- Vote on crewmates to rank up.
|
||||||
|
- Toggle personal objectives (Get a Gat, Earn a Name, Die like a Pirate).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation & Running
|
||||||
|
|
||||||
|
This project supports standard Python package tools as well as Nix flakes.
|
||||||
|
|
||||||
|
### Option 1: Standard Python Installation
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
- Python >= 3.9
|
||||||
|
- Virtual environment tool (optional but recommended)
|
||||||
|
|
||||||
|
#### 1. Setup Virtual Environment (Optional)
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Install Package
|
||||||
|
Install the package and its dependencies in editable mode:
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Run the App
|
||||||
|
Launch the FastAPI server using the installed package entrypoint:
|
||||||
|
```bash
|
||||||
|
pirats --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
Alternatively, run it with `uvicorn`:
|
||||||
|
```bash
|
||||||
|
uvicorn pirats.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Once running, access the web UI at [http://localhost:8000](http://localhost:8000).
|
||||||
|
|
||||||
|
#### Configuration (environment variables)
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `DATABASE_URL` | `sqlite:///./rats_with_gats.db` | SQLAlchemy database URL. |
|
||||||
|
| `PIRATS_LOG_FILE` | _(unset)_ | If set, also write logs to this file (rotating, 10 MB × 5 backups). Logs always go to stderr regardless. |
|
||||||
|
| `PIRATS_LOG_LEVEL` | `INFO` | Minimum log severity (`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`). |
|
||||||
|
| `PIRATS_DEV_MODE` | _(unset = on)_ | Default Dev Mode for new games. Unset is treated as a local checkout (on); set to `0`/`false` to disable. |
|
||||||
|
| `PIRATS_PURGE_ENABLED` | `true` | Periodically purge old finished/inactive games. |
|
||||||
|
| `PIRATS_PURGE_INTERVAL_HOURS` | `24` | How often the purge task runs. |
|
||||||
|
| `PIRATS_PURGE_FINISHED_DAYS` | `14` | Purge games that finished more than this many days ago. |
|
||||||
|
| `PIRATS_PURGE_INACTIVE_DAYS` | `30` | Purge games with no activity for this many days. |
|
||||||
|
| `PIRATS_MAX_BODY_BYTES` | `1048576` | Reject request bodies larger than this (HTTP 413). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 2: Using Nix Flakes
|
||||||
|
|
||||||
|
If you use the Nix package manager, this repository provides a complete environment.
|
||||||
|
|
||||||
|
#### 1. Enter the Development Shell
|
||||||
|
Includes Python, all runtime dependencies, and testing tools (`pytest`):
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Run the Application
|
||||||
|
Run the package directly from the flake:
|
||||||
|
```bash
|
||||||
|
nix run . -- --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Build the Application Package
|
||||||
|
```bash
|
||||||
|
nix build .
|
||||||
|
```
|
||||||
|
The executable will be located in `./result/bin/pirats`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploying as a NixOS Service
|
||||||
|
|
||||||
|
The Nix flake exposes a NixOS module that configures the application to run as a systemd service with appropriate sandboxing.
|
||||||
|
|
||||||
|
Add this flake to your system configuration and configure the service:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
services.pirats = {
|
||||||
|
enable = true;
|
||||||
|
host = "127.0.0.1";
|
||||||
|
port = 8000;
|
||||||
|
databasePath = "/var/lib/pirats/rats_with_gats.db";
|
||||||
|
logFile = "/var/log/pirats/pirats.log"; # rotating; stderr also goes to the journal
|
||||||
|
logLevel = "info";
|
||||||
|
openFirewall = false; # Set to true to open ports in the firewall
|
||||||
|
purge = {
|
||||||
|
enable = true; # periodically remove old finished/inactive games
|
||||||
|
intervalHours = 24;
|
||||||
|
finishedDays = 14; # purge games finished more than 14 days ago
|
||||||
|
inactiveDays = 30; # ...or with no activity for 30 days
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The service writes its log to `logFile` (under the systemd-managed `/var/log/pirats`
|
||||||
|
`LogsDirectory`, so the sandboxed `DynamicUser` can write to it) and additionally
|
||||||
|
to the systemd journal via stderr (`journalctl -u pirats`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Tests are written using `pytest`.
|
||||||
|
|
||||||
|
### Running with Pip
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running with Nix
|
||||||
|
```bash
|
||||||
|
nix develop --command pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pirats/
|
||||||
|
├── pyproject.toml # Python package metadata and dependencies
|
||||||
|
├── flake.nix # Nix package (incl. frontend build), dev shell, NixOS service module
|
||||||
|
├── tests/
|
||||||
|
│ └── test_game.py # Game logic and CRUD test suite (pure-crud, no HTTP for most)
|
||||||
|
├── frontend/ # Svelte 5 + Vite SPA
|
||||||
|
│ └── src/
|
||||||
|
│ ├── pages/ # Routes: Home, Join, Dashboard (game UI), Admin
|
||||||
|
│ ├── components/ # One component per game phase, Card.svelte, and scene/ (ScenePhase sub-panels)
|
||||||
|
│ └── lib/ # api.js (fetch wrapper), cards.js (card display helpers), suggestions.js (Suggest-button pools)
|
||||||
|
└── src/
|
||||||
|
└── pirats/ # FastAPI backend package
|
||||||
|
├── main.py # App setup, /api/game create/join/state endpoints, CLI entrypoint
|
||||||
|
├── models.py # SQLModel tables (Game, Player, Obstacle, Challenge, Vote, GameEvent)
|
||||||
|
├── cards.py # Card parsing, deck building, obstacle table from the rulebook
|
||||||
|
├── crud.py # Facade re-exporting all crud_* modules
|
||||||
|
├── crud_*.py # Game logic by phase: base (deck/hands/rank), character, scene, challenge, upkeep
|
||||||
|
├── routes_*.py # Thin API routers by phase, mounted under /api
|
||||||
|
└── static/ # Built frontend lands here (gitignored; populated by the Nix build)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
|
||||||
|
Start the backend and Vite dev server together from the repository root. Pressing Ctrl-C stops both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To run only the frontend, start the backend separately on port 8000, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite proxies `/api` to the backend on port 8000.
|
||||||
309
RULEBOOK.md
Normal file
309
RULEBOOK.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# Rats with Gats: A Story Game about the Pi-Rats of Yeld!
|
||||||
|
|
||||||
|
**Written & Designed by**: Nick Smith
|
||||||
|
**Art by**: J. Richmond
|
||||||
|
**Yeld Team**: Sally Hsu (Producer), Maia and EmmaVoid (Support)
|
||||||
|
*Copyright 2025 Nick Smith and J. Richmond. The Magical Land of Yeld is a product of Yeld LLC.*
|
||||||
|
|
||||||
|
Welcome to **Rats with Gats**, a collaborative storytelling game about ordinary stowaway rats onboard a ship who are transformed into gunslinging Pi-Rat pirates through the radical power of Math Magic!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference Links
|
||||||
|
* [OBSTACLES.md](file:///Users/ttm/Projects/pirats/OBSTACLES.md) - Tables of Obstacle Types by suit (Clubs, Spades, Hearts, Diamonds)
|
||||||
|
* [EXAMPLESCENE.md](file:///Users/ttm/Projects/pirats/EXAMPLESCENE.md) - Full walkthrough of a play scene ("The Cheesy Heist")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Table of Contents
|
||||||
|
1. [Part 1: Introduction](#part-1-introduction)
|
||||||
|
2. [Part 2: Creating a Pi-Rat](#part-2-creating-a-pi-rat)
|
||||||
|
* [Rank and Hand Size](#rank-and-hand-size)
|
||||||
|
* [Secret Pirate Techniques](#secret-pirate-techniques)
|
||||||
|
* [Objectives](#objectives)
|
||||||
|
* [Personal Objectives](#personal-objectives) (Gat & Name Privileges/Taxes)
|
||||||
|
* [Crew Objectives](#crew-objectives) (Captain rules)
|
||||||
|
3. [Part 3: Playing The Game](#part-3-playing-the-game)
|
||||||
|
* [Setting Scenes](#setting-scenes)
|
||||||
|
* [Playing Out a Scene](#playing-out-a-scene)
|
||||||
|
* [Ending a Scene](#ending-a-scene)
|
||||||
|
* [Between Scenes](#between-scenes)
|
||||||
|
* [Resolving Challenges](#resolving-challenges)
|
||||||
|
* [Using Face Cards & Jokers](#using-face-cards-jokers)
|
||||||
|
* [Advanced Resolution Mechanics & Questions](#advanced-resolution-mechanics-questions)
|
||||||
|
4. [Part 4: Setting Information](#part-4-setting-information)
|
||||||
|
5. [Final Thoughts](#final-thoughts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 1: Introduction
|
||||||
|
|
||||||
|
What Are Pi-Rats?
|
||||||
|
Pi-Rats are four-foot-tall, gun-toting, ship-stealing rodents created by accident during a long naval war between the Kingdom of Yeld and the Legions of the Deep. Pi-Rats were originally designed to be weapons—disposable soldiers who could overwhelm enemy crews. Instead, they became something far more dangerous: pirates with attitude, cunning, and an unshakeable love of violence and the sea. Pi-Rats are scrappy, loyal to their crews, and almost never die of old age (mostly because they rarely survive that long).
|
||||||
|
|
||||||
|
Your Story Begins
|
||||||
|
You and your fellow players are ordinary rats—stowaways hiding in the cargo hold of a ship sailing the dangerous waters of Yeld. But when an unknown Mathematical mage casts the “Pie” spell, everything changes. You’re suddenly bigger, smarter, and armed with an overwhelming urge to commit piracy. You’re now a Pi-Rat!
|
||||||
|
|
||||||
|
Your first order of business? Steal the ship you’re on. Deal with the current crew, establish control, and make it yours. From there, the real adventure begins: choosing a captain, assembling your crew, and committing enough piracy to earn your place in Pi-Rat legend.
|
||||||
|
|
||||||
|
The World Of Yeld
|
||||||
|
Yeld is a vast and dangerous fantasy world—a place of magic, monsters, and mystery. The oceans are controlled by the Deep, an ancient oceanic god who wages eternal war against the Vampire Prince Dragul and his Fairy armies, who rule the Kingdom of Yeld. Pi-Rats exist in the chaos between these powers, stealing ships from both sides and carving out their own chaotic existence on the high seas. Expect to encounter Mermaids, Squidfolk, vengeful ghosts, magical storms, rival pirates, and the occasional Bean Whale who’s fallen in love with your ship.
|
||||||
|
|
||||||
|
### What Do I Need To Play?
|
||||||
|
* **3–6 Players** with a strong enthusiasm for whimsical piracy or rodentia.
|
||||||
|
* **A standard deck of playing cards** with both Jokers still in there.
|
||||||
|
* **A couple sheets of paper, some note cards, and at least 1 pencil** for tracking Objectives, Pi-Rats (Rat Records), and Secret Pirate Techniques.
|
||||||
|
|
||||||
|
### Summary Of Play
|
||||||
|
1. Each Player creates a Pi-Rat crewmember and 3 Secret Pirate Techniques.
|
||||||
|
2. As a group, take turns setting scenes where some Players control their Pi-Rats while others play as the Deep.
|
||||||
|
3. During each scene, participating Pi-Rat Players attempt to complete Personal and/or Crew Objectives.
|
||||||
|
4. Deep Players draw Obstacle cards and challenge the Pi-Rats.
|
||||||
|
5. Pi-Rats play cards from their Hand to overcome Obstacles.
|
||||||
|
6. A scene ends when all Pi-Rats have fled or the Deep chooses to end it.
|
||||||
|
7. Between scenes, Players vote to Rank up crew members and rotate roles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 2: Creating a Pi-Rat
|
||||||
|
|
||||||
|
To create a Pi-Rat, answer the following 6 questions and record them on a sheet of paper (your **Rat Records**). Go with your gat!
|
||||||
|
|
||||||
|
1. **What does your Pi-Rat look like?** Pi-Rats look very similar to one another, but distinguish themselves with bandanas, eyepatches, clothing, scars, tattoos, or body paint.
|
||||||
|
2. **What does your Pi-Rat smell like?** Pi-Rats identify each other by scent. Before receiving a proper name, they are referred to by the smells they produce.
|
||||||
|
3. **What was the first thing your Pi-Rat said after transforming?** A Pi-Rat’s first words indicate their pirate personality.
|
||||||
|
4. **Is your Pi-Rat good at Math?** This has no implications or meaning.
|
||||||
|
5. **What is something other pirates like about your Pi-Rat?** Let another Player answer this question for you.
|
||||||
|
6. **What is something other pirates hate about your Pi-Rat?** Let another Player answer this question for you.
|
||||||
|
*(Don’t worry about a name right now; that comes later!)*
|
||||||
|
|
||||||
|
### Determine Starting Rank
|
||||||
|
Once questions are answered, randomly determine each Player's starting Rank:
|
||||||
|
* **One Player** starts at **Rank 3**. This Player must play the **Deep** during the first scene of the game.
|
||||||
|
* **One Player** starts 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 their Pi-Rat or the Deep during the first scene.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rank and Hand Size
|
||||||
|
|
||||||
|
Your Rank represents your status and reputation. The higher your Rank, the more respected you are. Pi-Rats gain Ranks as follows:
|
||||||
|
* Complete your first Personal Objective $\rightarrow$ **Gain 1 Rank**
|
||||||
|
* Complete your second Personal Objective $\rightarrow$ **Gain 1 Rank**
|
||||||
|
* Complete your third Personal Objective $\rightarrow$ **Choose another Pi-Rat to gain 1 Rank**
|
||||||
|
* Receive the most votes during Between Scene voting $\rightarrow$ **Gain 1 Rank**
|
||||||
|
* Special circumstances determined by the group $\rightarrow$ **Gain 1 Rank**
|
||||||
|
|
||||||
|
Your Hand size determines how many cards you have available for Challenges. Compare Ranks to determine Hand Sizes:
|
||||||
|
|
||||||
|
| Rank Placement | Maximum Hand Size |
|
||||||
|
| :--- | :---: |
|
||||||
|
| **Highest Rank Pi-Rat(s)** | 4 cards |
|
||||||
|
| **Middle Rank Pi-Rat(s)** | 3 cards |
|
||||||
|
| **Lowest Rank Pi-Rat(s)** | 2 cards |
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Anytime your Hand size increases, you immediately draw a card and add it to your Hand. This may happen in the middle of a Scene.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secret Pirate Techniques
|
||||||
|
|
||||||
|
Any time a Pi-Rat Player plays a Face Card (Jack, Queen, King) when resolving a Challenge, that Challenge is resolved utilizing a **Secret Pirate Technique**! These represent special moves, tricks, or cartoon logic.
|
||||||
|
|
||||||
|
**Example Techniques:**
|
||||||
|
* *Secret Pirate Technique #64*: "I missed on purpose"
|
||||||
|
* *Secret Pirate Technique #3*: "Carlos will figure it out"
|
||||||
|
* *Secret Pirate Technique #57*: "Never trust a hatless captain"
|
||||||
|
* *Secret Pirate Technique #90*: "I'm not left handed"
|
||||||
|
* *Secret Pirate Technique #15*: "The Albatross is always watching"
|
||||||
|
* *Secret Pirate Technique #99*: "I've got a bomb"
|
||||||
|
|
||||||
|
#### Technique Creation & Assignment
|
||||||
|
1. Using individual note cards, each Player writes down the names of 3 Secret Pirate Techniques.
|
||||||
|
2. Once created, Players swap those techniques, one at a time, with other Players until they have 3 new techniques created by others.
|
||||||
|
3. Write these 3 Techniques in your Rat Records.
|
||||||
|
4. Assign each of the 3 Techniques to a different Face Card type (**Jack, Queen, King**). Playing that Face Card type activates that specific Technique, allowing you to narrate a spectacular, automatic success!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
Objectives guide your actions. Always consult them for inspiration during a scene.
|
||||||
|
|
||||||
|
### Personal Objectives
|
||||||
|
Must be completed in order:
|
||||||
|
|
||||||
|
1. **Get a Gat (or something just as good)**: Acquire an impressive weapon (pistol, cutlass, etc.).
|
||||||
|
* **Gat Privileges**: While you have a Gat, Black cards you play (Clubs ♣ and Spades ♠) during Challenges have their Value increased by 1.
|
||||||
|
* **Gat Tax**: When a Gatless Pi-Rat faces a Challenge, they may ask a Pi-Rat with a Gat in the same Scene to attempt it for them.
|
||||||
|
* *If accepted*: The Gatless Pi-Rat gives the Gatted Pi-Rat one random card from their Hand (the Gatted player chooses without looking). The Gatted Pi-Rat attempts the Challenge, sharing the successes and failures.
|
||||||
|
* *If refused*: The Gatted Pi-Rat must hand over their Gat to the Gatless Pi-Rat (who immediately completes Personal Objective 1). The newly Gatted Pi-Rat attempts the Challenge. If they succeed, they keep the Gat (the original owner must find a new one). If they fail, they return the Gat and cannot initiate a Gat/Name Tax for the rest of the Scene.
|
||||||
|
2. **Earn a Name (a normal one is fine)**: Gain a proper pirate name (e.g., Carlos, Barry, Jaspar) instead of temporary crew nicknames (e.g., Piss Whiskers, Dainty Spanker).
|
||||||
|
* **Name Privileges**: While you have a Name, Red cards you play (Diamonds ♦ and Hearts ♥) during Challenges have their Value increased by 1.
|
||||||
|
* **Name Tax**: When a Pi-Rat with a Gat but no Name faces a Challenge, they may ask a Named Pi-Rat in the same Scene to attempt it for them.
|
||||||
|
* *If accepted*: The Nameless Pi-Rat gives the Named Pi-Rat one random card from their Hand. The Named Pi-Rat attempts the Challenge, sharing successes/failures.
|
||||||
|
* *If refused*: The Named Pi-Rat gives their Name to the Nameless Pi-Rat (who immediately completes Personal Objective 2). The newly Named Pi-Rat attempts the Challenge. If they succeed, they keep the Name. If they fail, they return the Name and cannot initiate a Gat/Name Tax for the rest of the Scene.
|
||||||
|
3. **Die Like a Pirate (some pirates retire instead; those pirates are smarter than you)**: Go out in a blaze of glory or retire honorably.
|
||||||
|
|
||||||
|
### Crew Objectives
|
||||||
|
Must be completed in order:
|
||||||
|
|
||||||
|
1. **Steal a Ship (the one we're on right now is a good choice)**: Take control of a vessel by dealing with the crew.
|
||||||
|
2. **Choose a Captain**: Establish a leader.
|
||||||
|
3. **Commit Piracy**: Plunder, raid, bury treasure, and build your reputation.
|
||||||
|
|
||||||
|
#### Pi-Rat Captain Rules
|
||||||
|
* **Becoming Captain**: Once the crew controls a ship, the highest-Ranked Pi-Rat becomes the de facto Captain. Any Pi-Rat can Challenge for leadership at any time during a scene if both are present. The Captain remains in power until they give up the position, are defeated in a challenge, die, or the ship is lost.
|
||||||
|
* **Captain Privileges**: The Captain's Hand size is increased by +1, regardless of Rank. The Captain has the final say in all ship/crew disputes, and wins ties in crew votes.
|
||||||
|
* **Captain Tax**: Being Captain is high pressure! Captains lose 1 Rank each time they personally fail a Challenge during a scene.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 3: Playing The Game
|
||||||
|
|
||||||
|
## Setting Scenes
|
||||||
|
|
||||||
|
Before each Scene, complete these setup steps:
|
||||||
|
|
||||||
|
1. **Declare Roles!** Players announce their role: **Pi-Rat Players** (control characters) or **Deep Players** (control environment, threats, NPCs). There must be at least 1 of each per scene. Players who were Deep in the previous scene must play Pi-Rats in the next.
|
||||||
|
2. **Shuffle and Draw Obstacles!** Return discards to the deck and shuffle. Draw cards from the top and add them to the **Obstacle List** until the total is **at least one greater than the number of Deep Players** in the scene.
|
||||||
|
3. **Frame the Scene!** Deep Players collaboratively describe the location, situation, and how the drawn Obstacles manifest as an urgent problem.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **First Scene Setup**: The first scene must establish that the Pi-Rats are on a ship they can steal (or have immediate opportunity to do so). Do not start far from water.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Playing Out a Scene
|
||||||
|
|
||||||
|
* **Pi-Rat Players** narrate actions, roleplay, make decisions, and play cards to overcome Obstacles.
|
||||||
|
* **Deep Players** control NPCs, hazards, enemies, and call for Challenges when Pi-Rats attempt something risky. Deep Players can distribute the Obstacles among themselves or share them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending a Scene
|
||||||
|
|
||||||
|
Deep Players choose when to end a Scene. Minimum requirements to end a Scene:
|
||||||
|
* Each participating Pi-Rat has been challenged at least once and had a chance to progress an Objective.
|
||||||
|
* OR there are no Obstacles remaining on the Obstacle List.
|
||||||
|
* OR all Pi-Rat Players have fled or been narratively incapacitated (death, capture, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Between Scenes
|
||||||
|
|
||||||
|
Upkeep steps before starting a new Scene:
|
||||||
|
|
||||||
|
1. **Pirate Ranking (Voting)**: Each Player nominates one *other* Pi-Rat Player to Rank up. The player with the most votes gains 1 Rank. Ties are resolved by discussion or random choice. Deep Players from the previous scene cannot be nominated but may vote.
|
||||||
|
2. **Additional Rank Bonuses**: Apply Ranks for completed Personal Objectives.
|
||||||
|
3. **Deep Players Draw Cards**: Players who were Deep in the previous scene may discard any number of cards, then draw back up to their maximum Hand size (based on their Rank).
|
||||||
|
4. **Start a New Scene!** Repeat setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending the Game
|
||||||
|
|
||||||
|
End the story when the group feels ready. For the best experience, ensure:
|
||||||
|
* Each player has played the Deep at least once.
|
||||||
|
* Each Pi-Rat has completed at least one Personal Objective.
|
||||||
|
* The crew has completed all 3 Crew Objectives.
|
||||||
|
* *Recommended*: Include a wrap-up scene where the Pi-Rats party til they pass out in a pile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Resolving Challenges
|
||||||
|
|
||||||
|
When a Deep Player calls for a Challenge, follow these steps:
|
||||||
|
|
||||||
|
### Step 1: Establish Stakes!
|
||||||
|
Deep Players explain what success and failure mean.
|
||||||
|
|
||||||
|
### Step 2: Apply Obstacles!
|
||||||
|
Deep Players apply one or more cards from the **Obstacle List** to the challenge. (See [OBSTACLES.md](file:///Users/ttm/Projects/pirats/OBSTACLES.md) for the suit/value meanings).
|
||||||
|
|
||||||
|
### Step 3: Play Cards!
|
||||||
|
The Pi-Rat Player plays one card from their Hand for each applied Obstacle.
|
||||||
|
* **Card Beats Obstacle Value**: Success!
|
||||||
|
* **Card is Equal to or Lower than Obstacle Value (or no card played)**: Failure!
|
||||||
|
* *Note*: If multiple Obstacles are applied, you only need to succeed at one to pass the Challenge, but each failed Obstacle creates a narrative complication.
|
||||||
|
|
||||||
|
#### Card Values:
|
||||||
|
* **Numbers (2–10)**: Face value.
|
||||||
|
* **Aces**: Value of 1.
|
||||||
|
* **Face Cards (J, Q, K)**: When played by a Pi-Rat, activates a Secret Pirate Technique (automatic success). When acting as an Obstacle, their value equals the Rank of the challenged Pi-Rat.
|
||||||
|
|
||||||
|
### Step 4: Place Cards!
|
||||||
|
Played cards are placed face up below the applied Obstacle card and rotated 90°. **The last played card in the column determines the Obstacle's new value for future challenges.** The original Obstacle card always determines the suit color and narrative chart entry.
|
||||||
|
|
||||||
|
### Step 5: Narrate Results!
|
||||||
|
The suit of the card played to successfully beat an Obstacle determines how the Pi-Rat overcomes it:
|
||||||
|
|
||||||
|
* **♣ Clubs - Shootin' and Stabbin'**: You use violence, combat, or direct confrontation.
|
||||||
|
* **♦ Diamonds - Swimmin' and Sailin'**: You use nautical skills, athletics, or mobility.
|
||||||
|
* **♠ Spades - Sneakin' and Schemin'**: You use stealth, deception, or cunning plans.
|
||||||
|
* **♥ Hearts - Shoutin' and Singin'**: You use social skills, performance, or charisma.
|
||||||
|
|
||||||
|
*(If you play a Face Card, narrate using the name of your Secret Pirate Technique instead).*
|
||||||
|
|
||||||
|
### Step 6: Draw Cards!
|
||||||
|
Draw 1 card back into your Hand for each card you played that **matched the suit color** (Red/Black) of the original Obstacle card.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using Face Cards & Jokers
|
||||||
|
|
||||||
|
### Jacks, Queens, and Kings
|
||||||
|
* **Played by a Pi-Rat**: Triggers a assigned Secret Pirate Technique. **Automatic success** regardless of the Obstacle value. Describe the ridiculous trick in action.
|
||||||
|
* **Drawn as or placed below an Obstacle**: The value of the card is equal to the **Rank** of the challenged Pi-Rat.
|
||||||
|
|
||||||
|
### Jokers (Pirate Luck)
|
||||||
|
* **Played by a Pi-Rat**: Play at any time (before or after a challenge) to discard one Obstacle from the list entirely (along with its column) and draw a new one. The Joker is then discarded.
|
||||||
|
* **Drawn as an Obstacle**: Discard the Joker immediately, and permanently increase the number of Obstacles required on the list for the following scene by 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Advanced Resolution Mechanics & Questions
|
||||||
|
|
||||||
|
* **Can the Deep say no?** Yes. Deep Players can veto impossible or context-breaking actions without offering a challenge.
|
||||||
|
* **Can multiple Pi-Rats attempt the same Challenge?** Yes. Other participating Pi-Rats can play their own cards against the applied Obstacles on behalf of the challenged Pi-Rat. They narrate their assistance and share in the successes and failures, but **only the originally challenged Pi-Rat draws cards** in Step 6.
|
||||||
|
* **Can Pi-Rats Challenge each other?** Yes. The challenging Pi-Rat plays 1 card from their hand face up as a temporary Obstacle (narrating based on suit). The defender plays a card against it. Resolve normally. Afterward, discard the temporary Obstacle and cards below it. Only the defender draws cards in Step 6.
|
||||||
|
* **How do we get rid of Obstacles?** Once an Obstacle has been successfully beaten a number of times **equal to the total number of players**, it is discarded. Unbeaten or partially beaten Obstacles remain on the list across scenes.
|
||||||
|
* **Can my Pi-Rat flee from a Scene?** Yes, anytime before or after a Challenge. Fleeing Pi-Rats cannot participate until the next Scene and cannot progress Objectives.
|
||||||
|
* **What do we do with discarded cards?** Place them in the discard pile. They are shuffled back into the deck during Step 2 of Setting Scenes.
|
||||||
|
* **What happens when my Pi-Rat dies, retires, or is kicked out?** Your story arc is complete! Create a new Rank 1 recruit with new random techniques and fresh objectives. Alternatively, you can play your deceased Pi-Rat as a **Ghost** (unable to interact well with the living but still helpful).
|
||||||
|
* **What happens when our ship sinks?** Stealing a new ship becomes the immediate priority. The Pi-Rats might wash up on an island, get rescued (and steal the rescue ship), build a raft, or escape capture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 4: Setting Information
|
||||||
|
|
||||||
|
The Magical Land of Yeld
|
||||||
|
Built on the back of a slumbering Serpent God, Yeld is a mysterious fantasy land bordered by dangerous wildlands. The Fairies claim to be the oldest inhabitants, but even they do not know all its secrets. It is a land of ruins, magic, monsters, and varied climates (mountains, swamps, deserts, forests, and frozen tundras).
|
||||||
|
|
||||||
|
The Vampire Prince, Dragul
|
||||||
|
Dragul usurped control of Yeld nearly 500 years ago with the help of Fairies from the Fairy Lands. A civil war crushed resistance and spread undeath. Centuries later, the oceanic god (the Deep) sent its legions of Mermaids and Squidfolk to eradicate Dragul and the undead, resulting in an ongoing war on land and sea.
|
||||||
|
|
||||||
|
The Deep
|
||||||
|
The Deep is an ancient oceanic god who detests Prince Dragul and undeath. It created species like Mermaids and Squidfolk to act as commanders and soldiers in its legions.
|
||||||
|
|
||||||
|
The Pi-Rats of Yeld
|
||||||
|
Created during the war when Goblin Math Divers cast the spell "Pie" (a spell of recited numbers that transforms stowaway rats into giant, clever gunslinging rodents to overwhelm enemy crews). The Pi-Rats proved too clever, learned to sail and speak, and stole so many ships they temporarily ground the war to a halt! They have a reputation for chaos, violence, and loyalty. There are 43 traditionally accepted Pi-Rat names, and a rat must perform a legendary feat to add a new name to the list.
|
||||||
|
|
||||||
|
### Other Peoples of Yeld
|
||||||
|
* **King's People**: Human in appearance with pointed ears. The original rulers of Yeld before Dragul's usurpation.
|
||||||
|
* **Fairies**: Large, furry creatures with pointed ears, sharp teeth, and flat feet. Sturdy, strong enough to throw a bull, and loyal to Dragul.
|
||||||
|
* **Mermaids**: Daughters of the Deep. Humanoid with finned ears and tails that propel them through water. They can transform their tails into legs to walk and fight on land.
|
||||||
|
* **Squidfolk**: The Deep's first creations. Retain weird squid heads. Initially poor spies, they now live peacefully in Yeld after being replaced by the Octopus Tribe.
|
||||||
|
* **Vampires**: Created by Dragul. Possess Fairy-like strength, speed, and immortality, but must consume flesh and blood to survive. Only King's People and Mermaids can become Vampires.
|
||||||
|
* **Goblins**: Short, mossy-green beings with large ears and stubby tails. They wear containment suits because they cannot breathe Yeld's air. Gifted merchants, mechanics, and traders.
|
||||||
|
* **Animal Tribes**: The true natives of Yeld who lived there before Witches or Fairies. Hundreds of tribes serve the Deep under the waves.
|
||||||
|
* **Ghosts**: Inhabitants of the Ghost World, a gray filter over reality. Ghosts can look back into the living world, watch over friends, or haunt enemies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Final Thoughts
|
||||||
|
|
||||||
|
*Rats with Gats* is about embracing madness, celebrating small victories, and telling ridiculous stories. Get silly, don't take it too seriously, and name your techniques ridiculous things (like *"The Cheese Tickler's Gambit"* or *"Omae Wa Mou Shindeiru"*). Every Pi-Rat deserves a chance at glory.
|
||||||
|
|
||||||
|
*Want more? Get the full game "The Magical Land of Yeld" at [YeldStuff.com](http://YeldStuff.com)!*
|
||||||
15
TODO.md
Normal file
15
TODO.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
## Polish
|
||||||
|
|
||||||
|
- [x] The character sheet modal popup is narrower than it needs to be on wide screens. I think it could safely be like 90% as wide as the non-modal UI.
|
||||||
|
- [x] Popping out the event log should leave a close button where the "Event Log" open button is, so that you don't have to move your mouse to quickly toggle it. The existing close button can remain.
|
||||||
|
- [x] Use the same vertical UI for the player list in all phases
|
||||||
|
- [x] In the admin panel, if a pi-rat doesn't have a name yet, it should show something like 'not chosen yet' rather than the player name
|
||||||
|
- [x] In the admin panel, the "Open" and "Copy" buttons should be the same size
|
||||||
|
- [x] Store the player name in local storage and pre-populate it when joining a new game (but don't force it, let the player edit it if they'd prefer a different name)
|
||||||
|
- [x] If a player tries to rejoin a game that has ended or that they've been kicked from, they should get an error message and it should be removed from the list of re-joinable games
|
||||||
|
- [x] Games should have join codes in addition to join links. Join codes should be a sequence of 5 alphanumeric characters (upper case letters only, no ambiguous letters like 0/O/1/I). Add a panel to the home page for joining a game via code, and display the join code on the admin page
|
||||||
|
|
||||||
|
## Words Words Words
|
||||||
|
|
||||||
|
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
|
||||||
|
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
|
||||||
47
alembic.ini
Normal file
47
alembic.ini
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Dev-only config for the alembic CLI (the app runs migrations itself at
|
||||||
|
# startup via pirats.database.run_migrations).
|
||||||
|
#
|
||||||
|
# New migration: .venv/bin/alembic revision --autogenerate -m "add foo column"
|
||||||
|
# Apply: .venv/bin/alembic upgrade head
|
||||||
|
#
|
||||||
|
# No sqlalchemy.url here: env.py uses the app's engine (DATABASE_URL or
|
||||||
|
# ./rats_with_gats.db). Override with: alembic -x url=sqlite:///other.db ...
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = src/pirats/migrations
|
||||||
|
file_template = %%(rev)s_%%(slug)s
|
||||||
|
prepend_sys_path = src
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
38
dev.sh
Executable file
38
dev.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PIDS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
|
||||||
|
if ((${#PIDS[@]})); then
|
||||||
|
kill "${PIDS[@]}" 2>/dev/null || true
|
||||||
|
wait "${PIDS[@]}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
echo "Starting backend at http://localhost:8000"
|
||||||
|
"$ROOT_DIR/.venv/bin/uvicorn" pirats.main:app --host 0.0.0.0 --port 8000 --reload &
|
||||||
|
PIDS+=("$!")
|
||||||
|
|
||||||
|
echo "Starting frontend at http://localhost:5173"
|
||||||
|
(
|
||||||
|
cd "$ROOT_DIR/frontend"
|
||||||
|
exec ./node_modules/.bin/vite
|
||||||
|
) &
|
||||||
|
PIDS+=("$!")
|
||||||
|
|
||||||
|
echo "Press Ctrl-C to stop both servers."
|
||||||
|
wait
|
||||||
34
flake.lock
generated
34
flake.lock
generated
@@ -1,23 +1,5 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780749050,
|
"lastModified": 1780749050,
|
||||||
@@ -36,24 +18,8 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
"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",
|
"root": "root",
|
||||||
|
|||||||
179
flake.nix
179
flake.nix
@@ -3,54 +3,87 @@
|
|||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils, ... }:
|
outputs = { self, nixpkgs, ... }:
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
let
|
||||||
let
|
# Systems to support
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||||
python = pkgs.python3;
|
|
||||||
|
# Helper to generate an attrset for all supported systems
|
||||||
# Define the application package
|
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
|
||||||
piratsApp = python.pkgs.buildPythonApplication {
|
in
|
||||||
pname = "pirats";
|
{
|
||||||
version = "0.1.0";
|
packages = forAllSystems (system:
|
||||||
src = ./.;
|
let
|
||||||
format = "pyproject";
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
python = pkgs.python3;
|
||||||
|
|
||||||
|
# Build the Svelte frontend
|
||||||
|
frontend = pkgs.buildNpmPackage {
|
||||||
|
pname = "pirats-frontend";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./frontend;
|
||||||
|
npmDepsHash = "sha256-S2CXmFgovGD40wS4bTsiVF84J3zEQK6qrg7EPy+ojPY=";
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out
|
||||||
|
cp -r dist $out/
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = python.pkgs.buildPythonApplication {
|
||||||
|
pname = "pirats";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
format = "pyproject";
|
||||||
|
|
||||||
nativeBuildInputs = with python.pkgs; [
|
nativeBuildInputs = with python.pkgs; [
|
||||||
setuptools
|
setuptools
|
||||||
wheel
|
wheel
|
||||||
];
|
];
|
||||||
|
|
||||||
propagatedBuildInputs = with python.pkgs; [
|
propagatedBuildInputs = with python.pkgs; [
|
||||||
fastapi
|
alembic
|
||||||
sqlmodel
|
fastapi
|
||||||
uvicorn
|
sqlmodel
|
||||||
jinja2
|
uvicorn
|
||||||
python-multipart
|
websockets
|
||||||
];
|
python-multipart
|
||||||
|
httpx
|
||||||
|
];
|
||||||
|
|
||||||
nativeCheckInputs = with python.pkgs; [
|
nativeCheckInputs = with python.pkgs; [
|
||||||
pytestCheckHook
|
pytestCheckHook
|
||||||
];
|
];
|
||||||
|
|
||||||
|
preBuild = ''
|
||||||
|
mkdir -p src/pirats/static
|
||||||
|
cp -r ${frontend}/dist/* src/pirats/static/
|
||||||
|
'';
|
||||||
|
|
||||||
pythonImportsCheck = [ "pirats" ];
|
pythonImportsCheck = [ "pirats" ];
|
||||||
};
|
};
|
||||||
in
|
pirats = self.packages.${system}.default;
|
||||||
{
|
}
|
||||||
packages.default = piratsApp;
|
);
|
||||||
packages.pirats = piratsApp;
|
|
||||||
|
devShells = forAllSystems (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
inputsFrom = [ self.packages.${system}.default ];
|
||||||
|
packages = with pkgs; [
|
||||||
|
pkgs.python3.pkgs.pytest
|
||||||
|
pkgs.nodejs
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
inputsFrom = [ piratsApp ];
|
|
||||||
packages = with pkgs; [
|
|
||||||
python.pkgs.pytest
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
) // {
|
|
||||||
# NixOS Module to run the application as a service
|
# NixOS Module to run the application as a service
|
||||||
nixosModules.default = { config, lib, pkgs, ... }:
|
nixosModules.default = { config, lib, pkgs, ... }:
|
||||||
let
|
let
|
||||||
@@ -79,11 +112,59 @@
|
|||||||
default = "/var/lib/pirats/rats_with_gats.db";
|
default = "/var/lib/pirats/rats_with_gats.db";
|
||||||
description = "Path to the SQLite database file.";
|
description = "Path to the SQLite database file.";
|
||||||
};
|
};
|
||||||
|
logFile = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "/var/log/pirats/pirats.log";
|
||||||
|
description = ''
|
||||||
|
Path to the server log file (rotating, 10 MB x 5 backups). stderr is
|
||||||
|
also captured by the systemd journal. The default lives under the
|
||||||
|
service's LogsDirectory (/var/log/pirats), which the sandboxed
|
||||||
|
DynamicUser can write to; a custom path elsewhere must be made
|
||||||
|
writable by the service separately.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
logLevel = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "debug" "info" "warning" "error" "critical" ];
|
||||||
|
default = "info";
|
||||||
|
description = "Minimum severity of log messages to record.";
|
||||||
|
};
|
||||||
|
maxBodyBytes = lib.mkOption {
|
||||||
|
type = lib.types.ints.positive;
|
||||||
|
default = 1048576;
|
||||||
|
description = "Reject request bodies larger than this many bytes (HTTP 413).";
|
||||||
|
};
|
||||||
openFirewall = lib.mkOption {
|
openFirewall = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
description = "Open ports in the firewall for the service.";
|
description = "Open ports in the firewall for the service.";
|
||||||
};
|
};
|
||||||
|
devModeDefault = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether new games start with Dev Mode (testing shortcuts) enabled.";
|
||||||
|
};
|
||||||
|
purge = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Periodically purge old finished/inactive games.";
|
||||||
|
};
|
||||||
|
intervalHours = lib.mkOption {
|
||||||
|
type = lib.types.numbers.positive;
|
||||||
|
default = 24;
|
||||||
|
description = "How often (in hours) the purge task runs.";
|
||||||
|
};
|
||||||
|
finishedDays = lib.mkOption {
|
||||||
|
type = lib.types.numbers.positive;
|
||||||
|
default = 14;
|
||||||
|
description = "Purge games that finished more than this many days ago.";
|
||||||
|
};
|
||||||
|
inactiveDays = lib.mkOption {
|
||||||
|
type = lib.types.numbers.positive;
|
||||||
|
default = 30;
|
||||||
|
description = "Purge games with no activity for this many days.";
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
@@ -96,15 +177,29 @@
|
|||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
DATABASE_URL = "sqlite:///${cfg.databasePath}";
|
DATABASE_URL = "sqlite:///${cfg.databasePath}";
|
||||||
|
# Unset means "local checkout" and defaults Dev Mode on, so the
|
||||||
|
# service must always set it explicitly.
|
||||||
|
PIRATS_DEV_MODE = lib.boolToString cfg.devModeDefault;
|
||||||
|
PIRATS_LOG_FILE = cfg.logFile;
|
||||||
|
PIRATS_LOG_LEVEL = cfg.logLevel;
|
||||||
|
PIRATS_PURGE_ENABLED = lib.boolToString cfg.purge.enable;
|
||||||
|
PIRATS_PURGE_INTERVAL_HOURS = toString cfg.purge.intervalHours;
|
||||||
|
PIRATS_PURGE_FINISHED_DAYS = toString cfg.purge.finishedDays;
|
||||||
|
PIRATS_PURGE_INACTIVE_DAYS = toString cfg.purge.inactiveDays;
|
||||||
|
PIRATS_MAX_BODY_BYTES = toString cfg.maxBodyBytes;
|
||||||
};
|
};
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${cfg.package}/bin/pirats --host ${cfg.host} --port ${toString cfg.port}";
|
ExecStart = "${cfg.package}/bin/pirats --host ${cfg.host} --port ${toString cfg.port}";
|
||||||
Restart = "always";
|
Restart = "always";
|
||||||
|
|
||||||
# Sandboxing and security
|
# Sandboxing and security
|
||||||
DynamicUser = true;
|
DynamicUser = true;
|
||||||
StateDirectory = "pirats";
|
StateDirectory = "pirats";
|
||||||
|
# Creates /var/log/pirats owned by the dynamic user and adds it to
|
||||||
|
# ReadWritePaths, so the default logFile under it is writable
|
||||||
|
# despite ProtectSystem=strict.
|
||||||
|
LogsDirectory = "pirats";
|
||||||
WorkingDirectory = "/var/lib/pirats";
|
WorkingDirectory = "/var/lib/pirats";
|
||||||
|
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
|
|||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
43
frontend/README.md
Normal file
43
frontend/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Svelte + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Svelte in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||||
|
|
||||||
|
## Need an official Svelte framework?
|
||||||
|
|
||||||
|
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||||
|
|
||||||
|
## Technical considerations
|
||||||
|
|
||||||
|
**Why use this over SvelteKit?**
|
||||||
|
|
||||||
|
- It brings its own routing solution which might not be preferable for some users.
|
||||||
|
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||||
|
|
||||||
|
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||||
|
|
||||||
|
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||||
|
|
||||||
|
**Why include `.vscode/extensions.json`?**
|
||||||
|
|
||||||
|
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||||
|
|
||||||
|
**Why enable `checkJs` in the JS template?**
|
||||||
|
|
||||||
|
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
|
||||||
|
|
||||||
|
**Why is HMR not preserving my local component state?**
|
||||||
|
|
||||||
|
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
|
||||||
|
|
||||||
|
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// store.js
|
||||||
|
// An extremely simple external store
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
export default writable(0)
|
||||||
|
```
|
||||||
19
frontend/index.html
Normal file
19
frontend/index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Rats with Gats</title>
|
||||||
|
<script>
|
||||||
|
// Apply the saved theme before first paint to avoid a flash of the
|
||||||
|
// wrong palette. lib/theme.js owns this key afterwards.
|
||||||
|
document.documentElement.dataset.theme =
|
||||||
|
localStorage.getItem('pirats-theme') === 'dark' ? 'dark' : 'light';
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
frontend/jsconfig.json
Normal file
33
frontend/jsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
/**
|
||||||
|
* svelte-preprocess cannot figure out whether you have
|
||||||
|
* a value or a type, so tell TypeScript to enforce using
|
||||||
|
* `import type` instead of `import` for Types.
|
||||||
|
*/
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
/**
|
||||||
|
* To have warnings / errors of the Svelte compiler at the
|
||||||
|
* correct position, enable source maps by default.
|
||||||
|
*/
|
||||||
|
"sourceMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable this if you'd like to use dynamic types.
|
||||||
|
*/
|
||||||
|
"checkJs": true
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Use global.d.ts instead of compilerOptions.types
|
||||||
|
* to avoid limiting type declarations.
|
||||||
|
*/
|
||||||
|
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
1216
frontend/package-lock.json
generated
Normal file
1216
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"svelte": "^5.55.5",
|
||||||
|
"vite": "^8.0.12"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/alegreya-sans": "^5.2.8",
|
||||||
|
"@fontsource/alegreya-sc": "^5.2.8",
|
||||||
|
"@fontsource/pirata-one": "^5.2.8",
|
||||||
|
"svelte-spa-router": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="14" fill="#131822" stroke="#d9a23c" stroke-width="3"/>
|
||||||
|
<text x="32" y="44" font-size="36" text-anchor="middle">🐀</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 240 B |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
20
frontend/src/App.svelte
Normal file
20
frontend/src/App.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script>
|
||||||
|
import Router from 'svelte-spa-router';
|
||||||
|
import Home from './pages/Home.svelte';
|
||||||
|
import Join from './pages/Join.svelte';
|
||||||
|
import Dashboard from './pages/Dashboard.svelte';
|
||||||
|
import Admin from './pages/Admin.svelte';
|
||||||
|
import CornerMenu from './components/CornerMenu.svelte';
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
'/': Home,
|
||||||
|
'/game/:id/join': Join,
|
||||||
|
'/game/:id/player/:pid': Dashboard,
|
||||||
|
'/game/:id/admin': Admin,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<Router {routes} />
|
||||||
|
<CornerMenu />
|
||||||
|
</main>
|
||||||
17
frontend/src/app.css
Normal file
17
frontend/src/app.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
RATS WITH GATS — STYLESHEET INDEX
|
||||||
|
theme.css holds every color/font/shape token (edit it to re-theme).
|
||||||
|
base.css holds the reset, typography, and utility classes.
|
||||||
|
The rest are component- and phase-specific styles built on those tokens.
|
||||||
|
========================================================================== */
|
||||||
|
@import './assets/css/theme.css';
|
||||||
|
@import './assets/css/base.css';
|
||||||
|
@import './assets/css/components.css';
|
||||||
|
@import './assets/css/card.css';
|
||||||
|
@import './assets/css/welcome.css';
|
||||||
|
@import './assets/css/lobby.css';
|
||||||
|
@import './assets/css/character.css';
|
||||||
|
@import './assets/css/scene-setup.css';
|
||||||
|
@import './assets/css/scene-play.css';
|
||||||
|
@import './assets/css/upkeep.css';
|
||||||
|
@import './assets/css/admin.css';
|
||||||
164
frontend/src/assets/css/admin.css
Normal file
164
frontend/src/assets/css/admin.css
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/* --- Admin panel --- */
|
||||||
|
.admin-page {
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 2.2rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-key-chip {
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-summary-details {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 10.5rem;
|
||||||
|
padding: 0.85rem 1.1rem 0.95rem;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--well));
|
||||||
|
color: var(--text);
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
font: inherit;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 20%, var(--well));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code.copied {
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.admin-summary {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th, .admin-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copy-action {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copy-action > .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 2.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Game over --- */
|
||||||
|
.gameover-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameover-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameover-card h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 2.6rem;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameover-card .sheet-group {
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
231
frontend/src/assets/css/base.css
Normal file
231
frontend/src/assets/css/base.css
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/* --- Reset & page setup --- */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 50% -20%, color-mix(in srgb, var(--accent) 7%, transparent) 0%, transparent 55%),
|
||||||
|
radial-gradient(circle at 50% 60%, var(--bg) 0%, var(--bg-deep) 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-deep);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--accent) 50%, var(--bg-deep));
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- App layout --- */
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top padding clears the fixed corner menu */
|
||||||
|
.dashboard-container {
|
||||||
|
padding: 3.5rem 2rem 4rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-view-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr) minmax(300px, 0.8fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-main-column {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1101px) {
|
||||||
|
.phase-view-layout.log-collapsed {
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.phase-view-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.dashboard-container {
|
||||||
|
padding: 3.5rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header h2,
|
||||||
|
.lobby-view h2,
|
||||||
|
.scene-setup-view h2,
|
||||||
|
.between-scenes-view h2 {
|
||||||
|
font-size: 2.1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Loader / spinner widgets --- */
|
||||||
|
.loader-container {
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 3px solid var(--edge-soft);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--edge-soft);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Text utilities --- */
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-sm { font-size: 0.875rem; }
|
||||||
|
.italic { font-style: italic; }
|
||||||
|
.font-bold { font-weight: 700; }
|
||||||
|
.font-mono { font-family: ui-monospace, monospace; }
|
||||||
|
.underline { text-decoration: underline; }
|
||||||
|
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.text-accent, .gold-text { color: var(--accent); }
|
||||||
|
.text-danger { color: var(--danger); }
|
||||||
|
.text-success, .success-text { color: var(--success); font-weight: 600; }
|
||||||
|
.text-warning { color: var(--warning); }
|
||||||
|
.text-deep, .deep-text { color: var(--deep); }
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote, .footnote-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote-desc {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Spacing & layout utilities --- */
|
||||||
|
.mt-2 { margin-top: 0.5rem; }
|
||||||
|
.mt-4 { margin-top: 1rem; }
|
||||||
|
.mt-6 { margin-top: 1.5rem; }
|
||||||
|
.mt-8 { margin-top: 2rem; }
|
||||||
|
.mb-4 { margin-bottom: 1rem; }
|
||||||
|
.mb-6 { margin-bottom: 1.5rem; }
|
||||||
|
.p-3 { padding: 0.75rem; }
|
||||||
|
.p-4 { padding: 1rem; }
|
||||||
|
.p-6 { padding: 1.5rem; }
|
||||||
|
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||||
|
.margin-top { margin-top: 1.5rem; }
|
||||||
|
.margin-top-small { margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-grow { flex: 1; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.gap-2 { gap: 0.5rem; }
|
||||||
|
.gap-4 { gap: 1rem; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.max-w-4xl { max-width: 56rem; }
|
||||||
|
.max-w-5xl { max-width: 64rem; }
|
||||||
|
.space-y-2 > * + * { margin-top: 0.5rem; }
|
||||||
|
.space-y-6 > * + * { margin-top: 1.5rem; }
|
||||||
|
|
||||||
|
.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%; }
|
||||||
|
|
||||||
|
/* --- Ghost World --- */
|
||||||
|
.ghost-world {
|
||||||
|
filter: grayscale(95%) brightness(0.85) sepia(15%) contrast(1.05);
|
||||||
|
transition: filter 1.2s ease-in-out;
|
||||||
|
}
|
||||||
200
frontend/src/assets/css/card.css
Normal file
200
frontend/src/assets/css/card.css
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Playing-card widgets (Card.svelte: mini / medium / large)
|
||||||
|
Light parchment faces on the dark table for contrast.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Shared face */
|
||||||
|
.card-mini, .card-medium, .card-large {
|
||||||
|
background: linear-gradient(150deg, var(--card-face) 0%, var(--card-face-shade) 100%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--card-ink-black) 35%, var(--card-face-shade));
|
||||||
|
color: var(--card-ink-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini.suit-h, .card-mini.suit-d,
|
||||||
|
.card-medium.suit-h .val, .card-medium.suit-h .suit, .card-medium.suit-h .card-center,
|
||||||
|
.card-medium.suit-d .val, .card-medium.suit-d .suit, .card-medium.suit-d .card-center,
|
||||||
|
.card-large.suit-h .val, .card-large.suit-h .suit, .card-large.suit-h .card-center,
|
||||||
|
.card-large.suit-d .val, .card-large.suit-d .suit, .card-large.suit-d .card-center {
|
||||||
|
color: var(--card-ink-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium.joker-card, .card-large.joker-card {
|
||||||
|
background: linear-gradient(150deg, color-mix(in srgb, var(--accent) 30%, var(--card-face)) 0%, var(--card-face-shade) 100%);
|
||||||
|
border-color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mini (played-card columns) --- */
|
||||||
|
.card-mini {
|
||||||
|
width: 32px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 900;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini.base-card {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini.rotated {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini.success {
|
||||||
|
box-shadow: 0 0 0 2px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini.failure {
|
||||||
|
box-shadow: 0 0 0 2px var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: color-mix(in srgb, var(--card-ink-black) 60%, var(--card-face));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Medium (obstacle display) --- */
|
||||||
|
.card-medium {
|
||||||
|
width: 75px;
|
||||||
|
height: 112px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.3rem;
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium .card-corner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium .card-corner .val {
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium .card-corner .suit {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium .card-center {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
text-align: center;
|
||||||
|
align-self: center;
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Large (the player's hand) --- */
|
||||||
|
.card-large {
|
||||||
|
width: 110px;
|
||||||
|
height: 165px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-width: 2px;
|
||||||
|
box-shadow: var(--shadow-card-raised);
|
||||||
|
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);
|
||||||
|
box-shadow: var(--shadow-card-lifted), 0 0 14px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 .card-center {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
text-align: center;
|
||||||
|
align-self: center;
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large .card-tech-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large .tech-tag {
|
||||||
|
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent-bright);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.joker-action {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Drag & drop --- */
|
||||||
|
.card-large[draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large[draggable="true"]:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
border-style: dashed;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-item.drag-over {
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
background: color-mix(in srgb, var(--accent) 9%, transparent) !important;
|
||||||
|
box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 20%, transparent) !important;
|
||||||
|
transform: translateY(-2px) scale(1.01);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
402
frontend/src/assets/css/character.css
Normal file
402
frontend/src/assets/css/character.css
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
/* --- Character creation & sheet --- */
|
||||||
|
.creation-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.creation-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-grid > .card {
|
||||||
|
grid-column: span 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-line {
|
||||||
|
background: var(--well);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delegation-box {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delegation-box h4 {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-box {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--accent-bright);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-list:has(.inbox-item) .inbox-empty-message {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-item {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-left: 3px solid var(--deep);
|
||||||
|
background: color-mix(in srgb, var(--deep) 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-item label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted-techniques .tech-chip {
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-assignment-pill {
|
||||||
|
background: color-mix(in srgb, var(--deep) 6%, transparent);
|
||||||
|
border: 1px solid var(--deep);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-rank {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
font-weight: 900;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Character sheet layout --- */
|
||||||
|
.character-sheet-details {
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.character-sheet-details {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.sheet-main-column {
|
||||||
|
flex: 2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sheet-side-column {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.techniques-list-sheet {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.techniques-list-sheet li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Face-card technique assignment (drag & drop) --- */
|
||||||
|
.face-tech-drag-form {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-techniques-container {
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px dashed var(--edge);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-techniques-container h4 {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-help {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.techniques-drag-pool {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 50px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--accent-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip.assigned-hidden {
|
||||||
|
opacity: 0.2;
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: var(--edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Face-card slots */
|
||||||
|
.face-cards-slots-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.face-cards-slots-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 220px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
min-height: 280px;
|
||||||
|
background: var(--well);
|
||||||
|
border: 2px dashed var(--edge-accent);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.drag-over {
|
||||||
|
border-color: var(--accent-bright);
|
||||||
|
border-style: solid;
|
||||||
|
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 5%, var(--well));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-bg-letter {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 9rem;
|
||||||
|
color: color-mix(in srgb, var(--text) 4%, transparent);
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment .card-bg-letter {
|
||||||
|
color: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-slot-header, .card-slot-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment .card-slot-header,
|
||||||
|
.face-card-slot.has-assignment .card-slot-footer {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-slot-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment .card-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-placeholder {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px dashed var(--edge);
|
||||||
|
background: var(--well);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot:hover .drop-zone-placeholder {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--edge-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assigned-tech-content {
|
||||||
|
width: 100%;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment .drop-zone-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment .assigned-tech-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assigned-tech-chip {
|
||||||
|
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
word-break: break-word;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-tech-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--danger);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--danger) 70%, var(--text));
|
||||||
|
color: var(--text-inverse);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-tech-btn:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: scale(0.9); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
489
frontend/src/assets/css/components.css
Normal file
489
frontend/src/assets/css/components.css
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
/* --- Panels --- */
|
||||||
|
.glass-panel {
|
||||||
|
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel:hover {
|
||||||
|
border-color: var(--edge-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--edge-accent);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sunken sub-section inside a panel (character sheet groups, ledgers...) */
|
||||||
|
.sheet-group {
|
||||||
|
background: var(--well);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border-left: 2px solid var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-group h4 {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notice banners (first-scene framing, over-limit warnings...) */
|
||||||
|
.notice-banner {
|
||||||
|
border: 1px dashed var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
max-width: 700px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-banner h4 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-banner.accent {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||||
|
}
|
||||||
|
.notice-banner.accent h4 { color: var(--accent); }
|
||||||
|
|
||||||
|
.notice-banner.danger {
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 9%, transparent);
|
||||||
|
}
|
||||||
|
.notice-banner.danger h4 { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Prompt boxes (character-sheet callouts: death, ghost, name...) */
|
||||||
|
.prompt-box {
|
||||||
|
border: 1px dashed var(--edge);
|
||||||
|
border-left: 2px solid var(--edge);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--well);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-box h4 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-box.accent {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
.prompt-box.accent h4 { color: var(--accent); }
|
||||||
|
|
||||||
|
.prompt-box.danger {
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 9%, transparent);
|
||||||
|
}
|
||||||
|
.prompt-box.danger h4 { color: var(--danger); }
|
||||||
|
|
||||||
|
.prompt-box.ghostly {
|
||||||
|
border-color: var(--ghost);
|
||||||
|
background: color-mix(in srgb, var(--ghost) 10%, transparent);
|
||||||
|
}
|
||||||
|
.prompt-box.ghostly h4 { color: var(--ghost); }
|
||||||
|
|
||||||
|
/* --- 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, .form-control, .select-field, .copy-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field::placeholder, .form-control::placeholder {
|
||||||
|
color: color-mix(in srgb, var(--text-muted) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus, .form-control:focus, .select-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-field {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-large {
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row .input-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; }
|
||||||
|
.select-xsmall { padding: 0.4rem 0.5rem; font-size: 0.85rem; width: 140px; }
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:has(input:disabled) {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objectives-checklist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Buttons --- */
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(160deg, var(--accent-bright) 0%, var(--accent) 45%, var(--accent-dark) 100%);
|
||||||
|
border-color: var(--accent-dark);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
border-color: var(--edge);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--text) 15%, transparent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-deep {
|
||||||
|
background: color-mix(in srgb, var(--deep) 10%, var(--surface));
|
||||||
|
border-color: var(--deep);
|
||||||
|
color: var(--deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-deep:hover:not(:disabled) {
|
||||||
|
background: var(--deep);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: color-mix(in srgb, var(--danger) 18%, var(--surface));
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: color-mix(in srgb, var(--danger) 70%, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: color-mix(in srgb, var(--success) 18%, var(--surface));
|
||||||
|
border-color: var(--success);
|
||||||
|
color: color-mix(in srgb, var(--success) 70%, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover:not(:disabled) {
|
||||||
|
background: var(--success);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gold {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gold:hover:not(:disabled) {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full, .btn-block { width: 100%; }
|
||||||
|
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
|
||||||
|
.btn-large { padding: 0.9rem 2rem; font-size: 1.15rem; }
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-effect:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Alerts --- */
|
||||||
|
.alert {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: color-mix(in srgb, var(--danger) 75%, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-chip {
|
||||||
|
border-color: var(--deep);
|
||||||
|
background: color-mix(in srgb, var(--deep) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pirat-chip {
|
||||||
|
border-color: var(--pirat);
|
||||||
|
background: color-mix(in srgb, var(--pirat) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Badges --- */
|
||||||
|
.creator-badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-badge, .pirat-badge, .ghost-badge {
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-badge {
|
||||||
|
background: color-mix(in srgb, var(--deep) 14%, transparent);
|
||||||
|
border: 1px solid var(--deep);
|
||||||
|
color: var(--deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pirat-badge {
|
||||||
|
background: color-mix(in srgb, var(--pirat) 14%, transparent);
|
||||||
|
border: 1px solid var(--pirat);
|
||||||
|
color: var(--pirat);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-badge {
|
||||||
|
background: color-mix(in srgb, var(--ghost) 14%, transparent);
|
||||||
|
border: 1px solid var(--ghost);
|
||||||
|
color: var(--ghost);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Waiting indicator --- */
|
||||||
|
.waiting-box, .waiting-indicator {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Modal overlay (shared by popups) --- */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
position: relative;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.75rem;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box.sheet-modal {
|
||||||
|
max-width: 520px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box.character-sheet-modal {
|
||||||
|
max-width: 1260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Graphical tooltip (lib/tooltip.js action) --- */
|
||||||
|
.tooltip-pop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3000;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--edge-accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(3px);
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-card {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-card.tt-red { color: var(--suit-red); }
|
||||||
|
.tooltip-pop .tt-card.tt-black { color: var(--suit-black); }
|
||||||
|
.tooltip-pop .tt-card.tt-joker { color: var(--accent); }
|
||||||
|
|
||||||
|
.tooltip-pop .tt-color {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-color-red {
|
||||||
|
color: var(--suit-red);
|
||||||
|
background: color-mix(in srgb, var(--suit-red) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-color-black {
|
||||||
|
color: var(--suit-black);
|
||||||
|
background: color-mix(in srgb, var(--suit-black) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-theme {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-row {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
62
frontend/src/assets/css/lobby.css
Normal file
62
frontend/src/assets/css/lobby.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/* --- Lobby / Ship's Hold --- */
|
||||||
|
.lobby-view {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-box {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teaching-box {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teaching-box .checkbox-label.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teaching-box .info-text {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item h4 {
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-join-code-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-input.admin-input {
|
||||||
|
border-color: var(--edge-accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link-item {
|
||||||
|
border-top: 1px dashed var(--edge-accent);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
482
frontend/src/assets/css/scene-play.css
Normal file
482
frontend/src/assets/css/scene-play.css
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
/* --- Scene board layout --- */
|
||||||
|
.scene-view-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1.7fr) minmax(300px, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The middle column stacks hand / challenge / obstacle cards. */
|
||||||
|
.table-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stretch the log column to the full row height so its sticky panel can travel. */
|
||||||
|
.log-column {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When the inline log is collapsed, reclaim its column (3-col layout only). */
|
||||||
|
@media (min-width: 1101px) {
|
||||||
|
.scene-view-layout.log-collapsed {
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner button that toggles the inline Event Log without moving. */
|
||||||
|
.log-reopen-btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.log-reopen-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.scene-view-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Crew bubble column --- */
|
||||||
|
.crew-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubbles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.1rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-left: 4px solid var(--pirat);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--surface-raised));
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-static:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-deep {
|
||||||
|
border-left-color: var(--deep);
|
||||||
|
background: color-mix(in srgb, var(--deep) 12%, var(--surface-raised));
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-you {
|
||||||
|
border-color: var(--accent);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-ghost { opacity: 0.7; filter: grayscale(0.6); }
|
||||||
|
.crew-bubble.is-dead { opacity: 0.6; filter: grayscale(0.8); }
|
||||||
|
|
||||||
|
/* Challenge-progress badge the Deep sees floating just outside the bubble */
|
||||||
|
.challenge-check {
|
||||||
|
position: absolute;
|
||||||
|
left: -0.6rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
color: var(--text-muted);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-check.done {
|
||||||
|
background: var(--success);
|
||||||
|
border-color: var(--success);
|
||||||
|
color: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble .bubble-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble .bubble-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble .bubble-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crew Objectives toggle at the end of the roster */
|
||||||
|
.crew-objectives-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-objectives-toggle:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-objectives-toggle .co-count {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-objectives-detail {
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the crew roster vertical at every width, matching the other phases. */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.crew-bubble {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.crew-objectives-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--edge-accent);
|
||||||
|
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(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Obstacles --- */
|
||||||
|
.obstacles-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-item {
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--well);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.8fr 2fr 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-item.in-challenge {
|
||||||
|
box-shadow: 0 0 0 2px var(--deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.obstacle-item {
|
||||||
|
grid-template-columns: 0.8fr 2fr 1.5fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.obstacle-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suit accent stripes */
|
||||||
|
.suit-c, .suit-s { border-left: 4px solid var(--suit-black); }
|
||||||
|
.suit-h, .suit-d { border-left: 4px solid var(--suit-red); }
|
||||||
|
|
||||||
|
/* Overlapping card column for an Obstacle. Cards stack with a heavy negative
|
||||||
|
margin so each earlier card shows only its top strip (rank + suit); the most
|
||||||
|
recent play sits fully visible at the bottom. Rings: accent = the original
|
||||||
|
card, green/red = a play that did / didn't beat the difficulty. */
|
||||||
|
.obstacle-stack {
|
||||||
|
--peek: 34px; /* visible strip of each earlier card */
|
||||||
|
--card-h: 112px; /* medium card height (card.css) */
|
||||||
|
align-self: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px; /* room for the rings, which overflow:hidden would clip */
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-card + .stack-card {
|
||||||
|
margin-top: calc(var(--peek) - var(--card-h));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-card.is-original {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-card.is-success {
|
||||||
|
box-shadow: 0 0 0 2px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-card.is-failure {
|
||||||
|
box-shadow: 0 0 0 2px var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suit-badge {
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suit-c .suit-badge, .suit-s .suit-badge { color: var(--suit-black); }
|
||||||
|
.suit-h .suit-badge, .suit-d .suit-badge { color: var(--suit-red); }
|
||||||
|
|
||||||
|
.card-code {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-details h4 {
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The title and its "K♦" card-code prefix take the Obstacle's fixed suit color
|
||||||
|
(red border on the left already marks color, so the old tag is gone). */
|
||||||
|
.suit-h .obstacle-details h4, .suit-d .obstacle-details h4 { color: var(--suit-red); }
|
||||||
|
.suit-c .obstacle-details h4, .suit-s .obstacle-details h4 { color: var(--suit-black); }
|
||||||
|
|
||||||
|
.obs-card-code { opacity: 0.8; }
|
||||||
|
|
||||||
|
.obstacle-details .in-challenge-tag {
|
||||||
|
color: var(--deep);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-details .desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-value-display {
|
||||||
|
align-self: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||||
|
border: 1px dashed var(--edge-accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-successes-display {
|
||||||
|
align-self: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: color-mix(in srgb, var(--success) 5%, transparent);
|
||||||
|
border: 1px dashed color-mix(in srgb, var(--success) 30%, transparent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-successes-display .val-number {
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 1.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(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-obstacle-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Challenges --- (framed in the Deep's teal so red/black always means suit color) */
|
||||||
|
/* The challenge-area card IS the box; the challenge content sits flush inside it. */
|
||||||
|
.challenge-area-card {
|
||||||
|
border-color: color-mix(in srgb, var(--deep) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-item {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-item + .challenge-item {
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-item h4 {
|
||||||
|
color: var(--deep);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tax-callout {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px dashed var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tax-callout p {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Challenge area (middle column) --- */
|
||||||
|
/* Obstacles applied to an active Challenge, pulled up into the Challenge panel */
|
||||||
|
.challenge-obstacles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Deep's call-a-challenge + end-scene controls live below any open Challenges */
|
||||||
|
.deep-challenge-controls {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When a Challenge is active the rest of the Obstacle list collapses out of the way */
|
||||||
|
.obstacle-collapse > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-collapse[open] > summary {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-upkeep-controls {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Hand --- */
|
||||||
|
.hand-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand-flex {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
70
frontend/src/assets/css/scene-setup.css
Normal file
70
frontend/src/assets/css/scene-setup.css
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/* --- Scene setup / role choice --- */
|
||||||
|
.setup-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-grid.single {
|
||||||
|
grid-template-columns: minmax(0, 700px);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: var(--well);
|
||||||
|
border: 2px solid var(--edge);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn.pirat-btn:hover:not(:disabled), .role-btn.pirat-btn.active {
|
||||||
|
border-color: var(--pirat);
|
||||||
|
color: var(--text);
|
||||||
|
background-color: color-mix(in srgb, var(--pirat) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn.deep-btn:hover:not(:disabled), .role-btn.deep-btn.active {
|
||||||
|
border-color: var(--deep);
|
||||||
|
color: var(--text);
|
||||||
|
background-color: color-mix(in srgb, var(--deep) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
padding: 0.15rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.deep {
|
||||||
|
background: color-mix(in srgb, var(--deep) 14%, transparent);
|
||||||
|
border: 1px solid var(--deep);
|
||||||
|
color: var(--deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.pirat {
|
||||||
|
background: color-mix(in srgb, var(--pirat) 14%, transparent);
|
||||||
|
border: 1px solid var(--pirat);
|
||||||
|
color: var(--pirat);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.unassigned {
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px dashed var(--edge);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
156
frontend/src/assets/css/theme.css
Normal file
156
frontend/src/assets/css/theme.css
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/* ============================================================
|
||||||
|
RATS WITH GATS — THEME TOKENS
|
||||||
|
|
||||||
|
Two themes:
|
||||||
|
- "Fair Winds" (default, light): blue skies, bright seas, and
|
||||||
|
parchment treasure maps with big red Xs. Wind Waker energy.
|
||||||
|
- "Lantern & Brine" (data-theme="dark"): a lantern-lit pirate
|
||||||
|
ship at night.
|
||||||
|
|
||||||
|
This file is the ONLY place colors, fonts, radii, and shadows
|
||||||
|
are defined. Every other stylesheet derives translucent
|
||||||
|
variants with color-mix(), so re-theming the app means
|
||||||
|
editing this file and nothing else. The active theme is the
|
||||||
|
data-theme attribute on <html>, set from localStorage by an
|
||||||
|
inline script in index.html and toggled via lib/theme.js.
|
||||||
|
(Font files are bundled via @fontsource imports in main.js.)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* --- "Fair Winds" raw palette (light, default) ----------- */
|
||||||
|
--sky: #cde6f5; /* page background: midday sky */
|
||||||
|
--sky-deep: #9fc9e2; /* page edges: deeper blue toward the horizon */
|
||||||
|
--sail: #f8f1dd; /* panel surface: weathered sailcloth */
|
||||||
|
--sail-bright: #fffaec; /* raised surface */
|
||||||
|
|
||||||
|
--map-ink: #33291a; /* primary text: sepia chart ink */
|
||||||
|
--map-faded: #7d6e54; /* muted text */
|
||||||
|
|
||||||
|
--doubloon: #a8770f; /* primary accent: antique gold */
|
||||||
|
--doubloon-bright: #c08a13;
|
||||||
|
--doubloon-dark: #7c560a;
|
||||||
|
|
||||||
|
--lagoon: #0c8d96; /* the Deep: tropical shallows */
|
||||||
|
--palm: #2f8f4e; /* Pi-Rats, success */
|
||||||
|
--x-red: #c92a35; /* danger: the X that marks the spot */
|
||||||
|
--cannonball: #2c3245; /* black suits: dark navy iron */
|
||||||
|
--sunset: #c2640e; /* warnings, jokers */
|
||||||
|
--sea-orchid: #7757c9; /* votes, objectives, misc arcana */
|
||||||
|
--sea-mist: #69808f; /* dead & ghostly things */
|
||||||
|
|
||||||
|
/* Playing-card faces (crisp cards on the sailcloth table) */
|
||||||
|
--card-face: #fffdf2;
|
||||||
|
--card-face-shade: #e7d7ac;
|
||||||
|
--card-ink-red: #b03546;
|
||||||
|
--card-ink-black: #2a3040;
|
||||||
|
|
||||||
|
/* --- Semantic assignments -------------------------------- */
|
||||||
|
--bg: var(--sky);
|
||||||
|
--bg-deep: var(--sky-deep);
|
||||||
|
--surface: var(--sail);
|
||||||
|
--surface-raised: var(--sail-bright);
|
||||||
|
--well: color-mix(in srgb, var(--map-ink) 8%, transparent);
|
||||||
|
|
||||||
|
--text: var(--map-ink);
|
||||||
|
--text-muted: var(--map-faded);
|
||||||
|
--text-inverse: #fff8e6;
|
||||||
|
|
||||||
|
--accent: var(--doubloon);
|
||||||
|
--accent-bright: var(--doubloon-bright);
|
||||||
|
--accent-dark: var(--doubloon-dark);
|
||||||
|
|
||||||
|
--deep: var(--lagoon);
|
||||||
|
--pirat: var(--palm);
|
||||||
|
--success: var(--palm);
|
||||||
|
--danger: var(--x-red);
|
||||||
|
--warning: var(--sunset);
|
||||||
|
--mystic: var(--sea-orchid);
|
||||||
|
--ghost: var(--sea-mist);
|
||||||
|
|
||||||
|
--suit-red: var(--x-red);
|
||||||
|
--suit-black: var(--cannonball);
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--edge: color-mix(in srgb, var(--map-ink) 22%, transparent);
|
||||||
|
--edge-soft: color-mix(in srgb, var(--map-ink) 11%, transparent);
|
||||||
|
--edge-accent: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
|
||||||
|
/* --- Type ------------------------------------------------ */
|
||||||
|
--font-display: 'Pirata One', 'Alegreya SC', serif;
|
||||||
|
--font-heading: 'Alegreya SC', serif;
|
||||||
|
--font-body: 'Alegreya Sans', sans-serif;
|
||||||
|
|
||||||
|
/* --- Shape & motion -------------------------------------- */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--shadow-soft: 0 4px 14px rgba(36, 62, 87, 0.18);
|
||||||
|
--shadow-deep: 0 10px 30px rgba(36, 62, 87, 0.28);
|
||||||
|
--shadow-card: 0 3px 10px rgba(36, 62, 87, 0.22);
|
||||||
|
--shadow-card-raised: 0 4px 15px rgba(36, 62, 87, 0.3);
|
||||||
|
--shadow-card-lifted: 0 10px 25px rgba(36, 62, 87, 0.32);
|
||||||
|
--transition-smooth: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
/* --- "Lantern & Brine" raw palette ----------------------- */
|
||||||
|
--ink-deep: #0a0d13; /* darkest: page edges, sunken wells */
|
||||||
|
--ink: #131822; /* page background */
|
||||||
|
--hull: #1b212e; /* panel surface */
|
||||||
|
--hull-light: #262e3f; /* raised surface */
|
||||||
|
|
||||||
|
--parchment: #efe7d3; /* primary text */
|
||||||
|
--driftwood: #a89e8a; /* muted text */
|
||||||
|
|
||||||
|
--brass: #d9a23c; /* primary accent */
|
||||||
|
--brass-bright: #f3c365;
|
||||||
|
--brass-dark: #93701f;
|
||||||
|
|
||||||
|
--tide: #41c9bd; /* the Deep */
|
||||||
|
--kelp: #6cc983; /* Pi-Rats, success */
|
||||||
|
--coral: #e8606e; /* danger, red suits */
|
||||||
|
--bone: #dfe6ef; /* black suits */
|
||||||
|
--ember: #f0954c; /* warnings, jokers */
|
||||||
|
|
||||||
|
/* Playing-card faces (light parchment cards on a dark table) */
|
||||||
|
--card-face: #f2e9d2;
|
||||||
|
--card-face-shade: #ddcfa9;
|
||||||
|
|
||||||
|
/* --- Semantic assignments -------------------------------- */
|
||||||
|
--bg: var(--ink);
|
||||||
|
--bg-deep: var(--ink-deep);
|
||||||
|
--surface: var(--hull);
|
||||||
|
--surface-raised: var(--hull-light);
|
||||||
|
--well: color-mix(in srgb, var(--ink-deep) 55%, transparent);
|
||||||
|
|
||||||
|
--text: var(--parchment);
|
||||||
|
--text-muted: var(--driftwood);
|
||||||
|
--text-inverse: #1c1508;
|
||||||
|
|
||||||
|
--accent: var(--brass);
|
||||||
|
--accent-bright: var(--brass-bright);
|
||||||
|
--accent-dark: var(--brass-dark);
|
||||||
|
|
||||||
|
--deep: var(--tide);
|
||||||
|
--pirat: var(--kelp);
|
||||||
|
--success: var(--kelp);
|
||||||
|
--danger: var(--coral);
|
||||||
|
--warning: var(--ember);
|
||||||
|
--mystic: #a08cd8;
|
||||||
|
--ghost: #8fa3b8;
|
||||||
|
|
||||||
|
--suit-red: var(--coral);
|
||||||
|
--suit-black: var(--bone);
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--edge: color-mix(in srgb, var(--parchment) 14%, transparent);
|
||||||
|
--edge-soft: color-mix(in srgb, var(--parchment) 8%, transparent);
|
||||||
|
--edge-accent: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
|
||||||
|
/* --- Shadows --------------------------------------------- */
|
||||||
|
--shadow-soft: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-deep: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-card: 0 3px 10px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-card-raised: 0 4px 15px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-card-lifted: 0 10px 25px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
141
frontend/src/assets/css/upkeep.css
Normal file
141
frontend/src/assets/css/upkeep.css
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/* --- Between-scenes upkeep --- */
|
||||||
|
.between-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Between scenes there's only the voting/roster panel — center it. */
|
||||||
|
.between-grid.single {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 640px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranks-ledger {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranks-ledger li {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Death fate panel --- */
|
||||||
|
.death-fate-card {
|
||||||
|
border: 2px solid var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 5%, var(--surface));
|
||||||
|
padding: 2.5rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
max-width: 650px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.death-fate-card > h2 {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.death-fate-card .description {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fate-choices {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-form-wrapper {
|
||||||
|
text-align: left;
|
||||||
|
background: var(--well);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--edge-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-form-wrapper h3 {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--edge-accent);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Deep hand refresh drag & drop --- */
|
||||||
|
.upkeep-drag-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.upkeep-drag-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upkeep-box {
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upkeep-box h4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upkeep-flex {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px dashed var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
align-content: flex-start;
|
||||||
|
min-height: 300px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-story-box {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
89
frontend/src/assets/css/welcome.css
Normal file
89
frontend/src/assets/css/welcome.css
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/* --- Welcome / splash & join screens --- */
|
||||||
|
.welcome-container {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4rem 1.5rem 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-hero {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-overline {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: clamp(3.2rem, 10vw, 5rem);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: linear-gradient(175deg, var(--accent-bright) 20%, var(--accent) 55%, var(--accent-dark) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
filter: drop-shadow(0 3px 12px color-mix(in srgb, var(--accent) 30%, transparent));
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark .amp {
|
||||||
|
font-size: 0.45em;
|
||||||
|
vertical-align: 0.45em;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-hero .subtitle {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anchor divider: ——— ⚓ ——— */
|
||||||
|
.hero-flourish {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin: 1.75rem auto 0;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-flourish::before,
|
||||||
|
.hero-flourish::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, transparent, var(--edge-accent), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-card {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-card h2 {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-links {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
82
frontend/src/components/AboutModal.svelte
Normal file
82
frontend/src/components/AboutModal.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { VERSION, CHANGELOG } from '../lib/changelog';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
function close() { dispatch('close'); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||||
|
|
||||||
|
<div class="modal-backdrop" on:click|self={close}>
|
||||||
|
<div class="modal-box sheet-modal glass-panel about-modal">
|
||||||
|
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||||
|
<h3>About</h3>
|
||||||
|
<p class="about-tagline">A remote-play companion for <em>Rats with Gats</em>.</p>
|
||||||
|
<p class="about-version">Version {VERSION}</p>
|
||||||
|
|
||||||
|
<h4 class="about-heading">What's New</h4>
|
||||||
|
<ul class="changelog">
|
||||||
|
{#each CHANGELOG as entry (entry.version)}
|
||||||
|
<li class="changelog-entry">
|
||||||
|
<div class="changelog-head">
|
||||||
|
<span class="changelog-ver">v{entry.version}</span>
|
||||||
|
<span class="changelog-date text-muted">{entry.date}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="changelog-changes">
|
||||||
|
{#each entry.changes as change}
|
||||||
|
<li>{change}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.about-tagline {
|
||||||
|
margin: 0.25rem 0 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.about-version {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.about-heading {
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
.changelog {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.changelog-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.changelog-ver {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.changelog-date {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.changelog-changes {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
59
frontend/src/components/Card.svelte
Normal file
59
frontend/src/components/Card.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script>
|
||||||
|
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, cardTooltipHtml, obstacleTable } from '../lib/cards';
|
||||||
|
import { tooltip } from '../lib/tooltip';
|
||||||
|
|
||||||
|
export let card; // card code, e.g. "10D" or "Joker1"
|
||||||
|
export let size = 'large'; // 'large' | 'medium' | 'mini'
|
||||||
|
export let draggable = false;
|
||||||
|
export let rotated = false; // mini: rendered as a played (rotated) column card
|
||||||
|
export let success = null; // mini: true/false adds success/failure styling
|
||||||
|
export let owner = ''; // mini: short label of who played the card
|
||||||
|
export let title = ''; // explicit override; otherwise the card describes itself
|
||||||
|
export let techs = null; // large: {J, Q, K} technique names for the face-card overlay
|
||||||
|
export let style = '';
|
||||||
|
|
||||||
|
$: joker = isJoker(card);
|
||||||
|
$: val = joker ? '🃏' : cardValue(card);
|
||||||
|
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
|
||||||
|
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
|
||||||
|
$: techName = techs && !joker ? techs[cardValue(card)] : null;
|
||||||
|
// Tooltip: an explicit title wins (plain text); otherwise a rich graphical
|
||||||
|
// tooltip describing the card itself (suit theme + Obstacle meaning), which
|
||||||
|
// recomputes once the obstacle table loads.
|
||||||
|
$: tipContent = title ? title : { html: cardTooltipHtml(card, $obstacleTable) };
|
||||||
|
$: ariaLabel = title || cardTooltip(card, $obstacleTable);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if size === 'mini'}
|
||||||
|
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}"
|
||||||
|
use:tooltip={tipContent} aria-label={ariaLabel}>
|
||||||
|
<span class="val">{val}</span>
|
||||||
|
<span class="suit">{suit}</span>
|
||||||
|
{#if owner}<span class="owner">{owner}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
|
||||||
|
use:tooltip={tipContent} aria-label={ariaLabel} {draggable} {style}
|
||||||
|
on:dragstart on:dragend on:click>
|
||||||
|
<div class="card-corner top-left">
|
||||||
|
<span class="val">{val}</span>
|
||||||
|
<span class="suit">{suit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-center">
|
||||||
|
{#if joker}
|
||||||
|
🃏
|
||||||
|
{:else}
|
||||||
|
<span class="giant-symbol" style={size === 'medium' ? 'font-size: 1.5rem;' : ''}>{suit}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if techName}
|
||||||
|
<div class="card-tech-overlay">
|
||||||
|
<div class="tech-tag" use:tooltip={`Automatic success when played\n\n"${techName}"`}>{cardValue(card)}: "{techName}"</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="card-corner bottom-right">
|
||||||
|
<span class="val">{val}</span>
|
||||||
|
<span class="suit">{suit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
570
frontend/src/components/CharacterCreationPhase.svelte
Normal file
570
frontend/src/components/CharacterCreationPhase.svelte
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
|
||||||
|
import { displayName } from '../lib/cards';
|
||||||
|
import FaceCardAssigner from './FaceCardAssigner.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
let basicDetails = {
|
||||||
|
avatar_look: state.player.avatar_look || '',
|
||||||
|
avatar_smell: state.player.avatar_smell || '',
|
||||||
|
first_words: state.player.first_words || '',
|
||||||
|
good_at_math: state.player.good_at_math || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
let savingBasic = false;
|
||||||
|
let editingBasic = false;
|
||||||
|
|
||||||
|
async function saveBasicDetails() {
|
||||||
|
savingBasic = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails);
|
||||||
|
editingBasic = false;
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
savingBasic = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviseBasicDetails() {
|
||||||
|
basicDetails = {
|
||||||
|
avatar_look: state.player.avatar_look || '',
|
||||||
|
avatar_smell: state.player.avatar_smell || '',
|
||||||
|
first_words: state.player.first_words || '',
|
||||||
|
good_at_math: state.player.good_at_math || ''
|
||||||
|
};
|
||||||
|
editingBasic = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard any edits made since clicking Revise and return to the saved view.
|
||||||
|
function cancelReviseBasic() {
|
||||||
|
editingBasic = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like/Hate tasks are assigned automatically when character creation starts.
|
||||||
|
// This is a fallback for games that entered the phase before assignments existed.
|
||||||
|
onMount(async () => {
|
||||||
|
if (!delegateComplete && state.players.length >= 2) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/delegate/auto`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A helper to get player name
|
||||||
|
function getPlayerName(id) {
|
||||||
|
if (!id) return "None";
|
||||||
|
const p = state.players.find(x => x.id === id);
|
||||||
|
return p ? displayName(p) : "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Techniques
|
||||||
|
let tech1 = '', tech2 = '', tech3 = '';
|
||||||
|
let savingTechs = false;
|
||||||
|
let techError = '';
|
||||||
|
// True while editing previously-submitted techniques (vs. first-time entry),
|
||||||
|
// so we know to offer a Cancel button. techSnapshot holds the saved values.
|
||||||
|
let revisingTechs = false;
|
||||||
|
let techSnapshot = [];
|
||||||
|
|
||||||
|
async function submitTechniques() {
|
||||||
|
savingTechs = true;
|
||||||
|
techError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||||
|
tech1, tech2, tech3
|
||||||
|
});
|
||||||
|
revisingTechs = false;
|
||||||
|
} catch(e) {
|
||||||
|
techError = e.message;
|
||||||
|
} finally {
|
||||||
|
savingTechs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviseTechniques() {
|
||||||
|
techSnapshot = [...createdTechniques];
|
||||||
|
[tech1 = '', tech2 = '', tech3 = ''] = createdTechniques;
|
||||||
|
revisingTechs = true;
|
||||||
|
techError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unsubmit-techniques`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
techError = e.message;
|
||||||
|
revisingTechs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw away edits and re-submit the techniques as they were before Revise.
|
||||||
|
async function cancelReviseTechniques() {
|
||||||
|
savingTechs = true;
|
||||||
|
techError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||||
|
tech1: techSnapshot[0] || '',
|
||||||
|
tech2: techSnapshot[1] || '',
|
||||||
|
tech3: techSnapshot[2] || ''
|
||||||
|
});
|
||||||
|
revisingTechs = false;
|
||||||
|
} catch(e) {
|
||||||
|
techError = e.message;
|
||||||
|
} finally {
|
||||||
|
savingTechs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technique swapping (swap_techniques phase)
|
||||||
|
let offerTech = '', offerTargetId = '';
|
||||||
|
let acceptChoice = {};
|
||||||
|
let swapBusy = false;
|
||||||
|
let swapError = '';
|
||||||
|
|
||||||
|
async function swapApi(path, body) {
|
||||||
|
swapBusy = true;
|
||||||
|
swapError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/${path}`, 'POST', body);
|
||||||
|
} catch (e) {
|
||||||
|
swapError = e.message;
|
||||||
|
} finally {
|
||||||
|
swapBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function offerSwap() {
|
||||||
|
swapApi('offer-swap', { target_player_id: offerTargetId, technique: offerTech });
|
||||||
|
}
|
||||||
|
function cancelOffer() {
|
||||||
|
swapApi('cancel-swap-offer');
|
||||||
|
}
|
||||||
|
function respondOffer(offererId, accept) {
|
||||||
|
swapApi('respond-swap-offer', {
|
||||||
|
offerer_player_id: offererId,
|
||||||
|
accept,
|
||||||
|
technique: accept ? (acceptChoice[offererId] || '') : ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setSwapReady(ready) {
|
||||||
|
swapApi('swap-ready', { ready });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face Techniques
|
||||||
|
let jackTech = '', queenTech = '', kingTech = '';
|
||||||
|
let assigningFace = false;
|
||||||
|
let faceError = '';
|
||||||
|
// True while revising a previously-assigned J/Q/K layout, so we offer Cancel.
|
||||||
|
let revisingFace = false;
|
||||||
|
let faceSnapshot = {};
|
||||||
|
|
||||||
|
async function assignFaceTechniques() {
|
||||||
|
assigningFace = true;
|
||||||
|
faceError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/assign-face-techniques`, 'POST', {
|
||||||
|
jack: jackTech,
|
||||||
|
queen: queenTech,
|
||||||
|
king: kingTech
|
||||||
|
});
|
||||||
|
revisingFace = false;
|
||||||
|
} catch(e) {
|
||||||
|
faceError = e.message;
|
||||||
|
} finally {
|
||||||
|
assigningFace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviseFaceTechniques() {
|
||||||
|
faceSnapshot = {
|
||||||
|
jack: state.player.tech_jack,
|
||||||
|
queen: state.player.tech_queen,
|
||||||
|
king: state.player.tech_king
|
||||||
|
};
|
||||||
|
jackTech = state.player.tech_jack;
|
||||||
|
queenTech = state.player.tech_queen;
|
||||||
|
kingTech = state.player.tech_king;
|
||||||
|
revisingFace = true;
|
||||||
|
faceError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unassign-face-techniques`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
faceError = e.message;
|
||||||
|
revisingFace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw away edits and re-assign the J/Q/K layout as it was before Revise.
|
||||||
|
async function cancelReviseFaceTechniques() {
|
||||||
|
assigningFace = true;
|
||||||
|
faceError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/assign-face-techniques`, 'POST', {
|
||||||
|
jack: faceSnapshot.jack,
|
||||||
|
queen: faceSnapshot.queen,
|
||||||
|
king: faceSnapshot.king
|
||||||
|
});
|
||||||
|
revisingFace = false;
|
||||||
|
} catch(e) {
|
||||||
|
faceError = e.message;
|
||||||
|
} finally {
|
||||||
|
assigningFace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit delegated answers — one draft per (player, question) so editing one
|
||||||
|
// task never bleeds into another.
|
||||||
|
let inboxAnswers = {};
|
||||||
|
async function submitDelegatedAnswer(type, targetId) {
|
||||||
|
const answer = (inboxAnswers[`${targetId}:${type}`] || '').trim();
|
||||||
|
if (!answer) return;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
|
||||||
|
delete inboxAnswers[`${targetId}:${type}`];
|
||||||
|
inboxAnswers = inboxAnswers;
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check completion status
|
||||||
|
$: phase = state.game.phase;
|
||||||
|
$: createdTechniques = state.player.created_techniques ? JSON.parse(state.player.created_techniques) : [];
|
||||||
|
$: heldTechniques = state.player.held_techniques ? JSON.parse(state.player.held_techniques) : [];
|
||||||
|
$: swappedTechniques = state.player.swapped_techniques ? JSON.parse(state.player.swapped_techniques) : [];
|
||||||
|
$: ownHeldCount = heldTechniques.filter(t => t.creator_id === state.player.id).length;
|
||||||
|
$: incomingOffers = state.players.filter(p => p.id !== state.player.id && p.swap_offer_to_id === state.player.id);
|
||||||
|
$: swapTargets = state.players.filter(p => p.id !== state.player.id && !p.needs_reroll);
|
||||||
|
$: offerTechCreator = (heldTechniques.find(t => t.text === offerTech) || {}).creator_id;
|
||||||
|
$: offerInvalid = !offerTech || !offerTargetId || offerTechCreator === offerTargetId;
|
||||||
|
|
||||||
|
$: basicComplete = !!(state.player.avatar_look && state.player.avatar_smell && state.player.first_words && state.player.good_at_math);
|
||||||
|
$: delegateComplete = !!(state.player.other_like_from_player_id && state.player.other_hate_from_player_id);
|
||||||
|
$: techniquesComplete = !!(state.player.tech_jack && state.player.tech_queen && state.player.tech_king);
|
||||||
|
|
||||||
|
// Dev Mode (toggled by the creator from the corner menu) reveals the
|
||||||
|
// Suggest buttons; in real games players write their own material.
|
||||||
|
$: devMode = !!state.game.dev_mode;
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="character-creation-view">
|
||||||
|
<div class="view-header text-center">
|
||||||
|
<h2>Character Sheet Creation</h2>
|
||||||
|
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MAIN PANEL: Grid for Layout -->
|
||||||
|
<div class="creation-grid max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
<!-- SECTION 1: Rat Records (Basic Info) -->
|
||||||
|
<div class="card glass-panel section-basic">
|
||||||
|
<h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3>
|
||||||
|
|
||||||
|
{#if basicComplete && !editingBasic}
|
||||||
|
<!-- Saved View -->
|
||||||
|
<div class="read-only-sheet">
|
||||||
|
<div class="record-line"><strong>Look:</strong> {state.player.avatar_look}</div>
|
||||||
|
<div class="record-line"><strong>Smell:</strong> {state.player.avatar_smell}</div>
|
||||||
|
<div class="record-line"><strong>First Words:</strong> "{state.player.first_words}"</div>
|
||||||
|
<div class="record-line"><strong>Good at Math?</strong> {state.player.good_at_math}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
|
||||||
|
{:else}
|
||||||
|
<!-- Form View -->
|
||||||
|
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="avatar_look">What does your Pi-Rat look like?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="first_words">What were your first words after transforming?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-row mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
||||||
|
{#if basicComplete}
|
||||||
|
<button type="button" class="btn btn-secondary" disabled={savingBasic} on:click={cancelReviseBasic}>Cancel</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 2: Delegated Questions (Like/Hate) -->
|
||||||
|
<div class="card glass-panel section-delegations">
|
||||||
|
<h3>II. Crew Delegations {delegateComplete ? '✅' : '❌'}</h3>
|
||||||
|
<p class="section-desc">Other players decide what their rats like and hate about your character. Crewmates have been randomly assigned to answer for you.</p>
|
||||||
|
|
||||||
|
<div class="delegation-list">
|
||||||
|
{#if delegateComplete}
|
||||||
|
<div class="delegation-box glass-panel">
|
||||||
|
<h4>What does another rat LIKE about you?</h4>
|
||||||
|
<div class="answer-box">
|
||||||
|
{state.player.other_like ? `"${state.player.other_like}"` : '(Waiting for answer...)'}
|
||||||
|
</div>
|
||||||
|
<div class="author-label">— {getPlayerName(state.player.other_like_from_player_id)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="delegation-box glass-panel">
|
||||||
|
<h4>What does another rat HATE about you?</h4>
|
||||||
|
<div class="answer-box">
|
||||||
|
{state.player.other_hate ? `"${state.player.other_hate}"` : '(Waiting for answer...)'}
|
||||||
|
</div>
|
||||||
|
<div class="author-label">— {getPlayerName(state.player.other_hate_from_player_id)}</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-4 text-sm text-muted">Assigning crewmates to write your relationships...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 3: Answer Delegations for Others -->
|
||||||
|
<div class="card glass-panel section-inbox" style="grid-column: span 2;">
|
||||||
|
<h3>III. Your Inbox (Task List)</h3>
|
||||||
|
<p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p>
|
||||||
|
<div class="inbox-list">
|
||||||
|
{#each state.players as p}
|
||||||
|
{#if p.other_like_from_player_id === state.player.id}
|
||||||
|
<div class="inbox-item">
|
||||||
|
<label>
|
||||||
|
What do you LIKE about {displayName(p)}?
|
||||||
|
{#if p.other_like}<span class="text-success">(submitted: "{p.other_like}" — you can revise it)</span>{/if}
|
||||||
|
</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>{/if}
|
||||||
|
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if p.other_hate_from_player_id === state.player.id}
|
||||||
|
<div class="inbox-item">
|
||||||
|
<label>
|
||||||
|
What do you HATE about {displayName(p)}?
|
||||||
|
{#if p.other_hate}<span class="text-success">(submitted: "{p.other_hate}" — you can revise it)</span>{/if}
|
||||||
|
</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if}
|
||||||
|
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if state.players.filter(p => p.other_like_from_player_id === state.player.id || p.other_hate_from_player_id === state.player.id).length === 0}
|
||||||
|
<p class="inbox-empty-message text-muted italic">No pending tasks! Wait for other players to assign you tasks.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 4: Techniques -->
|
||||||
|
<div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;">
|
||||||
|
<h3>IV. Secret Techniques {techniquesComplete ? '✅' : '❌'}</h3>
|
||||||
|
|
||||||
|
{#if state.player.needs_reroll}
|
||||||
|
<p class="text-muted italic">You're spectating for now — you'll create your Pi-Rat with the crew between scenes.</p>
|
||||||
|
{:else if phase === 'character_creation' && createdTechniques.length === 0}
|
||||||
|
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
|
||||||
|
{#if techError}
|
||||||
|
<div class="alert alert-danger">{techError}</div>
|
||||||
|
{/if}
|
||||||
|
<form on:submit|preventDefault={submitTechniques} id="tech-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tech1">Technique #1:</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech1 = await getTechniqueSuggestion([tech2, tech3])}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tech2">Technique #2:</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech2 = await getTechniqueSuggestion([tech1, tech3])}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tech3">Technique #3:</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
||||||
|
{#if revisingTechs}
|
||||||
|
<button type="button" class="btn btn-secondary btn-full mt-4" disabled={savingTechs} on:click={cancelReviseTechniques}>Cancel — keep my original techniques</button>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{:else if phase === 'character_creation'}
|
||||||
|
<div class="submitted-techniques text-center mt-4">
|
||||||
|
{#if techError}
|
||||||
|
<div class="alert alert-danger">{techError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="tech-chip">#1: {createdTechniques[0]}</div>
|
||||||
|
<div class="tech-chip">#2: {createdTechniques[1]}</div>
|
||||||
|
<div class="tech-chip">#3: {createdTechniques[2]}</div>
|
||||||
|
<p class="waiting-box margin-top flex justify-center mt-4">
|
||||||
|
<span class="spinner-small"></span>
|
||||||
|
Techniques submitted! Once everyone submits, you'll trade them all away in blind swaps.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseTechniques}>✏️ Revise Techniques</button>
|
||||||
|
</div>
|
||||||
|
{:else if phase === 'swap_techniques'}
|
||||||
|
<p class="section-desc">
|
||||||
|
Trade away all 3 techniques you created by offering blind swaps to your crewmates.
|
||||||
|
They only learn that you offered a swap — never which technique is on the table!
|
||||||
|
You can't offer a technique to the rat who wrote it.
|
||||||
|
</p>
|
||||||
|
{#if swapError}
|
||||||
|
<div class="alert alert-danger">{swapError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h4>Your techniques in hand</h4>
|
||||||
|
<div class="submitted-techniques mt-4">
|
||||||
|
{#each heldTechniques as t}
|
||||||
|
<div class="tech-chip">
|
||||||
|
{t.text}
|
||||||
|
{#if t.creator_id === state.player.id}
|
||||||
|
<span class="text-warning"> — yours, swap it away!</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-success"> — from {getPlayerName(t.creator_id)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if state.player.swap_offer_to_id}
|
||||||
|
<div class="delegation-box glass-panel mt-4">
|
||||||
|
<p>
|
||||||
|
You offered <strong>"{state.player.swap_offer_technique}"</strong> to
|
||||||
|
{getPlayerName(state.player.swap_offer_to_id)}. Waiting for their answer...
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-secondary mt-4" disabled={swapBusy} on:click={cancelOffer}>Withdraw Offer</button>
|
||||||
|
</div>
|
||||||
|
{:else if !state.player.is_ready}
|
||||||
|
<div class="delegation-box glass-panel mt-4">
|
||||||
|
<h4>Offer a swap</h4>
|
||||||
|
<div class="input-row">
|
||||||
|
<select class="input-field" bind:value={offerTech}>
|
||||||
|
<option value="" disabled>Pick a technique to give...</option>
|
||||||
|
{#each heldTechniques as t}
|
||||||
|
<option value={t.text}>{t.text}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select class="input-field" bind:value={offerTargetId}>
|
||||||
|
<option value="" disabled>Pick a crewmate...</option>
|
||||||
|
{#each swapTargets as p}
|
||||||
|
<option value={p.id} disabled={offerTechCreator === p.id}>
|
||||||
|
{displayName(p)}{offerTechCreator === p.id ? ' (wrote it!)' : ''}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" disabled={swapBusy || offerInvalid} on:click={offerSwap}>Offer Swap</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each incomingOffers as offerer (offerer.id)}
|
||||||
|
<div class="delegation-box glass-panel mt-4">
|
||||||
|
<h4>📨 {displayName(offerer)} offered you a mystery technique!</h4>
|
||||||
|
<p class="text-sm text-muted">Choose one of your techniques to trade back (not one they wrote):</p>
|
||||||
|
<div class="input-row">
|
||||||
|
<select class="input-field" bind:value={acceptChoice[offerer.id]}>
|
||||||
|
<option value="" disabled selected>Pick a technique to give back...</option>
|
||||||
|
{#each heldTechniques as t}
|
||||||
|
<option value={t.text} disabled={t.creator_id === offerer.id}>
|
||||||
|
{t.text}{t.creator_id === offerer.id ? ' (they wrote it!)' : ''}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" disabled={swapBusy || !acceptChoice[offerer.id]} on:click={() => respondOffer(offerer.id, true)}>Accept</button>
|
||||||
|
<button class="btn btn-secondary" disabled={swapBusy} on:click={() => respondOffer(offerer.id, false)}>Decline</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="text-center mt-6">
|
||||||
|
{#if state.player.is_ready}
|
||||||
|
<p class="waiting-box flex justify-center">
|
||||||
|
<span class="spinner-small"></span>
|
||||||
|
Done swapping! Waiting for the rest of the crew...
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-secondary mt-4" disabled={swapBusy} on:click={() => setSwapReady(false)}>Wait, I want to keep swapping</button>
|
||||||
|
{:else if ownHeldCount > 0}
|
||||||
|
<p class="text-muted italic">Swap away {ownHeldCount} more of your own technique{ownHeldCount === 1 ? '' : 's'} before you can ready up.</p>
|
||||||
|
{:else}
|
||||||
|
<button class="btn btn-primary btn-large" disabled={swapBusy || !!state.player.swap_offer_to_id} on:click={() => setSwapReady(true)}>I'm done swapping</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if !techniquesComplete}
|
||||||
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
|
<p>Drag and drop your assigned techniques onto your Face Cards:</p>
|
||||||
|
|
||||||
|
{#if faceError}
|
||||||
|
<div class="alert alert-danger">{faceError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<FaceCardAssigner techniques={swappedTechniques} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} />
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-large w-full"
|
||||||
|
disabled={assigningFace || !jackTech || !queenTech || !kingTech}
|
||||||
|
on:click={assignFaceTechniques}
|
||||||
|
>
|
||||||
|
{assigningFace ? 'Assigning...' : 'Assign to Cards'}
|
||||||
|
</button>
|
||||||
|
{#if revisingFace}
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary w-full mt-4"
|
||||||
|
disabled={assigningFace}
|
||||||
|
on:click={cancelReviseFaceTechniques}
|
||||||
|
>Cancel — keep my original assignment</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="assigned-techs text-center mt-4">
|
||||||
|
{#if faceError}
|
||||||
|
<div class="alert alert-danger">{faceError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="tech-assignment-pill"><span class="card-rank">J</span> {state.player.tech_jack}</div>
|
||||||
|
<div class="tech-assignment-pill"><span class="card-rank">Q</span> {state.player.tech_queen}</div>
|
||||||
|
<div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div>
|
||||||
|
<p class="waiting-box margin-top flex justify-center mt-4">
|
||||||
|
<span class="spinner-small"></span>
|
||||||
|
Ready! Waiting for other players to finish J/Q/K assignment...
|
||||||
|
</p>
|
||||||
|
{#if phase === 'assign_techniques'}
|
||||||
|
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseFaceTechniques}>✏️ Revise Assignment</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
112
frontend/src/components/CornerMenu.svelte
Normal file
112
frontend/src/components/CornerMenu.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script>
|
||||||
|
import AboutModal from './AboutModal.svelte';
|
||||||
|
import { extraMenuLinks } from '../lib/menu';
|
||||||
|
import { theme, toggleTheme } from '../lib/theme';
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
let showAbout = false;
|
||||||
|
|
||||||
|
$: themeLink = $theme === 'dark'
|
||||||
|
? { label: '☀️ Light Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the light theme' }
|
||||||
|
: { label: '🌙 Dark Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the dark theme' };
|
||||||
|
|
||||||
|
$: baseLinks = [
|
||||||
|
{ label: '📖 Rules', href: '/rules', external: true, title: 'Open the rulebook in a new tab' },
|
||||||
|
themeLink,
|
||||||
|
{ label: 'ℹ️ About', action: () => (showAbout = true), title: 'Version and changelog' },
|
||||||
|
];
|
||||||
|
|
||||||
|
$: links = [...$extraMenuLinks, ...baseLinks];
|
||||||
|
|
||||||
|
function handleWindowClick(event) {
|
||||||
|
if (open && !event.target.closest('.corner-menu')) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:click={handleWindowClick} />
|
||||||
|
|
||||||
|
<div class="corner-menu">
|
||||||
|
<button class="menu-toggle" class:open on:click={() => (open = !open)} title="Menu">
|
||||||
|
☰ Menu
|
||||||
|
</button>
|
||||||
|
{#if open}
|
||||||
|
<nav class="menu-dropdown">
|
||||||
|
{#each links as link}
|
||||||
|
{#if link.action}
|
||||||
|
<button
|
||||||
|
class="menu-item"
|
||||||
|
title={link.title}
|
||||||
|
on:click={() => { if (!link.keepOpen) open = false; link.action(); }}
|
||||||
|
>{link.label}</button>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
class="menu-item"
|
||||||
|
href={link.href}
|
||||||
|
target={link.external ? '_blank' : undefined}
|
||||||
|
rel={link.external ? 'noopener' : undefined}
|
||||||
|
title={link.title}
|
||||||
|
on:click={() => (open = false)}
|
||||||
|
>{link.label}</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showAbout}
|
||||||
|
<AboutModal on:close={() => (showAbout = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.corner-menu {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 1001;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.menu-toggle {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 92%, transparent);
|
||||||
|
border: 1px solid var(--edge-accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.menu-toggle:hover, .menu-toggle.open {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
|
||||||
|
}
|
||||||
|
.menu-dropdown {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 96%, transparent);
|
||||||
|
border: 1px solid var(--edge-accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.menu-item:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
.menu-item + .menu-item {
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
97
frontend/src/components/CrewSidebar.svelte
Normal file
97
frontend/src/components/CrewSidebar.svelte
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script>
|
||||||
|
import { displayName, crewLabel } from '../lib/cards';
|
||||||
|
import CharacterSheet from './scene/CharacterSheet.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let openTargetId = null;
|
||||||
|
|
||||||
|
$: captainId = state.game.captain_player_id;
|
||||||
|
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
||||||
|
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
||||||
|
|
||||||
|
function iconFor(p) {
|
||||||
|
if (p.role === 'deep') return '🌊';
|
||||||
|
if (p.is_ghost) return '👻';
|
||||||
|
if (p.is_dead) return '💀';
|
||||||
|
return '🐀';
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeArray(value) {
|
||||||
|
try { return JSON.parse(value || '[]'); } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFor(p) {
|
||||||
|
const phase = state.game.phase;
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (p.is_creator) return 'Creator';
|
||||||
|
if (p.is_admin) return 'Admin';
|
||||||
|
return 'Crew member';
|
||||||
|
}
|
||||||
|
if (phase === 'character_creation') {
|
||||||
|
if (p.needs_reroll) return 'Spectating';
|
||||||
|
return safeArray(p.created_techniques).length === 3 ? 'Techniques submitted' : 'Writing sheet';
|
||||||
|
}
|
||||||
|
if (phase === 'swap_techniques') {
|
||||||
|
if (p.is_ready) return 'Done swapping';
|
||||||
|
const ownLeft = safeArray(p.held_techniques).filter(t => t.creator_id === p.id).length;
|
||||||
|
return `Swapping · ${ownLeft} own left`;
|
||||||
|
}
|
||||||
|
if (phase === 'assign_techniques') return p.tech_jack ? 'Ready' : 'Assigning face cards';
|
||||||
|
if (phase === 'scene_setup') {
|
||||||
|
if (p.needs_reroll) return 'Spectating';
|
||||||
|
if (p.role === 'deep') return 'The Deep';
|
||||||
|
if (p.role === 'pirat') return `Pi-Rat · Rank ${p.rank}`;
|
||||||
|
return 'Choosing a role…';
|
||||||
|
}
|
||||||
|
if (phase === 'between_scenes') {
|
||||||
|
const voted = (state.votes || []).some(v => v.voter_player_id === p.id);
|
||||||
|
const canVote = state.players.some(candidate =>
|
||||||
|
candidate.id !== p.id && candidate.role !== 'deep' && !candidate.is_dead && !candidate.needs_reroll
|
||||||
|
);
|
||||||
|
return `Rank ${p.rank} · ${voted ? 'Nomination done' : canVote ? 'Nominating…' : 'No vote'}`;
|
||||||
|
}
|
||||||
|
if (phase === 'deep_upkeep') return `Rank ${p.rank} · ${p.is_ready ? 'Ready' : 'Finishing upkeep…'}`;
|
||||||
|
if (phase === 'recruit_creation') return p.needs_reroll ? 'Creating a new Pi-Rat' : `Rank ${p.rank} · Helping recruits`;
|
||||||
|
if (phase === 'ended') {
|
||||||
|
const story = p.completed_personal_3 ? 'Story complete' : 'Still sailing';
|
||||||
|
return `Rank ${p.rank} · ${story}`;
|
||||||
|
}
|
||||||
|
return `Rank ${p.rank}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="crew-column phase-crew-column" aria-label="Crew roster">
|
||||||
|
<div class="crew-bubbles">
|
||||||
|
{#each crew as p (p.id)}
|
||||||
|
{#if state.game.phase === 'lobby'}
|
||||||
|
<div
|
||||||
|
class="crew-bubble is-static"
|
||||||
|
class:is-you={p.id === state.player.id}
|
||||||
|
>
|
||||||
|
<span class="bubble-icon">{iconFor(p)}</span>
|
||||||
|
<span class="bubble-name">{displayName(p)}</span>
|
||||||
|
<span class="bubble-meta">{statusFor(p)}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="crew-bubble"
|
||||||
|
class:is-you={p.id === state.player.id}
|
||||||
|
class:is-deep={p.role === 'deep'}
|
||||||
|
class:is-dead={p.is_dead}
|
||||||
|
class:is-ghost={p.is_ghost}
|
||||||
|
title="View {p.name}'s character sheet"
|
||||||
|
on:click={() => openTargetId = p.id}
|
||||||
|
>
|
||||||
|
<span class="bubble-icon">{iconFor(p)}</span>
|
||||||
|
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||||
|
<span class="bubble-meta">{statusFor(p)}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{#if openTarget}
|
||||||
|
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
||||||
|
{/if}
|
||||||
515
frontend/src/components/EventLog.svelte
Normal file
515
frontend/src/components/EventLog.svelte
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
<script>
|
||||||
|
import { tick, onMount, createEventDispatcher } from 'svelte';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
// Inline mode pins the log open as a column (scene phase) instead of the
|
||||||
|
// floating, collapsible corner panel used in every other phase.
|
||||||
|
export let inline = false;
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
||||||
|
const TOP_LOAD_THRESHOLD = 60; // px from the top that triggers loading older events
|
||||||
|
|
||||||
|
const KIND_ICONS = {
|
||||||
|
join: '👋',
|
||||||
|
phase: '🧭',
|
||||||
|
scene: '🎬',
|
||||||
|
captain: '🏴☠️',
|
||||||
|
challenge: '⚔️',
|
||||||
|
card: '🃏',
|
||||||
|
tax: '💰',
|
||||||
|
objective: '🎯',
|
||||||
|
vote: '🗳️',
|
||||||
|
obstacle: '🌊',
|
||||||
|
joker: '🤡',
|
||||||
|
victory: '🎉',
|
||||||
|
warning: '⚠️',
|
||||||
|
rank: '🎖️',
|
||||||
|
info: '📜',
|
||||||
|
};
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
// The content is visible whenever the floating log is open OR it's pinned inline.
|
||||||
|
$: shown = open || inline;
|
||||||
|
let logEl;
|
||||||
|
let events = []; // ascending by timestamp; accumulated across polls + history loads
|
||||||
|
let seenIds = new Set();
|
||||||
|
let haveMore = true;
|
||||||
|
let loadingOlder = false;
|
||||||
|
let atBottom = true;
|
||||||
|
let error = '';
|
||||||
|
let timelineVersion = null;
|
||||||
|
// Toast that briefly previews a freshly-arrived event while the log is
|
||||||
|
// collapsed, then animates down into the Event Log button.
|
||||||
|
let toast = null; // { id, message, kind }
|
||||||
|
let initialized = false; // suppresses a toast flood on the first state load
|
||||||
|
|
||||||
|
|
||||||
|
// Rollback buttons appear during a scene, for Admins and Deep players (server-enforced too).
|
||||||
|
$: canRollback = state.game?.phase === 'scene'
|
||||||
|
&& (state.player?.is_admin || state.player?.role === 'deep');
|
||||||
|
// When set, the game is at a rolled-back state; events past this checkpoint are the
|
||||||
|
// greyed, still-undoable future. Rolling forward to one of them redoes the rollback.
|
||||||
|
$: head = state.game?.rollback_head_checkpoint_id ?? null;
|
||||||
|
// The point in time the game is currently at: the rollback head if set, otherwise
|
||||||
|
// the newest checkpoint (supplied by the backend). A button targeting it would be a
|
||||||
|
// no-op, so the "you are here" event — and, in live play, the latest event — has none.
|
||||||
|
$: currentPos = head !== null ? head : (state.latest_checkpoint_id ?? 0);
|
||||||
|
|
||||||
|
$: mergePolled(state.events);
|
||||||
|
|
||||||
|
// The poll only carries the newest PAGE_SIZE events, so accumulate them here —
|
||||||
|
// otherwise events the player paged back to would vanish as the window slides.
|
||||||
|
async function mergePolled(polled) {
|
||||||
|
if (!polled) return;
|
||||||
|
// Plain rollback/redo keeps every event (only `head` moves); but taking a new
|
||||||
|
// action from a rolled-back state truncates the abandoned future and bumps the
|
||||||
|
// version, which tells us to drop what we'd accumulated and reseed.
|
||||||
|
const version = state.game?.rollback_timeline_version ?? 0;
|
||||||
|
let reset = false;
|
||||||
|
if (timelineVersion !== null && version !== timelineVersion) {
|
||||||
|
events = [];
|
||||||
|
seenIds = new Set();
|
||||||
|
reset = true;
|
||||||
|
}
|
||||||
|
timelineVersion = version;
|
||||||
|
if (events.length === 0) {
|
||||||
|
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
||||||
|
}
|
||||||
|
let added = false;
|
||||||
|
let newest = null;
|
||||||
|
for (const e of polled) {
|
||||||
|
if (!seenIds.has(e.id)) {
|
||||||
|
seenIds.add(e.id);
|
||||||
|
events.push(e);
|
||||||
|
added = true;
|
||||||
|
newest = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added) {
|
||||||
|
events = events;
|
||||||
|
if (shown && atBottom) {
|
||||||
|
await tick();
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
// Toast only for genuinely new events (not the initial seed or a
|
||||||
|
// rollback reseed) and only while the floating log is closed.
|
||||||
|
if (initialized && !reset && !shown && newest) {
|
||||||
|
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An open/inline log makes the preview toast redundant.
|
||||||
|
$: if (shown) toast = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (inline) scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearToast(id) {
|
||||||
|
if (toast && toast.id === id) toast = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
atBottom = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (!logEl) return;
|
||||||
|
atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight <= BOTTOM_TOLERANCE;
|
||||||
|
if (logEl.scrollTop <= TOP_LOAD_THRESHOLD) loadOlder();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlder() {
|
||||||
|
if (loadingOlder || !haveMore || events.length === 0) return;
|
||||||
|
loadingOlder = true;
|
||||||
|
try {
|
||||||
|
const oldest = events[0].timestamp;
|
||||||
|
const data = await apiRequest(`/game/${state.game.id}/events?before=${oldest}&limit=${PAGE_SIZE}`);
|
||||||
|
haveMore = data.have_more;
|
||||||
|
const fresh = data.events.filter(e => !seenIds.has(e.id));
|
||||||
|
for (const e of fresh) seenIds.add(e.id);
|
||||||
|
if (fresh.length) {
|
||||||
|
// Prepending grows the content above the viewport; restore the
|
||||||
|
// player's position so the log doesn't visually jump.
|
||||||
|
const prevHeight = logEl.scrollHeight;
|
||||||
|
const prevTop = logEl.scrollTop;
|
||||||
|
events = [...fresh, ...events];
|
||||||
|
await tick();
|
||||||
|
logEl.scrollTop = prevTop + (logEl.scrollHeight - prevHeight);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Transient fetch failure; scrolling again retries.
|
||||||
|
} finally {
|
||||||
|
loadingOlder = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleOpen() {
|
||||||
|
open = !open;
|
||||||
|
if (open) {
|
||||||
|
await tick();
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both rollback (to a past event) and redo (to a greyed future event) are the same
|
||||||
|
// call — it just moves the head. It's reversible until someone takes a new action,
|
||||||
|
// so no confirm; the greyed future makes the state obvious.
|
||||||
|
async function rollback(event) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(
|
||||||
|
`/game/${state.game.id}/player/${state.player.id}/rollback`,
|
||||||
|
'POST',
|
||||||
|
{ checkpoint_id: event.checkpoint_id },
|
||||||
|
);
|
||||||
|
// The state-changed ping drives Dashboard to refetch the restored state.
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="floating-event-log {shown ? 'open' : ''} {inline ? 'inline' : ''}">
|
||||||
|
{#if toast && !shown}
|
||||||
|
{#key toast.id}
|
||||||
|
<button
|
||||||
|
class="event-toast log-kind-{toast.kind}"
|
||||||
|
title="Open the Event Log"
|
||||||
|
on:click={toggleOpen}
|
||||||
|
on:animationend={() => clearToast(toast.id)}
|
||||||
|
>
|
||||||
|
<span class="log-icon">{KIND_ICONS[toast.kind] || KIND_ICONS.info}</span>
|
||||||
|
<span class="toast-message">{toast.message}</span>
|
||||||
|
</button>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
{#if inline}
|
||||||
|
<div class="log-header">
|
||||||
|
📜 Event Log
|
||||||
|
<button class="log-hide-btn" title="Collapse the event log" on:click={() => dispatch('collapse')}>✕</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="toggle-log-btn" on:click={toggleOpen}>
|
||||||
|
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if shown}
|
||||||
|
{#if error}
|
||||||
|
<div class="log-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if head !== null}
|
||||||
|
<div class="log-rolledback">
|
||||||
|
↩ Rolled back — greyed entries are undone. Click one to redo, or take an action to make it permanent.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
|
||||||
|
{#if events.length}
|
||||||
|
<div class="log-top">
|
||||||
|
{#if loadingOlder}
|
||||||
|
<span class="log-top-note">Hauling up older entries…</span>
|
||||||
|
{:else if haveMore}
|
||||||
|
<button class="load-older-btn" on:click={loadOlder}>⬆️ Load earlier events</button>
|
||||||
|
{:else}
|
||||||
|
<span class="log-top-note">⚓ The story begins here</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#each events as event (event.id)}
|
||||||
|
{@const isFuture = head !== null && event.checkpoint_id > head}
|
||||||
|
<div class="log-entry log-kind-{event.kind || 'info'}" class:greyed={isFuture}>
|
||||||
|
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
|
||||||
|
<span class="log-message">{event.message}</span>
|
||||||
|
<span class="log-time">{formatTime(event.timestamp)}</span>
|
||||||
|
{#if canRollback && event.checkpoint_id > 0 && event.checkpoint_id !== currentPos}
|
||||||
|
<button
|
||||||
|
class="rollback-btn"
|
||||||
|
title={isFuture ? 'Redo forward to just after this event' : 'Roll the game back to just after this event'}
|
||||||
|
on:click={() => rollback(event)}
|
||||||
|
>{isFuture ? '↪' : '↩'}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="log-empty">No events yet.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !atBottom}
|
||||||
|
<button class="jump-to-bottom" on:click={scrollToBottom}>⬇️ Latest</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.floating-event-log {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 380px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 60vh;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.floating-event-log:not(.open) {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
/* Inline mode: pinned as the scene's third column instead of floating. The
|
||||||
|
top offset matches .dashboard-container's top padding (clears the fixed
|
||||||
|
corner menu) so the panel doesn't shift as the page scrolls; the height
|
||||||
|
fills to ~1rem above the viewport bottom, so the panel never runs past
|
||||||
|
the screen and its .log-content scrolls internally instead. */
|
||||||
|
.floating-event-log.inline {
|
||||||
|
position: sticky;
|
||||||
|
top: 3.5rem;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 4.5rem);
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.floating-event-log.inline {
|
||||||
|
position: static;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.log-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.log-hide-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 6px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.log-hide-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--text) 12%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.toggle-log-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
.toggle-log-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
|
}
|
||||||
|
.log-content {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.log-top {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 0 6px;
|
||||||
|
}
|
||||||
|
.log-top-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.load-older-btn {
|
||||||
|
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.load-older-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--text) 12%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.log-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: color-mix(in srgb, var(--text) 3%, transparent);
|
||||||
|
border-left: 3px solid var(--edge);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: color-mix(in srgb, var(--text) 85%, var(--text-muted));
|
||||||
|
}
|
||||||
|
.rollback-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.rollback-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.log-error {
|
||||||
|
margin: 6px 8px 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.log-rolledback {
|
||||||
|
margin: 6px 8px 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.log-entry.greyed {
|
||||||
|
opacity: 0.45;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.log-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.log-message {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.log-time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.log-empty {
|
||||||
|
padding: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.jump-to-bottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
.jump-to-bottom:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast preview of a fresh event, anchored above the button */
|
||||||
|
.event-toast {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
width: 320px;
|
||||||
|
max-width: 80vw;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
cursor: pointer;
|
||||||
|
transform-origin: bottom right;
|
||||||
|
animation: event-toast-into-button 3.4s ease-in forwards;
|
||||||
|
}
|
||||||
|
.event-toast .toast-message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
@keyframes event-toast-into-button {
|
||||||
|
0% { opacity: 0; transform: translateY(-6px) scale(0.96); }
|
||||||
|
8% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
75% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
100% { opacity: 0; transform: translateY(40px) scale(0.4); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.event-toast { animation-duration: 2.5s; animation-timing-function: linear; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent colors by event kind */
|
||||||
|
.log-kind-challenge { border-left-color: var(--danger); }
|
||||||
|
.log-kind-card { border-left-color: var(--deep); }
|
||||||
|
.log-kind-captain,
|
||||||
|
.log-kind-tax,
|
||||||
|
.log-kind-rank,
|
||||||
|
.log-kind-victory { border-left-color: var(--accent); }
|
||||||
|
.log-kind-scene,
|
||||||
|
.log-kind-phase { border-left-color: var(--pirat); }
|
||||||
|
.log-kind-joker,
|
||||||
|
.log-kind-warning { border-left-color: var(--warning); }
|
||||||
|
.log-kind-obstacle { border-left-color: var(--deep); }
|
||||||
|
.log-kind-join,
|
||||||
|
.log-kind-vote,
|
||||||
|
.log-kind-objective { border-left-color: var(--mystic); }
|
||||||
|
.log-kind-victory {
|
||||||
|
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
frontend/src/components/FaceCardAssigner.svelte
Normal file
79
frontend/src/components/FaceCardAssigner.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script>
|
||||||
|
// Drag-and-drop assignment of 3 techniques onto the J/Q/K face cards.
|
||||||
|
// Used during initial character creation and between-scenes recruit creation.
|
||||||
|
export let techniques = [];
|
||||||
|
export let jack = '';
|
||||||
|
export let queen = '';
|
||||||
|
export let king = '';
|
||||||
|
|
||||||
|
const slots = [
|
||||||
|
{ key: 'jack', letter: 'J', title: 'Jack' },
|
||||||
|
{ key: 'queen', letter: 'Q', title: 'Queen' },
|
||||||
|
{ key: 'king', letter: 'K', title: 'King' },
|
||||||
|
];
|
||||||
|
|
||||||
|
$: values = { jack, queen, king };
|
||||||
|
$: assigned = [jack, queen, king];
|
||||||
|
|
||||||
|
function set(key, val) {
|
||||||
|
if (key === 'jack') jack = val;
|
||||||
|
else if (key === 'queen') queen = val;
|
||||||
|
else king = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e, key) {
|
||||||
|
const tech = e.dataTransfer.getData('text/plain');
|
||||||
|
if (!tech) return;
|
||||||
|
for (const s of slots) {
|
||||||
|
if (s.key !== key && values[s.key] === tech) set(s.key, '');
|
||||||
|
}
|
||||||
|
set(key, tech);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="available-techniques-container">
|
||||||
|
<h4>Available Techniques</h4>
|
||||||
|
<p class="instruction-help">Drag a technique onto a card slot below to assign it.</p>
|
||||||
|
<div class="techniques-drag-pool">
|
||||||
|
{#each techniques as tech}
|
||||||
|
<div
|
||||||
|
draggable={!assigned.includes(tech)}
|
||||||
|
on:dragstart={(e) => e.dataTransfer.setData('text/plain', tech)}
|
||||||
|
class="draggable-tech-chip {assigned.includes(tech) ? 'assigned-hidden' : ''}"
|
||||||
|
>
|
||||||
|
<span class="drag-icon">⋮⋮</span> {tech}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="face-cards-slots-grid">
|
||||||
|
{#each slots as s}
|
||||||
|
<div class="face-card-slot-wrapper">
|
||||||
|
<div class="face-card-slot {values[s.key] ? 'has-assignment' : ''}"
|
||||||
|
on:dragover|preventDefault
|
||||||
|
on:drop={(e) => handleDrop(e, s.key)}>
|
||||||
|
<div class="card-bg-letter">{s.letter}</div>
|
||||||
|
<div class="card-slot-header">
|
||||||
|
<span class="rank">{s.letter}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-slot-body">
|
||||||
|
<div class="card-title">{s.title}</div>
|
||||||
|
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
||||||
|
<div class="assigned-tech-content">
|
||||||
|
{#if values[s.key]}
|
||||||
|
<div class="assigned-tech-chip">
|
||||||
|
{values[s.key]}
|
||||||
|
<span class="remove-tech-btn" on:click={() => set(s.key, '')}>×</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-slot-footer">
|
||||||
|
<span></span>
|
||||||
|
<span class="rank">{s.letter}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
31
frontend/src/components/GameOverPhase.svelte
Normal file
31
frontend/src/components/GameOverPhase.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script>
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="gameover-container">
|
||||||
|
<div class="card glass-panel gameover-card">
|
||||||
|
<h1>🎉 The Story Has Ended!</h1>
|
||||||
|
<p class="section-desc" style="font-size: 1.1rem;">
|
||||||
|
The Pi-Rats of {state.game.crew_name || 'the crew'} party til they pass out in a pile.
|
||||||
|
Tell one last tale of how your Pi-Rat is remembered!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="sheet-group">
|
||||||
|
<h3 class="text-accent">Crew Objectives</h3>
|
||||||
|
<div class="objectives-checklist">
|
||||||
|
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_1} disabled> <span>Steal a Ship</span></label>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_2} disabled> <span>Choose a Captain</span></label>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_3} disabled> <span>Commit Piracy</span></label>
|
||||||
|
</div>
|
||||||
|
{#if crewDone}
|
||||||
|
<p class="success-text">A complete pirate legend! ⭐</p>
|
||||||
|
{:else}
|
||||||
|
<p class="info-text">Some deeds were left undone... a tale for the next crew.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="info-text" style="margin-top: 2rem;">Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
88
frontend/src/components/GatModal.svelte
Normal file
88
frontend/src/components/GatModal.svelte
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let descInput = '';
|
||||||
|
let error = '';
|
||||||
|
let submitting = false;
|
||||||
|
|
||||||
|
async function submitDescription() {
|
||||||
|
if (!descInput.trim() || submitting) return;
|
||||||
|
error = '';
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-gat-description`, 'POST', {
|
||||||
|
description: descInput.trim()
|
||||||
|
});
|
||||||
|
descInput = '';
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.key === 'Enter') submitDescription();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if state.player.needs_gat_description}
|
||||||
|
<div class="modal-backdrop">
|
||||||
|
<div class="modal-box glass-panel">
|
||||||
|
<h3>🔫 You Got a Gat!</h3>
|
||||||
|
<p class="info-text">
|
||||||
|
Every Pi-Rat's Gat is one of a kind. What does yours look like?
|
||||||
|
</p>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={descInput}
|
||||||
|
class="input-field"
|
||||||
|
placeholder="e.g. A pearl-handled flintlock that smells of cheese"
|
||||||
|
on:keydown={onKeydown}
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-gold btn-full"
|
||||||
|
on:click={submitDescription}
|
||||||
|
disabled={!descInput.trim() || submitting}
|
||||||
|
>
|
||||||
|
Claim Gat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.75rem;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.modal-box h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.modal-box .input-field {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
124
frontend/src/components/LobbyPhase.svelte
Normal file
124
frontend/src/components/LobbyPhase.svelte
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let starting = false;
|
||||||
|
let copiedLink = null;
|
||||||
|
let copiedTimer = null;
|
||||||
|
|
||||||
|
function copyLink(which, value) {
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
copiedLink = which;
|
||||||
|
clearTimeout(copiedTimer);
|
||||||
|
copiedTimer = setTimeout(() => { copiedLink = null; }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGame() {
|
||||||
|
starting = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/lobby/start`, 'POST');
|
||||||
|
// The polling will pick up the phase change automatically
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to start game:", err);
|
||||||
|
starting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: teaching = state.game.teaching_deep_player_id === state.player.id;
|
||||||
|
$: teachingTakenByOther = state.game.teaching_deep_player_id && !teaching;
|
||||||
|
|
||||||
|
async function toggleTeaching() {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/teaching-mode`, 'POST', {
|
||||||
|
enabled: !teaching
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to toggle teaching mode:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="lobby-view text-center">
|
||||||
|
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
|
||||||
|
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
||||||
|
|
||||||
|
<div class="links-box glass-panel">
|
||||||
|
{#if state.player.is_admin}
|
||||||
|
<div class="link-item lobby-join-code-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-join-code"
|
||||||
|
class:copied={copiedLink === 'code'}
|
||||||
|
aria-label="Copy join code {state.game.join_code}"
|
||||||
|
on:click={() => copyLink('code', state.game.join_code)}
|
||||||
|
>
|
||||||
|
<span class="admin-join-code-label">Join Code</span>
|
||||||
|
<strong>{state.game.join_code}</strong>
|
||||||
|
<span class="admin-join-code-hint">{copiedLink === 'code' ? '✓ Copied!' : 'Click to copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="link-item">
|
||||||
|
<h4>Share Join Link:</h4>
|
||||||
|
<div class="copy-container">
|
||||||
|
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/join" class="copy-input" id="join-link-copy">
|
||||||
|
<button on:click={() => copyLink('join', document.getElementById('join-link-copy').value)} class="btn btn-secondary btn-small">
|
||||||
|
{copiedLink === 'join' ? '✓ Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if state.player.is_admin}
|
||||||
|
<div class="link-item admin-link-item">
|
||||||
|
<h4 class="gold-text">⚠️ Admin Magic Link:</h4>
|
||||||
|
<p class="info-text">Save this admin link! You can use it to recover character sheets if another player loses their connection or link.</p>
|
||||||
|
<div class="copy-container">
|
||||||
|
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/admin" class="copy-input admin-input" id="admin-link-copy">
|
||||||
|
<button on:click={() => copyLink('admin', document.getElementById('admin-link-copy').value)} class="btn btn-gold btn-small">
|
||||||
|
{copiedLink === 'admin' ? '✓ Copied' : 'Copy Admin Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/#/game/{state.game.id}/admin" class="text-sm text-muted underline">Access Admin Panel directly</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if state.player.is_admin}
|
||||||
|
<div class="teaching-box glass-panel">
|
||||||
|
<label class="checkbox-label" class:disabled={teachingTakenByOther}>
|
||||||
|
<input type="checkbox" checked={teaching} disabled={teachingTakenByOther} on:change={toggleTeaching}>
|
||||||
|
<span>📚 <strong>Teaching mode:</strong> I'll be the Deep for the first scene</span>
|
||||||
|
</label>
|
||||||
|
<p class="info-text">
|
||||||
|
{#if teachingTakenByOther}
|
||||||
|
Another admin has already volunteered to teach as the Deep.
|
||||||
|
{:else}
|
||||||
|
Seeds you as the Rank-3 player who must play the Deep in scene 1, so you can run the table for new crews. Otherwise it's assigned at random.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="lobby-action">
|
||||||
|
{#if state.player.is_admin}
|
||||||
|
{#if state.players.length >= 3 || state.game.dev_mode}
|
||||||
|
<button on:click={startGame}
|
||||||
|
disabled={starting}
|
||||||
|
class="btn btn-primary btn-large glow-effect">
|
||||||
|
{starting ? 'Starting...' : 'Start Character Creation'}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn btn-primary btn-large" disabled>Waiting for players... ({state.players.length}/3 minimum)</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="waiting-indicator">
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
<p>Waiting for Captain to cast the spell...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
88
frontend/src/components/NameModal.svelte
Normal file
88
frontend/src/components/NameModal.svelte
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let newNameInput = '';
|
||||||
|
let error = '';
|
||||||
|
let submitting = false;
|
||||||
|
|
||||||
|
async function submitNewName() {
|
||||||
|
if (!newNameInput.trim() || submitting) return;
|
||||||
|
error = '';
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
|
||||||
|
new_name: newNameInput.trim()
|
||||||
|
});
|
||||||
|
newNameInput = '';
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.key === 'Enter') submitNewName();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if state.player.needs_name}
|
||||||
|
<div class="modal-backdrop">
|
||||||
|
<div class="modal-box glass-panel">
|
||||||
|
<h3>🏴☠️ You Earned a Name!</h3>
|
||||||
|
<p class="info-text">
|
||||||
|
You completed your second objective and ranked up. No more going by your smell — what is your new Pirate Name?
|
||||||
|
</p>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newNameInput}
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Enter your Pirate Name..."
|
||||||
|
on:keydown={onKeydown}
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-gold btn-full"
|
||||||
|
on:click={submitNewName}
|
||||||
|
disabled={!newNameInput.trim() || submitting}
|
||||||
|
>
|
||||||
|
Claim Name
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.75rem;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.modal-box h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.modal-box .input-field {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
104
frontend/src/components/RankBonusModal.svelte
Normal file
104
frontend/src/components/RankBonusModal.svelte
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { displayName } from '../lib/cards';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let error = '';
|
||||||
|
let submitting = false;
|
||||||
|
|
||||||
|
$: me = state.player;
|
||||||
|
// Mirror the backend's is_eligible_nominee guard: another living, in-play Pi-Rat.
|
||||||
|
$: eligible = (state.players || []).filter(
|
||||||
|
(p) => p.id !== me.id && p.role !== 'deep' && !p.is_dead && !p.needs_reroll
|
||||||
|
);
|
||||||
|
|
||||||
|
async function grant(targetId) {
|
||||||
|
if (submitting) return;
|
||||||
|
error = '';
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${me.id}/bonus-rank-up`, 'POST', {
|
||||||
|
target_player_id: targetId || ''
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if me.needs_rank_3_bonus}
|
||||||
|
<div class="modal-backdrop">
|
||||||
|
<div class="modal-box glass-panel">
|
||||||
|
<h3>⭐ Your Story is Complete!</h3>
|
||||||
|
<p class="info-text">
|
||||||
|
You completed your final Personal Objective and earned your rest. Choose a
|
||||||
|
crewmate to carry on stronger — they gain <strong>1 Rank</strong>.
|
||||||
|
</p>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if eligible.length}
|
||||||
|
<div class="bonus-choices">
|
||||||
|
{#each eligible as p (p.id)}
|
||||||
|
<button
|
||||||
|
class="btn btn-gold btn-full"
|
||||||
|
on:click={() => grant(p.id)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{displayName(p)}
|
||||||
|
<span class="rank-hint">Rank {p.rank} → {p.rank + 1}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="info-text">No crewmate is eligible to receive the Rank right now.</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-full"
|
||||||
|
on:click={() => grant('')}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Forfeit the Rank Bonus
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.75rem;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.modal-box h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.bonus-choices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.rank-hint {
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
315
frontend/src/components/RecruitPhase.svelte
Normal file
315
frontend/src/components/RecruitPhase.svelte
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
|
||||||
|
import { displayName } from '../lib/cards';
|
||||||
|
import FaceCardAssigner from './FaceCardAssigner.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
function getPlayerName(id) {
|
||||||
|
if (!id) return 'None';
|
||||||
|
const p = state.players.find(x => x.id === id);
|
||||||
|
return p ? displayName(p) : 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function incomingTechs(p) {
|
||||||
|
return p.incoming_techniques ? JSON.parse(p.incoming_techniques) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev Mode (toggled by the creator from the corner menu) reveals the
|
||||||
|
// Suggest buttons; in real games players write their own material.
|
||||||
|
$: devMode = !!state.game.dev_mode;
|
||||||
|
|
||||||
|
$: me = state.player;
|
||||||
|
$: recruits = state.players.filter(p => p.needs_reroll);
|
||||||
|
$: iAmRecruit = me.needs_reroll;
|
||||||
|
|
||||||
|
// --- My recruit sheet (only if I'm a pending recruit) ---
|
||||||
|
let basicDetails = {
|
||||||
|
avatar_look: state.player.avatar_look || '',
|
||||||
|
avatar_smell: state.player.avatar_smell || '',
|
||||||
|
first_words: state.player.first_words || '',
|
||||||
|
good_at_math: state.player.good_at_math || ''
|
||||||
|
};
|
||||||
|
let savingBasic = false;
|
||||||
|
let editingBasic = false;
|
||||||
|
|
||||||
|
async function saveBasicDetails() {
|
||||||
|
savingBasic = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${me.id}/save-basic`, 'POST', basicDetails);
|
||||||
|
editingBasic = false;
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
savingBasic = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviseBasicDetails() {
|
||||||
|
basicDetails = {
|
||||||
|
avatar_look: me.avatar_look || '',
|
||||||
|
avatar_smell: me.avatar_smell || '',
|
||||||
|
first_words: me.first_words || '',
|
||||||
|
good_at_math: me.good_at_math || ''
|
||||||
|
};
|
||||||
|
editingBasic = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard any edits made since clicking Revise and return to the saved view.
|
||||||
|
function cancelReviseBasic() {
|
||||||
|
editingBasic = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: basicComplete = !!(me.avatar_look && me.avatar_smell && me.first_words && me.good_at_math);
|
||||||
|
$: likeDone = !me.other_like_from_player_id || !!me.other_like;
|
||||||
|
$: hateDone = !me.other_hate_from_player_id || !!me.other_hate;
|
||||||
|
$: myTechs = incomingTechs(me);
|
||||||
|
$: myTechTexts = myTechs.map(s => s.text);
|
||||||
|
$: techsDone = myTechTexts.length === 3 && myTechTexts.every(t => t);
|
||||||
|
|
||||||
|
// J/Q/K assignment + finalize
|
||||||
|
let jackTech = '', queenTech = '', kingTech = '';
|
||||||
|
let finalizing = false;
|
||||||
|
let finalizeError = '';
|
||||||
|
|
||||||
|
async function finalizeRecruit() {
|
||||||
|
finalizing = true;
|
||||||
|
finalizeError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${me.id}/finalize-recruit`, 'POST', {
|
||||||
|
jack: jackTech, queen: queenTech, king: kingTech
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
finalizeError = e.message;
|
||||||
|
} finally {
|
||||||
|
finalizing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helping other recruits ---
|
||||||
|
// Like/Hate answers (same endpoint as character creation)
|
||||||
|
let inboxAnswers = {};
|
||||||
|
async function submitDelegatedAnswer(type, targetId) {
|
||||||
|
const answer = (inboxAnswers[`${targetId}:${type}`] || '').trim();
|
||||||
|
if (!answer) return;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${me.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
|
||||||
|
delete inboxAnswers[`${targetId}:${type}`];
|
||||||
|
inboxAnswers = inboxAnswers;
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technique contributions: one draft per (recruit, slot)
|
||||||
|
let techDrafts = {};
|
||||||
|
let techErrors = {};
|
||||||
|
async function contributeTechnique(recruitId, slot) {
|
||||||
|
const text = (techDrafts[`${recruitId}:${slot}`] || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${me.id}/contribute-technique`, 'POST', {
|
||||||
|
recruit_id: recruitId, slot, text
|
||||||
|
});
|
||||||
|
delete techDrafts[`${recruitId}:${slot}`];
|
||||||
|
techDrafts = techDrafts;
|
||||||
|
delete techErrors[`${recruitId}:${slot}`];
|
||||||
|
techErrors = techErrors;
|
||||||
|
} catch(e) {
|
||||||
|
techErrors[`${recruitId}:${slot}`] = e.message;
|
||||||
|
techErrors = techErrors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suggestTechnique(recruitId, slot) {
|
||||||
|
techDrafts[`${recruitId}:${slot}`] = await getTechniqueSuggestion(Object.values(techDrafts));
|
||||||
|
techDrafts = techDrafts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// My outstanding tasks for other recruits
|
||||||
|
$: myTasks = recruits.filter(p => p.id !== me.id).map(p => ({
|
||||||
|
recruit: p,
|
||||||
|
like: p.other_like_from_player_id === me.id,
|
||||||
|
hate: p.other_hate_from_player_id === me.id,
|
||||||
|
techSlots: incomingTechs(p).map((s, i) => ({ ...s, slot: i })).filter(s => s.from_id === me.id)
|
||||||
|
})).filter(t => t.like || t.hate || t.techSlots.length > 0);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="recruit-phase-view">
|
||||||
|
<div class="view-header text-center">
|
||||||
|
<h2>🐀 New Recruits</h2>
|
||||||
|
<p>Fresh rats are washing aboard! Recruits fill in their Rat Records while the crew answers their Like/Hate questions and writes their Secret Techniques.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="creation-grid max-w-5xl mx-auto">
|
||||||
|
{#if iAmRecruit}
|
||||||
|
<!-- MY NEW RECRUIT SHEET -->
|
||||||
|
<div class="card glass-panel section-basic">
|
||||||
|
<h3>I. Your New Rat Records {basicComplete ? '✅' : '❌'}</h3>
|
||||||
|
{#if basicComplete && !editingBasic}
|
||||||
|
<div class="read-only-sheet">
|
||||||
|
<div class="record-line"><strong>Look:</strong> {me.avatar_look}</div>
|
||||||
|
<div class="record-line"><strong>Smell:</strong> {me.avatar_smell}</div>
|
||||||
|
<div class="record-line"><strong>First Words:</strong> "{me.first_words}"</div>
|
||||||
|
<div class="record-line"><strong>Good at Math?</strong> {me.good_at_math}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
|
||||||
|
{:else}
|
||||||
|
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="avatar_look">What does your Pi-Rat look like?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="avatar_smell">What does your Pi-Rat smell like? (Determines name)</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="first_words">What were your first words after transforming?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="good_at_math" placeholder="e.g. Thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-row mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
||||||
|
{#if basicComplete}
|
||||||
|
<button type="button" class="btn btn-secondary" disabled={savingBasic} on:click={cancelReviseBasic}>Cancel</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card glass-panel section-delegations">
|
||||||
|
<h3>II. Crew Delegations {likeDone && hateDone ? '✅' : '❌'}</h3>
|
||||||
|
<p class="section-desc">Crewmates decide what their rats like and hate about your new recruit.</p>
|
||||||
|
<div class="delegation-list">
|
||||||
|
<div class="delegation-box glass-panel">
|
||||||
|
<h4>What does another rat LIKE about you?</h4>
|
||||||
|
<div class="answer-box">
|
||||||
|
{me.other_like ? `"${me.other_like}"` : '(Waiting for answer...)'}
|
||||||
|
</div>
|
||||||
|
<div class="author-label">— {getPlayerName(me.other_like_from_player_id)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="delegation-box glass-panel">
|
||||||
|
<h4>What does another rat HATE about you?</h4>
|
||||||
|
<div class="answer-box">
|
||||||
|
{me.other_hate ? `"${me.other_hate}"` : '(Waiting for answer...)'}
|
||||||
|
</div>
|
||||||
|
<div class="author-label">— {getPlayerName(me.other_hate_from_player_id)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card glass-panel section-techniques" style="grid-column: span 2;">
|
||||||
|
<h3>III. Your New Secret Techniques {techsDone ? '✅' : '❌'}</h3>
|
||||||
|
{#if !techsDone}
|
||||||
|
<p class="section-desc">Your crewmates are writing 3 fresh techniques for you:</p>
|
||||||
|
<div class="submitted-techniques text-center mt-4">
|
||||||
|
{#each myTechs as slot, i}
|
||||||
|
<div class="tech-chip">#{i + 1}: {slot.text ? slot.text : `Waiting for ${getPlayerName(slot.from_id)}...`}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
|
<p>Drag and drop your new techniques onto your Face Cards:</p>
|
||||||
|
{#if finalizeError}
|
||||||
|
<div class="alert alert-danger">{finalizeError}</div>
|
||||||
|
{/if}
|
||||||
|
<FaceCardAssigner techniques={myTechTexts} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} />
|
||||||
|
<div class="mt-6">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-large w-full glow-effect"
|
||||||
|
disabled={finalizing || !basicComplete || !likeDone || !hateDone || !jackTech || !queenTech || !kingTech}
|
||||||
|
on:click={finalizeRecruit}
|
||||||
|
>
|
||||||
|
{finalizing ? 'Joining the Crew...' : '🐀 Join the Crew!'}
|
||||||
|
</button>
|
||||||
|
{#if !basicComplete || !likeDone || !hateDone}
|
||||||
|
<p class="info-text text-center" style="margin-top: 0.5rem;">Finish your Rat Records and wait for your Like/Hate answers before joining.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- TASKS FOR OTHER RECRUITS -->
|
||||||
|
<div class="card glass-panel section-inbox" style="grid-column: span 2;">
|
||||||
|
<h3>{iAmRecruit ? 'IV.' : 'I.'} Your Inbox (Help the Recruits)</h3>
|
||||||
|
<p class="section-desc">Answer questions and write Secret Techniques for the fresh recruits. Be creative and have fun with it!</p>
|
||||||
|
<div class="inbox-list">
|
||||||
|
{#each myTasks as task (task.recruit.id)}
|
||||||
|
{#if task.like}
|
||||||
|
<div class="inbox-item">
|
||||||
|
<label>
|
||||||
|
What do you LIKE about {displayName(task.recruit)}?
|
||||||
|
{#if task.recruit.other_like}<span class="text-success">(submitted: "{task.recruit.other_like}" — you can revise it)</span>{/if}
|
||||||
|
</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:like`]} placeholder="e.g. They always share their cheese"/>
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:like`] = getSuggestion('like')}>Suggest</button>{/if}
|
||||||
|
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', task.recruit.id)}>Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if task.hate}
|
||||||
|
<div class="inbox-item">
|
||||||
|
<label>
|
||||||
|
What do you HATE about {displayName(task.recruit)}?
|
||||||
|
{#if task.recruit.other_hate}<span class="text-success">(submitted: "{task.recruit.other_hate}" — you can revise it)</span>{/if}
|
||||||
|
</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:hate`]} placeholder="e.g. They snore too loudly"/>
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if}
|
||||||
|
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', task.recruit.id)}>Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each task.techSlots as slot}
|
||||||
|
<div class="inbox-item">
|
||||||
|
<label>
|
||||||
|
Write a Secret Technique for {displayName(task.recruit)}
|
||||||
|
{#if slot.text}<span class="text-success">(submitted: "{slot.text}" — you can revise it)</span>{/if}
|
||||||
|
</label>
|
||||||
|
{#if techErrors[`${task.recruit.id}:${slot.slot}`]}
|
||||||
|
<div class="alert alert-danger">{techErrors[`${task.recruit.id}:${slot.slot}`]}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" class="input-field" bind:value={techDrafts[`${task.recruit.id}:${slot.slot}`]} placeholder="e.g. Parabolic boarding bounce"/>
|
||||||
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggestTechnique(task.recruit.id, slot.slot)}>Suggest</button>{/if}
|
||||||
|
<button class="btn btn-primary" disabled={!(techDrafts[`${task.recruit.id}:${slot.slot}`] || '').trim()} on:click={() => contributeTechnique(task.recruit.id, slot.slot)}>Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
{#if myTasks.length === 0}
|
||||||
|
<p class="inbox-empty-message text-muted italic">
|
||||||
|
{#if iAmRecruit}
|
||||||
|
No tasks — your crewmates are hard at work on your new rat.
|
||||||
|
{:else}
|
||||||
|
Nothing left to write. Waiting for the recruits to finish their sheets...
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
79
frontend/src/components/ScenePhase.svelte
Normal file
79
frontend/src/components/ScenePhase.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script>
|
||||||
|
import Card from './Card.svelte';
|
||||||
|
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||||
|
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||||
|
import CrewColumn from './scene/CrewColumn.svelte';
|
||||||
|
import EventLog from './EventLog.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
// The inline Event Log can be toggled from a fixed corner button to declutter.
|
||||||
|
let logOpen = true;
|
||||||
|
|
||||||
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
|
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
// The Challenge area is shown when something is happening there, or to the Deep
|
||||||
|
// (who calls Challenges and ends the scene from it).
|
||||||
|
$: showChallengeArea = isDeep || openChallenges.length > 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="scene-view-layout" class:log-collapsed={!logOpen} id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||||
|
<!-- LEFT COLUMN: The Crew -->
|
||||||
|
<CrewColumn {state} />
|
||||||
|
|
||||||
|
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
|
||||||
|
<div class="table-column">
|
||||||
|
{#if !isDeep}
|
||||||
|
<div class="card glass-panel hand-card">
|
||||||
|
<span class="hand-label">🃏 Your Hand</span>
|
||||||
|
|
||||||
|
<div class="hand-flex">
|
||||||
|
{#each hand as card}
|
||||||
|
<Card {card} techs={playerTechs}
|
||||||
|
draggable={true}
|
||||||
|
on:dragstart={(e) => {
|
||||||
|
e.dataTransfer.setData('text/plain', card);
|
||||||
|
e.currentTarget.classList.add("dragging");
|
||||||
|
}}
|
||||||
|
on:dragend={(e) => {
|
||||||
|
e.currentTarget.classList.remove("dragging");
|
||||||
|
}} />
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showChallengeArea}
|
||||||
|
<div class="card glass-panel challenge-area-card">
|
||||||
|
<ChallengePanel {state} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card glass-panel obstacle-list-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌊 The Obstacle List</h3>
|
||||||
|
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
|
||||||
|
</div>
|
||||||
|
<ObstacleBoard {state} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: The Event Log (collapsible to a corner button) -->
|
||||||
|
{#if logOpen}
|
||||||
|
<div class="log-column">
|
||||||
|
<EventLog {state} inline on:collapse={() => (logOpen = false)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="log-reopen-btn"
|
||||||
|
title={logOpen ? 'Hide the event log' : 'Show the event log'}
|
||||||
|
on:click={() => (logOpen = !logOpen)}
|
||||||
|
>
|
||||||
|
{logOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
134
frontend/src/components/SceneSetupPhase.svelte
Normal file
134
frontend/src/components/SceneSetupPhase.svelte
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let settingRole = false;
|
||||||
|
let starting = false;
|
||||||
|
let error = '';
|
||||||
|
let allowedRoles = ['pirat', 'deep'];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiRequest(`/game/${state.game.id}/player/${state.player.id}/allowed-roles`, 'GET');
|
||||||
|
allowedRoles = res.allowed_roles;
|
||||||
|
} catch(e) {
|
||||||
|
console.error("Failed to load allowed roles", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setRole(role) {
|
||||||
|
settingRole = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-role`, 'POST', { role });
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
settingRole = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScene() {
|
||||||
|
starting = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/scene/start`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
error = e.message;
|
||||||
|
starting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: allRolesAssigned = state.players.every(p => p.role !== null || p.needs_reroll);
|
||||||
|
$: deepPlayer = state.players.find(p => p.role === 'deep');
|
||||||
|
$: piratPlayer = state.players.find(p => p.role === 'pirat');
|
||||||
|
$: enoughPlayers = state.players.length >= 3 || state.game.dev_mode;
|
||||||
|
$: canStart = enoughPlayers && allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_admin || state.player.role === 'deep');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="scene-setup-view text-center">
|
||||||
|
<h2>Scene Setup (Scene #{state.game.current_scene_number})</h2>
|
||||||
|
<p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p>
|
||||||
|
|
||||||
|
{#if state.game.current_scene_number === 1}
|
||||||
|
<div class="notice-banner accent">
|
||||||
|
<h4>🚢 First Scene Framing</h4>
|
||||||
|
<p class="info-text" style="margin: 0; font-size: 0.95rem;">
|
||||||
|
The first scene must establish that the Pi-Rats are <strong>on a ship they can steal</strong> (or have an immediate opportunity to steal one). <strong>Don't start far from water!</strong>
|
||||||
|
{#if state.player.role === 'deep'}
|
||||||
|
<br/><span class="text-accent">You're playing the Deep — once the scene starts, describe where the crew is and what ship is ripe for the taking before calling any Challenges.</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="setup-grid single">
|
||||||
|
<!-- Role Selection Card -->
|
||||||
|
<div class="card glass-panel role-selection-card">
|
||||||
|
<h3>Select Your Role</h3>
|
||||||
|
|
||||||
|
{#if state.player.needs_reroll}
|
||||||
|
<div class="notice-banner accent">
|
||||||
|
<p class="info-text" style="margin: 0;">🐀 You don't have a Pi-Rat yet! You'll spectate this scene and create your recruit with the crew between scenes.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="role-buttons">
|
||||||
|
<button on:click={() => setRole('pirat')}
|
||||||
|
disabled={settingRole || !allowedRoles.includes('pirat')}
|
||||||
|
class="btn btn-large role-btn pirat-btn {state.player.role === 'pirat' ? 'active' : ''}">
|
||||||
|
🐀 Play Pi-Rat
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button on:click={() => setRole('deep')}
|
||||||
|
disabled={settingRole || !allowedRoles.includes('deep')}
|
||||||
|
class="btn btn-large role-btn deep-btn {state.player.role === 'deep' ? 'active' : ''}">
|
||||||
|
🌊 Play the Deep
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="role-restrictions mt-4">
|
||||||
|
{#if !allowedRoles.includes('pirat') && !state.player.needs_reroll}
|
||||||
|
{#if state.game.current_scene_number === 1}
|
||||||
|
<p class="text-danger text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-danger text-sm italic">You are the only eligible player for The Deep this scene!</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if !allowedRoles.includes('deep') && !state.player.needs_reroll}
|
||||||
|
{#if state.game.current_scene_number === 1}
|
||||||
|
<p class="text-danger text-sm italic">You start at Rank 1, so you must play your Pi-Rat for the first scene!</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-danger text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-box margin-top">
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canStart}
|
||||||
|
<button on:click={startScene}
|
||||||
|
disabled={starting}
|
||||||
|
class="btn btn-primary btn-large glow-effect">
|
||||||
|
{starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'}
|
||||||
|
</button>
|
||||||
|
{:else if !enoughPlayers}
|
||||||
|
<p class="text-danger italic mt-4">You need at least 3 players to start a scene — share the join link!</p>
|
||||||
|
{:else if !allRolesAssigned}
|
||||||
|
<p class="text-muted italic mt-4">Waiting for all players to choose a role...</p>
|
||||||
|
{:else if !deepPlayer}
|
||||||
|
<p class="text-danger italic mt-4">Someone must play as The Deep!</p>
|
||||||
|
{:else if !piratPlayer}
|
||||||
|
<p class="text-danger italic mt-4">Someone must play as a Pi-Rat!</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted italic mt-4">Waiting for The Deep or Creator to start the scene...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
360
frontend/src/components/UpkeepPhase.svelte
Normal file
360
frontend/src/components/UpkeepPhase.svelte
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { displayName } from '../lib/cards';
|
||||||
|
import Card from './Card.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let voting = false;
|
||||||
|
let readying = false;
|
||||||
|
let confirming = false;
|
||||||
|
|
||||||
|
let selectedVoteId = '';
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
|
// Winner of the rank-up vote, surfaced on the post-voting (deep_upkeep) screen.
|
||||||
|
$: rankUpWinner = state.game.last_rank_up_player_id
|
||||||
|
? state.players.find(p => p.id === state.game.last_rank_up_player_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// A nominee must be another living, in-play Pi-Rat: not the voter, not a
|
||||||
|
// previous-scene Deep, and not dead / awaiting-recruit (they'd reset to
|
||||||
|
// Rank 1 anyway). Mirrors crud_upkeep.is_eligible_nominee.
|
||||||
|
function eligibleNomineesFor(voter) {
|
||||||
|
return state.players.filter(p =>
|
||||||
|
p.id !== voter.id && p.role !== 'deep' && !p.is_dead && !p.needs_reroll
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$: myNominees = eligibleNomineesFor(state.player);
|
||||||
|
$: hasVoted = !!state.votes.find(v => v.voter_player_id === state.player.id);
|
||||||
|
// With no eligible crewmates, the voter skips voting entirely (no deadlock).
|
||||||
|
$: mustVote = myNominees.length > 0;
|
||||||
|
|
||||||
|
// Drag & Drop State
|
||||||
|
let keepCards = [];
|
||||||
|
let discardCards = [];
|
||||||
|
let dragNDropInitialized = false;
|
||||||
|
|
||||||
|
// Ghost & Re-rolling fate choice
|
||||||
|
async function becomeGhost() {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseReRoll() {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/choose-reroll`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishGame() {
|
||||||
|
if (!confirm('End the story for everyone? Best saved for when every player has played the Deep, each Pi-Rat has completed a Personal Objective, and all 3 Crew Objectives are done.')) return;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/finish`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (state && state.game.phase === 'deep_upkeep') {
|
||||||
|
if (!dragNDropInitialized) {
|
||||||
|
keepCards = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
|
discardCards = [];
|
||||||
|
dragNDropInitialized = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dragNDropInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let draggedCardCode = null;
|
||||||
|
|
||||||
|
function handleDragStart(e, card) {
|
||||||
|
draggedCardCode = card;
|
||||||
|
e.dataTransfer.setData("text/plain", card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDropToKeep(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const card = e.dataTransfer.getData("text/plain") || draggedCardCode;
|
||||||
|
if (card && !keepCards.includes(card)) {
|
||||||
|
discardCards = discardCards.filter(c => c !== card);
|
||||||
|
keepCards = [...keepCards, card];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDropToDiscard(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const card = e.dataTransfer.getData("text/plain") || draggedCardCode;
|
||||||
|
if (card && !discardCards.includes(card)) {
|
||||||
|
keepCards = keepCards.filter(c => c !== card);
|
||||||
|
discardCards = [...discardCards, card];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToDiscard(card) {
|
||||||
|
keepCards = keepCards.filter(c => c !== card);
|
||||||
|
if (!discardCards.includes(card)) {
|
||||||
|
discardCards = [...discardCards, card];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToKeep(card) {
|
||||||
|
discardCards = discardCards.filter(c => c !== card);
|
||||||
|
if (!keepCards.includes(card)) {
|
||||||
|
keepCards = [...keepCards, card];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the backend hand size table: highest rank -> 4, lowest -> 2, middle -> 3, Captain +1.
|
||||||
|
// Deep players are ranked against all players.
|
||||||
|
$: maxHandSize = (() => {
|
||||||
|
const ranks = state.players.map(p => p.rank);
|
||||||
|
let base;
|
||||||
|
if (state.player.rank >= Math.max(...ranks)) base = 4;
|
||||||
|
else if (state.player.rank <= Math.min(...ranks)) base = 2;
|
||||||
|
else base = 3;
|
||||||
|
return base + (state.game.captain_player_id === state.player.id ? 1 : 0);
|
||||||
|
})();
|
||||||
|
$: isOverLimit = keepCards.length > maxHandSize;
|
||||||
|
|
||||||
|
async function submitVote() {
|
||||||
|
if (!selectedVoteId) return;
|
||||||
|
voting = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-vote`, 'POST', {
|
||||||
|
nominated_id: selectedVoteId
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
voting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setReady() {
|
||||||
|
readying = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/ready-next`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
readying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRefresh() {
|
||||||
|
confirming = true;
|
||||||
|
try {
|
||||||
|
let discards = JSON.stringify(discardCards);
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/confirm-refresh`, 'POST', {
|
||||||
|
discard_cards: discards
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
confirming = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="between-scenes-view text-center">
|
||||||
|
<h2>Between Scenes (Scene #{state.game.current_scene_number} Concluded)</h2>
|
||||||
|
<p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p>
|
||||||
|
|
||||||
|
{#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll}
|
||||||
|
<!-- Death Fate Panel -->
|
||||||
|
<div class="glass-panel death-fate-card">
|
||||||
|
<h2>💀 Your Pi-Rat Has Died!</h2>
|
||||||
|
<p class="description">
|
||||||
|
Your story arc is complete! You went out in a blaze of glory (or retired honorably). You must now choose how your journey continues:
|
||||||
|
</p>
|
||||||
|
<div class="fate-choices">
|
||||||
|
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}>
|
||||||
|
👻 Remain as a Ghost
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-large glow-effect" on:click={chooseReRoll}>
|
||||||
|
🐀 Roll a New Character
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="info-text italic" style="margin-top: 1.5rem;">
|
||||||
|
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh at Rank 1 — after upkeep, the whole crew will help create them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if state.player.needs_reroll}
|
||||||
|
<div class="notice-banner" style="margin-bottom: 1rem;">
|
||||||
|
<p style="margin: 0;">🐀 A fresh recruit is on the way! Once upkeep wraps up, you and the crew will create your new Pi-Rat together.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="between-grid" class:single={state.game.phase !== 'deep_upkeep'}>
|
||||||
|
<!-- Ranks and Voting Card -->
|
||||||
|
<div class="card glass-panel voting-card">
|
||||||
|
<h3>1. Pirate Ranking (Voting)</h3>
|
||||||
|
<p class="section-desc">Every player (including the Deep) nominates one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
|
||||||
|
|
||||||
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
|
<div class="voted-confirmation-box glass-panel">
|
||||||
|
{#if rankUpWinner}
|
||||||
|
<p class="success-text">🏆 {displayName(rankUpWinner)} won the vote and ranked up to Rank {rankUpWinner.rank}!</p>
|
||||||
|
{:else}
|
||||||
|
<p class="success-text">✔️ Voting complete! No one ranked up this time.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if hasVoted}
|
||||||
|
<div class="voted-confirmation-box glass-panel">
|
||||||
|
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
||||||
|
</div>
|
||||||
|
{:else if !mustVote}
|
||||||
|
<div class="voted-confirmation-box glass-panel">
|
||||||
|
<p class="info-text">No living crewmates left to nominate — you skip voting this round. Go ahead and ready up.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
|
||||||
|
<div class="form-group inline-group">
|
||||||
|
<select class="select-field" bind:value={selectedVoteId} required>
|
||||||
|
<option value="">Nominate crewmate...</option>
|
||||||
|
{#each myNominees as p}
|
||||||
|
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resting Deep Player Card — only the actual hand refresh, during deep_upkeep -->
|
||||||
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
|
<div class="card glass-panel deep-rest-card">
|
||||||
|
<h3>2. Resting Deep Upkeep</h3>
|
||||||
|
|
||||||
|
{#if state.player.role === "deep"}
|
||||||
|
<div class="deep-rest-panel glass-panel">
|
||||||
|
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p>
|
||||||
|
|
||||||
|
{#if state.player.is_ready}
|
||||||
|
<!-- Confirmation: the refresh went through; show the freshly-drawn hand. -->
|
||||||
|
<div class="voted-confirmation-box glass-panel" style="margin-top: 1rem;">
|
||||||
|
<p class="success-text">✔️ Hand refreshed! You're holding {hand.length} card{hand.length === 1 ? '' : 's'} for the next scene.</p>
|
||||||
|
</div>
|
||||||
|
<div class="upkeep-flex" style="justify-content: center; margin-top: 1rem;">
|
||||||
|
{#each hand as card}
|
||||||
|
<Card {card} />
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text text-center w-full">No cards in hand.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="info-text text-center" style="margin-top: 1rem;">Waiting for the rest of the crew to finish upkeep...</p>
|
||||||
|
{:else}
|
||||||
|
<p class="info-text" style="margin-top: 1rem;">
|
||||||
|
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
|
||||||
|
Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="upkeep-drag-container">
|
||||||
|
<!-- KEEP HAND ZONE -->
|
||||||
|
<div class="upkeep-box"
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
on:drop={handleDropToKeep}>
|
||||||
|
<h4 class="gold-text">Hand (Keep)</h4>
|
||||||
|
<div class="upkeep-flex">
|
||||||
|
{#each keepCards as card}
|
||||||
|
<Card {card} draggable={true} style="cursor: pointer;"
|
||||||
|
title="Click or drag to discard"
|
||||||
|
on:dragstart={(e) => handleDragStart(e, card)}
|
||||||
|
on:click={() => moveToDiscard(card)} />
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text text-center w-full">No cards in hand.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DISCARD PILE ZONE -->
|
||||||
|
<div class="upkeep-box"
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
on:drop={handleDropToDiscard}>
|
||||||
|
<h4 class="gold-text">Discard Pile</h4>
|
||||||
|
<div class="upkeep-flex">
|
||||||
|
{#each discardCards as card}
|
||||||
|
<Card {card} draggable={true} style="cursor: pointer;"
|
||||||
|
title="Click or drag to keep"
|
||||||
|
on:dragstart={(e) => handleDragStart(e, card)}
|
||||||
|
on:click={() => moveToKeep(card)} />
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text text-center w-full">Drag cards here to discard them.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isOverLimit}
|
||||||
|
<div class="notice-banner danger" style="margin: 1rem 0; max-width: none;">
|
||||||
|
<p class="text-danger font-bold" style="margin: 0;">⚠️ Discard Required: You have selected {keepCards.length} cards to keep, which exceeds your maximum hand limit of {maxHandSize}. Please discard at least {keepCards.length - maxHandSize} card(s).</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="btn btn-primary mt-4 w-full" on:click={confirmRefresh} disabled={confirming || isOverLimit}>
|
||||||
|
Confirm Hand Refresh
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next Scene Ready Up -->
|
||||||
|
<div class="action-box margin-top">
|
||||||
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
|
{#if state.player.role === 'deep' && !state.player.is_ready}
|
||||||
|
<p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="waiting-box">
|
||||||
|
<p>Waiting for resting Deep player to refresh their hand...</p>
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#if mustVote && !hasVoted}
|
||||||
|
<p class="info-text gold-text" style="margin-bottom:0.5rem;">⚠️ You must nominate a crewmate above before you can ready up.</p>
|
||||||
|
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
|
||||||
|
{:else}
|
||||||
|
{#if state.player.is_ready}
|
||||||
|
<div class="waiting-box">
|
||||||
|
<p>Ready! Waiting for other players to ready up...</p>
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button on:click={setReady}
|
||||||
|
disabled={readying}
|
||||||
|
class="btn btn-primary btn-large glow-effect">
|
||||||
|
Ready for Next Scene
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if state.player.is_admin && state.game.phase === 'between_scenes'}
|
||||||
|
<div class="end-story-box">
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">All good stories end. When the crew agrees the legend is complete:</p>
|
||||||
|
<button on:click={finishGame} class="btn btn-danger">🏴☠️ End the Story</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
225
frontend/src/components/scene/ChallengePanel.svelte
Normal file
225
frontend/src/components/scene/ChallengePanel.svelte
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltipHtml, obstacleTable } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
|
import ObstacleItem from './ObstacleItem.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let error = '';
|
||||||
|
let taxTargetId = '';
|
||||||
|
// Deep: call-a-challenge form (moved here from the old Deep Control Panel)
|
||||||
|
let showCreate = false;
|
||||||
|
let challengeTargetId = '';
|
||||||
|
let stakesSuccess = '';
|
||||||
|
let stakesFailure = '';
|
||||||
|
let selectedObstacles = {};
|
||||||
|
|
||||||
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||||
|
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
|
||||||
|
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
|
||||||
|
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
|
||||||
|
function playerName(id) {
|
||||||
|
return lookupName(state.players, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function taxType(player) {
|
||||||
|
if (!player.completed_personal_1) return 'Gat';
|
||||||
|
if (!player.completed_personal_2) return 'Name';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eligibleTaxTargets() {
|
||||||
|
const type = taxType(state.player);
|
||||||
|
if (!type) return [];
|
||||||
|
return scenePirats.filter(p =>
|
||||||
|
p.id !== state.player.id && !p.is_dead &&
|
||||||
|
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, data = null) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/${path}`, 'POST', data);
|
||||||
|
return true;
|
||||||
|
} catch(e) {
|
||||||
|
error = e.message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveChallenge = (id) => post(`challenge/${id}/resolve`);
|
||||||
|
const cancelChallenge = (id) => post(`challenge/${id}/cancel`);
|
||||||
|
const respondTax = (id, accept) => post(`challenge/${id}/tax/respond`, { accept: accept ? 'true' : 'false' });
|
||||||
|
const pvpDefend = (id, cardCode) => post(`challenge/${id}/pvp/defend`, { card_code: cardCode });
|
||||||
|
|
||||||
|
async function requestTax(challengeId) {
|
||||||
|
if (await post(`challenge/${challengeId}/tax/request`, { target_player_id: taxTargetId })) {
|
||||||
|
taxTargetId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createChallenge() {
|
||||||
|
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
|
||||||
|
if (await post('challenge/create', {
|
||||||
|
target_player_id: challengeTargetId,
|
||||||
|
obstacle_ids: JSON.stringify(ids),
|
||||||
|
stakes_success: stakesSuccess,
|
||||||
|
stakes_failure: stakesFailure,
|
||||||
|
})) {
|
||||||
|
challengeTargetId = '';
|
||||||
|
stakesSuccess = '';
|
||||||
|
stakesFailure = '';
|
||||||
|
selectedObstacles = {};
|
||||||
|
showCreate = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endScene() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each openChallenges as ch}
|
||||||
|
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
|
||||||
|
<div class="challenge-item">
|
||||||
|
<div class="challenge-head">
|
||||||
|
<h4>
|
||||||
|
{#if ch.challenge_type === 'pvp'}
|
||||||
|
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
|
||||||
|
{:else}
|
||||||
|
⚔️ The Deep challenges {playerName(ch.target_player_id)}!
|
||||||
|
{/if}
|
||||||
|
</h4>
|
||||||
|
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
|
||||||
|
<div class="challenge-actions">
|
||||||
|
<button class="btn btn-primary" on:click={() => resolveChallenge(ch.id)} disabled={ch.tax_state === 'requested'}>Resolve</button>
|
||||||
|
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if ch.stakes_success}
|
||||||
|
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>✅ On success:</strong> {ch.stakes_success}</p>
|
||||||
|
{/if}
|
||||||
|
{#if ch.stakes_failure}
|
||||||
|
<p class="info-text" style="margin: 0.25rem 0 0 0;"><strong>❌ On failure:</strong> {ch.stakes_failure}</p>
|
||||||
|
{/if}
|
||||||
|
{#if ch.stakes && !ch.stakes_success && !ch.stakes_failure}
|
||||||
|
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
|
||||||
|
{/if}
|
||||||
|
{#if ch.challenge_type === 'pvp'}
|
||||||
|
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||||
|
Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable, true) }}>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="challenge-obstacles">
|
||||||
|
{#each chObstacleIds as oid}
|
||||||
|
{@const obs = state.obstacles.find(o => o.id === oid)}
|
||||||
|
{#if obs}
|
||||||
|
<ObstacleItem {state} {obs} embedded />
|
||||||
|
{:else}
|
||||||
|
<p class="info-text" style="margin: 0;">An applied Obstacle was discarded.</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Tax: pending request aimed at me -->
|
||||||
|
{#if ch.tax_state === 'requested'}
|
||||||
|
{#if myTaxRequest && myTaxRequest.id === ch.id}
|
||||||
|
<div class="tax-callout">
|
||||||
|
<p><strong>{playerName(ch.target_player_id)}</strong> calls a <strong>{ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax</strong> on you: take this Challenge for them!</p>
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">Accept: you get one random card from their hand and attempt the Challenge. Refuse: you hand over your {ch.tax_type === 'gat' ? 'Gat' : 'Name'} — they keep it if they succeed!</p>
|
||||||
|
<div class="challenge-actions">
|
||||||
|
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
|
||||||
|
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="info-text gold-text" style="margin-top: 0.5rem;">⏳ Waiting for {playerName(ch.tax_target_id)} to answer the {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax...</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tax: option for the challenged player -->
|
||||||
|
{#if ch.challenge_type === 'deep' && ch.acting_player_id === state.player.id && !ch.tax_state && !state.player.tax_banned && taxType(state.player) && eligibleTaxTargets().length > 0}
|
||||||
|
<div class="challenge-actions" style="margin-top: 0.75rem;">
|
||||||
|
<span class="info-text" style="font-size: 0.9rem; margin: 0;">No {taxType(state.player)}? Make it someone else's problem:</span>
|
||||||
|
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
|
||||||
|
<option value="">Pick a crewmate...</option>
|
||||||
|
{#each eligibleTaxTargets() as p}
|
||||||
|
<option value={p.id}>{displayName(p)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- PvP: defender picks a card -->
|
||||||
|
{#if myPvpDefense && myPvpDefense.id === ch.id}
|
||||||
|
<div style="margin-top: 0.75rem;">
|
||||||
|
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
||||||
|
<div class="challenge-actions">
|
||||||
|
{#each hand.filter(c => !isJoker(c)) as card}
|
||||||
|
<button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable, true) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Deep controls: call a Challenge, and end the scene when none is active -->
|
||||||
|
<!-- The Deep calls Challenges and ends the scene only when none is active. -->
|
||||||
|
{#if isDeep && openChallenges.length === 0}
|
||||||
|
<div class="deep-challenge-controls">
|
||||||
|
<button class="btn {showCreate ? 'btn-secondary' : 'btn-primary'} btn-full" on:click={() => showCreate = !showCreate}>
|
||||||
|
{showCreate ? '✕ Cancel' : '⚔️ Call a Challenge'}
|
||||||
|
</button>
|
||||||
|
{#if showCreate}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
|
||||||
|
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<option value="">Challenge which Pi-Rat?</option>
|
||||||
|
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||||
|
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<div style="margin-bottom: 0.5rem;">
|
||||||
|
{#each state.obstacles as obs}
|
||||||
|
{@const taken = challengedObstacleIds.has(obs.id)}
|
||||||
|
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
|
||||||
|
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
|
||||||
|
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<input type="text" class="input-field" placeholder="On success (optional): what they win…" bind:value={stakesSuccess} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<input type="text" class="input-field" placeholder="On failure (optional): what it costs them…" bind:value={stakesFailure} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<button class="btn btn-primary btn-full" on:click={createChallenge}
|
||||||
|
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
|
||||||
|
Call Challenge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="scene-upkeep-controls text-center">
|
||||||
|
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
|
||||||
|
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
|
||||||
|
End Scene & Proceed to Ranking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
213
frontend/src/components/scene/CharacterSheet.svelte
Normal file
213
frontend/src/components/scene/CharacterSheet.svelte
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
import { getCardDisplay, isJoker, displayName, crewLabel, captainName, cardObstacleInfo, pvpObstacleInfo, obstacleTable } from '../../lib/cards';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
export let target; // the player whose sheet is being viewed
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
function close() { dispatch('close'); }
|
||||||
|
|
||||||
|
let error = '';
|
||||||
|
let pvpCard = '';
|
||||||
|
|
||||||
|
$: viewer = state.player;
|
||||||
|
$: isSelf = target.id === viewer.id;
|
||||||
|
$: isDeep = viewer.role === 'deep';
|
||||||
|
$: isCaptain = target.id === state.game.captain_player_id;
|
||||||
|
// The Deep manages captaincy and ticks personal objectives from a Pi-Rat's sheet
|
||||||
|
// (both moved here from the old Deep Control Panel).
|
||||||
|
$: canManageCaptain = isDeep && target.role === 'pirat' && !target.is_dead;
|
||||||
|
$: canManageObjectives = isDeep && target.role === 'pirat';
|
||||||
|
// The viewer throws one of their OWN cards in a duel.
|
||||||
|
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
|
||||||
|
$: canDuel = !isSelf
|
||||||
|
&& viewer.role === 'pirat' && !viewer.is_dead && !viewer.needs_reroll
|
||||||
|
&& target.role === 'pirat' && !target.is_dead;
|
||||||
|
// What the chosen duel card becomes as a temporary Obstacle for the defender
|
||||||
|
$: pvpCardObstacle = pvpObstacleInfo(pvpCard);
|
||||||
|
|
||||||
|
function getPlayerName(id) {
|
||||||
|
if (!id) return 'a crewmate';
|
||||||
|
const p = state.players.find(pl => pl.id === id);
|
||||||
|
return p ? displayName(p) : 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPvp() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/challenge/pvp/create`, 'POST', {
|
||||||
|
defender_id: target.id,
|
||||||
|
card_code: pvpCard
|
||||||
|
});
|
||||||
|
pvpCard = '';
|
||||||
|
close();
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setCaptain(makeCaptain) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/set-captain`, 'POST', {
|
||||||
|
target_player_id: makeCaptain ? target.id : ''
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleObjective(type) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${target.id}/objective/toggle`, 'POST', { type });
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||||
|
|
||||||
|
<div class="modal-backdrop" on:click|self={close}>
|
||||||
|
<div class="modal-box sheet-modal character-sheet-modal glass-panel">
|
||||||
|
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
{#if target.role === 'deep'}🌊{:else if target.is_ghost}👻{:else if target.is_dead}💀{:else}🐀{/if}
|
||||||
|
{target.id === state.game.captain_player_id ? captainName(target.name) : target.name}'s Character Sheet
|
||||||
|
</h3>
|
||||||
|
{#if target.player_name && target.player_name !== target.name}
|
||||||
|
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
|
||||||
|
Played by {target.player_name}{#if !target.completed_personal_2} — they'll go by their smell until they earn a Name!{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="character-sheet-details">
|
||||||
|
<div class="sheet-main-column">
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isSelf && state.player.needs_reroll}
|
||||||
|
<div class="prompt-box accent text-center">
|
||||||
|
<h4>🐀 Recruit Incoming!</h4>
|
||||||
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You don't have a Pi-Rat in this scene. Sit back, enjoy the chaos, and you'll create your recruit with the crew between scenes.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isSelf && state.player.is_dead}
|
||||||
|
<div class="prompt-box danger text-center">
|
||||||
|
<h4>💀 You have Died / Retired!</h4>
|
||||||
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">Your story arc is complete. You will choose to become a Ghost or roll a new character during the upkeep phase between scenes.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isSelf && state.player.is_ghost}
|
||||||
|
<div class="prompt-box ghostly text-center">
|
||||||
|
<h4>👻 Playing as a Ghost</h4>
|
||||||
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You are in the Ghost World (grayscale view). You cannot interact well with the living but you can still help them resolve challenges!</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isSelf && state.player.tax_banned}
|
||||||
|
<div class="prompt-box danger text-center">
|
||||||
|
<p class="info-text" style="margin: 0; font-size: 0.9rem;">🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sheet-group">
|
||||||
|
<h4>Description:</h4>
|
||||||
|
<p><strong>Look:</strong> {target.avatar_look}</p>
|
||||||
|
<p><strong>Smell:</strong> {target.avatar_smell}</p>
|
||||||
|
<p><strong>First Words:</strong> "{target.first_words}"</p>
|
||||||
|
{#if target.gat_description}
|
||||||
|
<p><strong>🔫 Gat:</strong> {target.gat_description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if target.other_like || target.other_hate}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4>What the Crew Thinks:</h4>
|
||||||
|
{#if target.other_like}
|
||||||
|
<p><strong>👍 Likes:</strong> "{target.other_like}" <span class="text-muted">— {getPlayerName(target.other_like_from_player_id)}</span></p>
|
||||||
|
{/if}
|
||||||
|
{#if target.other_hate}
|
||||||
|
<p><strong>👎 Hates:</strong> "{target.other_hate}" <span class="text-muted">— {getPlayerName(target.other_hate_from_player_id)}</span></p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Secret techniques are visible only on your own sheet -->
|
||||||
|
{#if isSelf}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
||||||
|
<ul class="techniques-list-sheet">
|
||||||
|
<li><strong>Jack (J):</strong> "{target.tech_jack}"</li>
|
||||||
|
<li><strong>Queen (Q):</strong> "{target.tech_queen}"</li>
|
||||||
|
<li><strong>King (K):</strong> "{target.tech_king}"</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Duel this Pi-Rat (target is fixed to this sheet) -->
|
||||||
|
{#if canDuel}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4>⚔️ Duel {crewLabel(target, state.game.captain_player_id)}</h4>
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">Challenge them (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
|
||||||
|
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<option value="">Throw which card?</option>
|
||||||
|
{#each hand.filter(c => !isJoker(c)) as card}
|
||||||
|
<option value={card} title={pvpObstacleInfo(card) ? `${pvpObstacleInfo(card).title} — ${pvpObstacleInfo(card).description}` : ''}>{getCardDisplay(card)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if pvpCardObstacle}
|
||||||
|
<p class="info-text" style="font-size: 0.8rem; margin: -0.25rem 0 0.5rem 0;">
|
||||||
|
As an Obstacle, {getCardDisplay(pvpCard)} is <strong>{pvpCardObstacle.title}</strong>: {pvpCardObstacle.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpCard}>Throw Down!</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canManageCaptain}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4>🏴☠️ Captaincy</h4>
|
||||||
|
{#if isCaptain}
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">{target.name} holds the Captaincy until they step down, lose a duel, die, or the ship is lost.</p>
|
||||||
|
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(false)}>Remove as Captain</button>
|
||||||
|
{:else}
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">Hand this Pi-Rat the Captaincy (e.g. after a leadership duel or resignation).</p>
|
||||||
|
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(true)}>Make Captain</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div> <!-- end sheet-main-column -->
|
||||||
|
|
||||||
|
<div class="sheet-side-column">
|
||||||
|
<div class="sheet-group">
|
||||||
|
<h4>Personal Objectives</h4>
|
||||||
|
{#if canManageObjectives}
|
||||||
|
<p class="info-text" style="font-size: 0.8rem;">As the Deep, tick these off in sequence (1 → 2 → 3).</p>
|
||||||
|
{/if}
|
||||||
|
<div class="objectives-checklist">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={target.completed_personal_1} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_1')}>
|
||||||
|
<span>1. Gat</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={target.completed_personal_2} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_2')}>
|
||||||
|
<span>2. Name</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={target.completed_personal_3} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_3')}>
|
||||||
|
<span>3. Die/Retire</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> <!-- end sheet-side-column -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
115
frontend/src/components/scene/CrewColumn.svelte
Normal file
115
frontend/src/components/scene/CrewColumn.svelte
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script>
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
import { crewLabel } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
|
import CharacterSheet from './CharacterSheet.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let openTargetId = null;
|
||||||
|
let showObjectives = false;
|
||||||
|
let objError = '';
|
||||||
|
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
|
||||||
|
// Track which Pi-Rats have faced a Deep Challenge this scene so the Deep can
|
||||||
|
// see who still needs one before ending the scene.
|
||||||
|
$: challengedIds = new Set(
|
||||||
|
(state.challenges || [])
|
||||||
|
.filter(c => c.challenge_type === 'deep')
|
||||||
|
.map(c => c.target_player_id)
|
||||||
|
);
|
||||||
|
function hasBeenChallenged(p) {
|
||||||
|
return p.role === 'pirat' && challengedIds.has(p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Deep ticks Crew Objectives off here (moved from the old Deep panel).
|
||||||
|
async function toggleCrew(type) {
|
||||||
|
objError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/objective/toggle`, 'POST', { type });
|
||||||
|
} catch (e) {
|
||||||
|
objError = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pi-Rats first, the Deep last; otherwise keep join order.
|
||||||
|
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
||||||
|
$: captainId = state.game.captain_player_id;
|
||||||
|
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
||||||
|
$: crewDone = [state.game.completed_crew_1, state.game.completed_crew_2, state.game.completed_crew_3].filter(Boolean).length;
|
||||||
|
|
||||||
|
function handSize(p) {
|
||||||
|
try { return JSON.parse(p.hand_cards || '[]').length; } catch { return 0; }
|
||||||
|
}
|
||||||
|
function roleIcon(p) {
|
||||||
|
if (p.role === 'deep') return '🌊';
|
||||||
|
if (p.is_ghost) return '👻';
|
||||||
|
if (p.is_dead) return '💀';
|
||||||
|
return '🐀';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crew-column">
|
||||||
|
<div class="crew-bubbles">
|
||||||
|
{#each crew as p (p.id)}
|
||||||
|
<button
|
||||||
|
class="crew-bubble"
|
||||||
|
class:is-you={p.id === state.player.id}
|
||||||
|
class:is-deep={p.role === 'deep'}
|
||||||
|
class:is-dead={p.is_dead}
|
||||||
|
class:is-ghost={p.is_ghost}
|
||||||
|
title="View {p.name}'s character sheet"
|
||||||
|
on:click={() => openTargetId = p.id}
|
||||||
|
>
|
||||||
|
{#if isDeep && p.role === 'pirat'}
|
||||||
|
<span
|
||||||
|
class="challenge-check"
|
||||||
|
class:done={hasBeenChallenged(p)}
|
||||||
|
use:tooltip={hasBeenChallenged(p)
|
||||||
|
? `${p.name} has faced a Challenge this scene.`
|
||||||
|
: `${p.name} hasn't faced a Challenge yet — the scene can't end until they do (or the Obstacle List is cleared).`}
|
||||||
|
>{hasBeenChallenged(p) ? '✓' : '○'}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="bubble-icon">{roleIcon(p)}</span>
|
||||||
|
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||||
|
<span class="bubble-meta">
|
||||||
|
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Crew Objectives sits at the end of the roster and slides open in place -->
|
||||||
|
<button
|
||||||
|
class="crew-objectives-toggle"
|
||||||
|
aria-expanded={showObjectives}
|
||||||
|
on:click={() => showObjectives = !showObjectives}
|
||||||
|
>
|
||||||
|
<span class="co-label">{showObjectives ? '▾' : '▸'} Crew Objectives</span>
|
||||||
|
<span class="co-count">{crewDone}/3</span>
|
||||||
|
</button>
|
||||||
|
{#if showObjectives}
|
||||||
|
<div class="crew-objectives-detail" transition:slide>
|
||||||
|
{#if objError}<div class="alert alert-danger">{objError}</div>{/if}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_1} disabled={!isDeep} on:change={() => toggleCrew('crew_1')}>
|
||||||
|
<span>Steal a Ship</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_2} disabled={!isDeep} on:change={() => toggleCrew('crew_2')}>
|
||||||
|
<span>Choose a Captain</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_3} disabled={!isDeep} on:change={() => toggleCrew('crew_3')}>
|
||||||
|
<span>Commit Piracy</span>
|
||||||
|
</label>
|
||||||
|
{#if isDeep}<p class="info-text" style="font-size: 0.75rem; margin: 0.25rem 0 0;">As the Deep, tick these off as the crew earns them.</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if openTarget}
|
||||||
|
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
||||||
|
{/if}
|
||||||
35
frontend/src/components/scene/ObstacleBoard.svelte
Normal file
35
frontend/src/components/scene/ObstacleBoard.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script>
|
||||||
|
import ObstacleItem from './ObstacleItem.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||||
|
// When a Challenge is active, the involved Obstacles move up into the Challenge
|
||||||
|
// panel; the rest of the list collapses so attention stays on the Challenge.
|
||||||
|
$: challengeActive = openChallenges.length > 0;
|
||||||
|
$: looseObstacles = state.obstacles.filter(o => !challengedObstacleIds.has(o.id));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if challengeActive}
|
||||||
|
{#if looseObstacles.length}
|
||||||
|
<details class="obstacle-collapse">
|
||||||
|
<summary>🌊 {looseObstacles.length} other Obstacle{looseObstacles.length === 1 ? '' : 's'} on the list — show</summary>
|
||||||
|
<div class="obstacles-container">
|
||||||
|
{#each looseObstacles as obs (obs.id)}
|
||||||
|
<ObstacleItem {state} {obs} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text">All Obstacles are tied up in the Challenge above.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="obstacles-container">
|
||||||
|
{#each state.obstacles as obs (obs.id)}
|
||||||
|
<ObstacleItem {state} {obs} />
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text">No active obstacles. The Deep can end the scene!</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
112
frontend/src/components/scene/ObstacleItem.svelte
Normal file
112
frontend/src/components/scene/ObstacleItem.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
import { isJoker, cardValue } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
|
import Card from '../Card.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
export let obs;
|
||||||
|
export let embedded = false; // rendered inside the Challenge panel — skip the "in challenge" flag
|
||||||
|
|
||||||
|
// Plain (text-presentation) suit glyphs so they inherit the title's suit color,
|
||||||
|
// unlike SUIT_EMOJI which renders as fixed-color emoji.
|
||||||
|
const SUIT_CHAR = { C: '♣', S: '♠', H: '♥', D: '♦' };
|
||||||
|
const VALUE_HINT = 'The number to beat. Set by the most recent card played on the column — or the original card '
|
||||||
|
+ 'until one is played. (Color stays fixed to the original.)';
|
||||||
|
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
$: column_cards = JSON.parse(obs.played_cards || '[]');
|
||||||
|
$: active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card;
|
||||||
|
$: success_count = column_cards.filter(c => c.success).length;
|
||||||
|
$: is_completed = success_count >= state.players.length;
|
||||||
|
$: in_challenge = openChallenges.some(c => JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
|
||||||
|
$: cardCodeLabel = `${cardValue(obs.original_card)}${SUIT_CHAR[obs.suit] || ''}`;
|
||||||
|
$: is_face_active = ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1));
|
||||||
|
|
||||||
|
// A face-card Obstacle is worth the challenged Rat's Rank. When this Obstacle is
|
||||||
|
// part of an open Challenge we know who that is — return their rank, else null.
|
||||||
|
$: challengedRank = (() => {
|
||||||
|
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
|
||||||
|
if (!ch) return null;
|
||||||
|
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function post(path, data) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(path, 'POST', data);
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playCard = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-card`, { obstacle_id: obs.id, card_code: cardCode });
|
||||||
|
const playJoker = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-joker`, { obstacle_id: obs.id, card_code: cardCode });
|
||||||
|
const clearObstacle = () => post(`/game/${state.game.id}/scene/obstacle/${obs.id}/clear`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="obstacle-item suit-{obs.suit.toLowerCase()}"
|
||||||
|
class:completed={is_completed}
|
||||||
|
class:in-challenge={in_challenge && !embedded}
|
||||||
|
data-obstacle-id={obs.id}
|
||||||
|
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
||||||
|
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
||||||
|
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
|
||||||
|
on:drop={(e) => {
|
||||||
|
if (is_completed) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove("drag-over");
|
||||||
|
const cardCode = e.dataTransfer.getData('text/plain');
|
||||||
|
if (cardCode) {
|
||||||
|
if (isJoker(cardCode)) playJoker(cardCode);
|
||||||
|
else playCard(cardCode);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger" style="grid-column: 1 / -1;">{error}</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Card column: original at the back, each play stacked in front of it, the
|
||||||
|
latest fully visible and earlier cards peeking their rank + suit. -->
|
||||||
|
<div class="obstacle-stack">
|
||||||
|
<div class="stack-card is-original" use:tooltip={'Original card — fixes the Obstacle’s color and never changes.'}>
|
||||||
|
<Card card={obs.original_card} size="medium" />
|
||||||
|
</div>
|
||||||
|
{#each column_cards as pc, i (i)}
|
||||||
|
<div class="stack-card {pc.success ? 'is-success' : 'is-failure'}"
|
||||||
|
class:is-latest={i === column_cards.length - 1}
|
||||||
|
use:tooltip={`${pc.player_name} played this — ${pc.success ? 'success' : 'no success'}.`}>
|
||||||
|
<Card card={pc.card} size="medium" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="obstacle-details">
|
||||||
|
<h4><span class="obs-card-code">{cardCodeLabel}</span> — {obs.title} {#if in_challenge && !embedded}<span class="in-challenge-tag">⚔️ In Challenge</span>{/if}</h4>
|
||||||
|
<p class="desc">{obs.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Value Display -->
|
||||||
|
<div class="obstacle-value-display text-center" use:tooltip={VALUE_HINT}>
|
||||||
|
<span class="val-label">Current Difficulty</span>
|
||||||
|
<span class="val-number">
|
||||||
|
{#if is_face_active}
|
||||||
|
{challengedRank !== null ? `${challengedRank} (Rat's Rank)` : "Challenged Rat's Rank"}
|
||||||
|
{:else}
|
||||||
|
{obs.current_value}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Successes Tracker -->
|
||||||
|
<div class="obstacle-successes-display text-center">
|
||||||
|
<span class="val-label">Successes</span>
|
||||||
|
<span class="val-number">{success_count} / {state.players.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if is_completed && state.player.role === 'deep'}
|
||||||
|
<div class="clear-obstacle-row text-center">
|
||||||
|
<button class="btn btn-success btn-full" on:click={clearObstacle}>Clear Obstacle</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
25
frontend/src/lib/api.js
Normal file
25
frontend/src/lib/api.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export async function apiRequest(endpoint, method = 'GET', data = null) {
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
|
options.body = new URLSearchParams(data).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api${endpoint}`, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = res.statusText;
|
||||||
|
try {
|
||||||
|
const errBody = await res.json();
|
||||||
|
if (errBody.error) msg = errBody.error;
|
||||||
|
else if (errBody.detail) msg = errBody.detail;
|
||||||
|
} catch(e) {}
|
||||||
|
const error = new Error(msg);
|
||||||
|
error.status = res.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
165
frontend/src/lib/cards.js
Normal file
165
frontend/src/lib/cards.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// Card-code helpers shared across components.
|
||||||
|
// Codes look like "10D", "KH", "Joker1" (black) / "Joker2" (red).
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { apiRequest } from './api';
|
||||||
|
|
||||||
|
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
|
||||||
|
|
||||||
|
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
|
||||||
|
const SUIT_THEMES = {
|
||||||
|
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
|
||||||
|
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
|
||||||
|
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
|
||||||
|
H: "♥ Hearts — Shoutin' and Singin': social skills, performance, or charisma",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The rulebook's obstacle tables (what each card represents when it surfaces
|
||||||
|
// as an Obstacle), served by the backend so there's a single source of truth.
|
||||||
|
// Loaded once at startup; until it arrives tooltips fall back to suit themes.
|
||||||
|
let OBSTACLE_TABLE = null;
|
||||||
|
export const obstacleTable = writable(null);
|
||||||
|
export async function loadObstacleTable(retries = 5) {
|
||||||
|
// Static rulebook data; retry on transient failures so a single blip on
|
||||||
|
// first load doesn't silently disable obstacle tooltips until a refresh.
|
||||||
|
while (!OBSTACLE_TABLE && retries-- > 0) {
|
||||||
|
try {
|
||||||
|
OBSTACLE_TABLE = (await apiRequest('/obstacles')).obstacles;
|
||||||
|
obstacleTable.set(OBSTACLE_TABLE);
|
||||||
|
} catch (e) {
|
||||||
|
if (retries <= 0) {
|
||||||
|
console.error('Failed to load obstacle table', e);
|
||||||
|
} else {
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OBSTACLE_TABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a card represents when it surfaces as an Obstacle, or null if unknown
|
||||||
|
// (Jokers / table not loaded). Pass the obstacleTable store value as `table`.
|
||||||
|
export function cardObstacleInfo(code, table = OBSTACLE_TABLE) {
|
||||||
|
if (!code || isJoker(code)) return null;
|
||||||
|
return table?.[cardSuit(code)]?.[cardValue(code)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default tooltip for a card: its suit theme when played, plus what it
|
||||||
|
// represents as an Obstacle (relevant when challenging another Pi-Rat).
|
||||||
|
// Pass the obstacleTable store's value as `table` to recompute reactively.
|
||||||
|
// Plain-string form, kept for aria-label / native fallbacks.
|
||||||
|
export function cardTooltip(code, table = OBSTACLE_TABLE) {
|
||||||
|
if (!code) return '';
|
||||||
|
if (isJoker(code)) {
|
||||||
|
return '🃏 Joker — played: discards an Obstacle (and its column) outright and draws a replacement.\n'
|
||||||
|
+ 'As an Obstacle: discarded, but future scenes permanently require one more Obstacle.';
|
||||||
|
}
|
||||||
|
const theme = SUIT_THEMES[cardSuit(code)] || '';
|
||||||
|
const obstacle = table?.[cardSuit(code)]?.[cardValue(code)];
|
||||||
|
if (!obstacle) return theme;
|
||||||
|
return `${theme}\nAs an Obstacle: ${obstacle.title} — ${obstacle.description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACE_VALUES = ['J', 'Q', 'K'];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rich (HTML) tooltip describing a card — what it does when *played*, and what
|
||||||
|
// it means when it surfaces as an Obstacle. Built only from static rulebook
|
||||||
|
// data (SUIT_THEMES + the obstacle table), so the HTML is trusted. Feed the
|
||||||
|
// result to the `tooltip` action as `{ html: cardTooltipHtml(...) }`.
|
||||||
|
export function cardTooltipHtml(code, table = OBSTACLE_TABLE, isPvp = false) {
|
||||||
|
if (!code) return '';
|
||||||
|
if (isJoker(code)) {
|
||||||
|
return '<div class="tt-head"><span class="tt-card tt-joker">🃏 Joker</span></div>'
|
||||||
|
+ '<div class="tt-row"><span class="tt-label">Played</span>Discard one Obstacle (and its column) outright, then draw a replacement.</div>'
|
||||||
|
+ '<div class="tt-row"><span class="tt-label">As an Obstacle</span>Discarded — but every following scene needs one more Obstacle.</div>';
|
||||||
|
}
|
||||||
|
const suit = cardSuit(code);
|
||||||
|
const val = cardValue(code);
|
||||||
|
const isRed = suit === 'H' || suit === 'D';
|
||||||
|
const colorClass = isRed ? 'tt-red' : 'tt-black';
|
||||||
|
let html = `<div class="tt-head"><span class="tt-card ${colorClass}">${esc(val)}${SUIT_EMOJI[suit] || esc(suit)}</span>`
|
||||||
|
+ `<span class="tt-color tt-color-${isRed ? 'red' : 'black'}">${isRed ? 'Red' : 'Black'}</span></div>`;
|
||||||
|
html += `<div class="tt-theme">${esc(SUIT_THEMES[suit] || '')}</div>`;
|
||||||
|
|
||||||
|
if (isPvp) {
|
||||||
|
const themeStr = SUIT_THEMES[suit] || '';
|
||||||
|
const splitIdx = themeStr.indexOf(': ');
|
||||||
|
const title = splitIdx !== -1 ? themeStr.substring(0, splitIdx) : themeStr;
|
||||||
|
const desc = splitIdx !== -1 ? themeStr.substring(splitIdx + 2) : '';
|
||||||
|
html += `<div class="tt-row"><span class="tt-label">PvP Obstacle</span><b>${esc(title)}</b> — ${esc(desc)}</div>`;
|
||||||
|
} else if (FACE_VALUES.includes(val)) {
|
||||||
|
html += '<div class="tt-row"><span class="tt-label">Played by a Pi-Rat</span>Triggers a Secret Technique — automatic success.</div>';
|
||||||
|
html += "<div class=\"tt-row\"><span class=\"tt-label\">As an Obstacle</span>Its value equals the challenged Pi-Rat's Rank.</div>";
|
||||||
|
} else {
|
||||||
|
const obstacle = table?.[suit]?.[val];
|
||||||
|
if (obstacle) {
|
||||||
|
html += `<div class="tt-row"><span class="tt-label">As an Obstacle</span><b>${esc(obstacle.title)}</b> — ${esc(obstacle.description)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pvpObstacleInfo(code) {
|
||||||
|
if (!code || isJoker(code)) return null;
|
||||||
|
const suit = cardSuit(code);
|
||||||
|
const themeStr = SUIT_THEMES[suit];
|
||||||
|
if (!themeStr) return null;
|
||||||
|
const splitIdx = themeStr.indexOf(': ');
|
||||||
|
if (splitIdx === -1) return { title: themeStr, description: '' };
|
||||||
|
return {
|
||||||
|
title: themeStr.substring(0, splitIdx),
|
||||||
|
description: themeStr.substring(splitIdx + 2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJoker(code) {
|
||||||
|
return !!code && code.startsWith('Joker');
|
||||||
|
}
|
||||||
|
|
||||||
|
// "10D" -> "10"
|
||||||
|
export function cardValue(code) {
|
||||||
|
return code.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "10D" -> "D"
|
||||||
|
export function cardSuit(code) {
|
||||||
|
return code.slice(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCardDisplay(code) {
|
||||||
|
if (!code) return '';
|
||||||
|
if (isJoker(code)) return '🃏 JOKER';
|
||||||
|
return `${cardValue(code)}${SUIT_EMOJI[cardSuit(code)] || cardSuit(code)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Recruit Cheese (Tim)" — the Pi-Rat's identity with the human player's lobby
|
||||||
|
// name in parentheses (omitted while the two are still identical, e.g. in the lobby).
|
||||||
|
export function displayName(p) {
|
||||||
|
if (!p) return '???';
|
||||||
|
if (p.player_name && p.player_name !== p.name) return `${p.name} (${p.player_name})`;
|
||||||
|
return p.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playerName(players, id) {
|
||||||
|
return displayName(players.find(p => p.id === id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Pi-Rat's name with the "Recruit" honorific swapped for "Captain" (or
|
||||||
|
// "Captain" prepended once they've earned a real Name). Used to mark the Captain
|
||||||
|
// in the scene roster without a separate icon.
|
||||||
|
export function captainName(name) {
|
||||||
|
if (!name) return 'Captain';
|
||||||
|
if (name.startsWith('Recruit ')) return 'Captain ' + name.slice('Recruit '.length);
|
||||||
|
return 'Captain ' + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayName, but the Captain is shown as "Captain <name> (player)".
|
||||||
|
export function crewLabel(p, captainId) {
|
||||||
|
if (!p) return '???';
|
||||||
|
const rat = p.id === captainId ? captainName(p.name) : p.name;
|
||||||
|
if (p.player_name && p.player_name !== p.name) return `${rat} (${p.player_name})`;
|
||||||
|
return rat;
|
||||||
|
}
|
||||||
160
frontend/src/lib/changelog.js
Normal file
160
frontend/src/lib/changelog.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// App version and player-facing changelog, surfaced in the ☰ menu → About
|
||||||
|
// (rendered by AboutModal.svelte).
|
||||||
|
//
|
||||||
|
// Maintenance (see AGENTS.md "Versioning & changelog"):
|
||||||
|
// - Bump VERSION by one on every commit.
|
||||||
|
// - Add a CHANGELOG entry only when a commit changes something players can
|
||||||
|
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
||||||
|
|
||||||
|
export const VERSION = 25;
|
||||||
|
|
||||||
|
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
||||||
|
export const CHANGELOG = [
|
||||||
|
{
|
||||||
|
version: 25,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Event Log now uses the same pinned column and fixed close/reopen control in every game phase.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 24,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The crew roster now stays in the same left-hand column throughout every phase of the game.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 23,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Admins can now click the join code to copy it, and the code is available directly from the lobby.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 21,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Player lists now use the same easy-to-scan vertical layout in every phase and at every screen size.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 20,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Admin Panel now gives the game’s join code a large, easy-to-share display.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 19,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Games now have short join codes that can be entered on the home page and shared from the Admin Panel.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 18,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Expired rejoin entries are now removed automatically when a game has ended or a player is no longer in the crew, with a clear explanation.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 17,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Join forms now remember your player name for the next crew while still letting you edit it.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 16,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Open and Copy controls in the Admin Panel now have matching sizes.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 15,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Admin Panel now clearly marks Pi-Rats whose character identity has not been chosen yet.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 14,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The corner Event Log button now stays put when the log is open, so it can be closed again without moving your mouse.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 13,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Character sheets now make better use of the available space on wide screens.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 8,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Crew roster bubbles are now legible and stand out from the background in both light and dark mode.',
|
||||||
|
'A scene can no longer be ended until every Pi-Rat has faced a Challenge (or the Obstacle List is empty). The Deep sees a ✓/○ marker on each Pi-Rat showing who still needs one.',
|
||||||
|
'Decluttered your Hand panel — dropped the how-to-play blurb for a low-profile label.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 7,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'During a scene, the Event Log can be collapsed to a corner button to declutter your screen, and reopened from that button.',
|
||||||
|
'In the three-column layout, the Event Log now stays pinned within the screen and scrolls internally, so its newest entries are always visible.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 6,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'PvP challenge cards now display the general suit theme instead of specific obstacles in tooltips.',
|
||||||
|
'Secret Technique tooltips now show the full technique description when played.',
|
||||||
|
'Personal Objectives on the Character Sheet are now positioned in a side column on wider screens.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 5,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Completing a Crew Objective is now announced in the Event Log.',
|
||||||
|
'Fixed a UI quirk where mousing over personal objectives incorrectly showed an interactive pointer for Pi-Rats who cannot change them.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 4,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Tidied the Challenge panel: dropped the redundant “Plays” list and the how-to-play blurb. Each Obstacle now leads with its card (e.g. “K♦”) in the title, colored by suit.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'The rulebook’s Example of Play now has card diagrams at each step, showing how each Obstacle’s column grows as the scene unfolds.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Obstacle cards now stack as a single overlapping column — the latest play sits in full view with earlier cards peeking out behind it, matching the rulebook’s card layout.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'First tracked version — the app now reports its version here in the ☰ menu → About, with a log of player-facing changes.',
|
||||||
|
'Home screen lists crews you can rejoin above “Muster a New Crew”.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
6
frontend/src/lib/menu.js
Normal file
6
frontend/src/lib/menu.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// Extra entries for the corner menu, set by pages that have context-specific
|
||||||
|
// links (e.g. Dashboard adds the creator-only Admin link). Each entry is
|
||||||
|
// { label, href, external?, title? }.
|
||||||
|
export const extraMenuLinks = writable([]);
|
||||||
46
frontend/src/lib/sessions.js
Normal file
46
frontend/src/lib/sessions.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Tracks the games this browser has joined so the home page can offer a
|
||||||
|
// "rejoin" menu. Stored in localStorage only — deleting an entry never touches
|
||||||
|
// the backend. Each entry is keyed by gameId + playerId:
|
||||||
|
// { gameId, playerId, crewName, playerName, ratName, updatedAt }
|
||||||
|
const KEY = 'pirats-sessions';
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(KEY));
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(list) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSession(a, gameId, playerId) {
|
||||||
|
return a.gameId === gameId && a.playerId === playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most-recently-updated first.
|
||||||
|
export function getSessions() {
|
||||||
|
return load().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert or merge a session. Only defined fields overwrite existing values, so
|
||||||
|
// a sparse update (e.g. just a playerName at join time) won't clobber names
|
||||||
|
// filled in later from the full game state.
|
||||||
|
export function saveSession(fields) {
|
||||||
|
const { gameId, playerId } = fields;
|
||||||
|
if (!gameId || !playerId) return;
|
||||||
|
const list = load();
|
||||||
|
const idx = list.findIndex(s => sameSession(s, gameId, playerId));
|
||||||
|
const incoming = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && v !== null));
|
||||||
|
const merged = { ...(idx >= 0 ? list[idx] : {}), ...incoming, updatedAt: Date.now() };
|
||||||
|
if (idx >= 0) list[idx] = merged;
|
||||||
|
else list.push(merged);
|
||||||
|
persist(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSession(gameId, playerId) {
|
||||||
|
persist(load().filter(s => !sameSession(s, gameId, playerId)));
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { apiRequest } from './api';
|
||||||
|
|
||||||
// Auto-generated math-pirate suggestions pool for character creation
|
// Auto-generated math-pirate suggestions pool for character creation
|
||||||
const SUGGESTIONS = {
|
const SUGGESTIONS = {
|
||||||
"look": [
|
"look": [
|
||||||
@@ -1211,213 +1213,32 @@ const SUGGESTIONS = {
|
|||||||
"They stubbornly argues about the slide rule and has the abacus tattoos to prove it.",
|
"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 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."
|
"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) {
|
function pickFrom(arr, exclude = []) {
|
||||||
|
const taken = new Set(exclude.map(s => s.trim().toLowerCase()));
|
||||||
|
const available = arr.filter(s => !taken.has(s.trim().toLowerCase()));
|
||||||
|
const pool = available.length > 0 ? available : arr;
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuggestion(type, exclude = []) {
|
||||||
const arr = SUGGESTIONS[type];
|
const arr = SUGGESTIONS[type];
|
||||||
if (!arr) return "";
|
if (!arr) return "";
|
||||||
return arr[Math.floor(Math.random() * arr.length)];
|
return pickFrom(arr, exclude);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret Pirate Technique names live in the backend (it also uses them to equip
|
||||||
|
// re-rolled recruits); fetch the pool once and pick from it client-side.
|
||||||
|
let techniquePoolPromise = null;
|
||||||
|
function loadTechniquePool() {
|
||||||
|
if (!techniquePoolPromise) {
|
||||||
|
techniquePoolPromise = apiRequest('/suggestions/techniques').then(d => d.techniques);
|
||||||
|
}
|
||||||
|
return techniquePoolPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTechniqueSuggestion(exclude = []) {
|
||||||
|
return pickFrom(await loadTechniquePool(), exclude);
|
||||||
}
|
}
|
||||||
23
frontend/src/lib/theme.js
Normal file
23
frontend/src/lib/theme.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// Light/dark theme preference, shared across games via localStorage (the
|
||||||
|
// rulebook page reads the same key). index.html applies the saved theme
|
||||||
|
// before first paint; this store keeps the <html> attribute and storage in
|
||||||
|
// sync when the user toggles from the corner menu.
|
||||||
|
const STORAGE_KEY = 'pirats-theme';
|
||||||
|
|
||||||
|
function savedTheme() {
|
||||||
|
const value = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return value === 'dark' ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme = writable(savedTheme());
|
||||||
|
|
||||||
|
theme.subscribe((value) => {
|
||||||
|
document.documentElement.dataset.theme = value;
|
||||||
|
localStorage.setItem(STORAGE_KEY, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function toggleTheme() {
|
||||||
|
theme.update((value) => (value === 'dark' ? 'light' : 'dark'));
|
||||||
|
}
|
||||||
9
frontend/src/lib/title.js
Normal file
9
frontend/src/lib/title.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Sets document.title to a " · "-joined set of page specifics followed by the
|
||||||
|
// app name, so tabs and browser history entries are easy to tell apart.
|
||||||
|
// Falsy parts are dropped; with no parts the title is just the app name.
|
||||||
|
const BASE = 'Rats with Gats';
|
||||||
|
|
||||||
|
export function setPageTitle(...parts) {
|
||||||
|
const prefix = parts.filter(Boolean).join(' · ');
|
||||||
|
document.title = prefix ? `${prefix} — ${BASE}` : BASE;
|
||||||
|
}
|
||||||
84
frontend/src/lib/tooltip.js
Normal file
84
frontend/src/lib/tooltip.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// A styled hover/focus tooltip that replaces the native `title` attribute.
|
||||||
|
//
|
||||||
|
// use:tooltip={"plain text"} -> rendered as text
|
||||||
|
// use:tooltip={{ html: "<b>rich</b>" }} -> rendered as HTML (trusted, static only)
|
||||||
|
//
|
||||||
|
// Only ever pass trusted, static HTML (e.g. built from the rulebook tables in
|
||||||
|
// lib/cards.js); never interpolate raw player input into the `html` form.
|
||||||
|
//
|
||||||
|
// The floating element is appended to <body> and positioned with fixed
|
||||||
|
// coordinates so it escapes any overflow:hidden / transformed ancestors
|
||||||
|
// (cards, modals, scrolling columns).
|
||||||
|
|
||||||
|
function contentEmpty(content) {
|
||||||
|
if (!content) return true;
|
||||||
|
if (typeof content === 'object') return !content.html;
|
||||||
|
return String(content).trim() === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tooltip(node, content) {
|
||||||
|
let current = content;
|
||||||
|
let tip = null;
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
if (tip || contentEmpty(current)) return;
|
||||||
|
tip = document.createElement('div');
|
||||||
|
tip.className = 'tooltip-pop';
|
||||||
|
tip.setAttribute('role', 'tooltip');
|
||||||
|
if (typeof current === 'object' && current.html != null) {
|
||||||
|
tip.innerHTML = current.html;
|
||||||
|
} else {
|
||||||
|
tip.textContent = String(current);
|
||||||
|
}
|
||||||
|
document.body.appendChild(tip);
|
||||||
|
position();
|
||||||
|
requestAnimationFrame(() => tip && tip.classList.add('visible'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function position() {
|
||||||
|
if (!tip) return;
|
||||||
|
const r = node.getBoundingClientRect();
|
||||||
|
const t = tip.getBoundingClientRect();
|
||||||
|
const margin = 8;
|
||||||
|
// Prefer above the target; flip below when there isn't room.
|
||||||
|
let top = r.top - t.height - margin;
|
||||||
|
if (top < margin) top = r.bottom + margin;
|
||||||
|
if (top + t.height > window.innerHeight - margin) {
|
||||||
|
top = Math.max(margin, window.innerHeight - t.height - margin);
|
||||||
|
}
|
||||||
|
let left = r.left + r.width / 2 - t.width / 2;
|
||||||
|
left = Math.max(margin, Math.min(left, window.innerWidth - t.width - margin));
|
||||||
|
tip.style.top = `${top}px`;
|
||||||
|
tip.style.left = `${left}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
if (tip) {
|
||||||
|
tip.remove();
|
||||||
|
tip = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('mouseenter', show);
|
||||||
|
node.addEventListener('mouseleave', hide);
|
||||||
|
node.addEventListener('focus', show);
|
||||||
|
node.addEventListener('blur', hide);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newContent) {
|
||||||
|
current = newContent;
|
||||||
|
if (tip) {
|
||||||
|
// Refresh the content of an already-visible tip in place.
|
||||||
|
hide();
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
hide();
|
||||||
|
node.removeEventListener('mouseenter', show);
|
||||||
|
node.removeEventListener('mouseleave', hide);
|
||||||
|
node.removeEventListener('focus', show);
|
||||||
|
node.removeEventListener('blur', hide);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
22
frontend/src/main.js
Normal file
22
frontend/src/main.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { mount } from 'svelte'
|
||||||
|
|
||||||
|
// Self-hosted fonts (bundled by Vite — no external font CDN at runtime)
|
||||||
|
import '@fontsource/pirata-one'
|
||||||
|
import '@fontsource/alegreya-sc/500.css'
|
||||||
|
import '@fontsource/alegreya-sc/700.css'
|
||||||
|
import '@fontsource/alegreya-sans/400.css'
|
||||||
|
import '@fontsource/alegreya-sans/500.css'
|
||||||
|
import '@fontsource/alegreya-sans/700.css'
|
||||||
|
|
||||||
|
import './app.css'
|
||||||
|
import App from './App.svelte'
|
||||||
|
import { loadObstacleTable } from './lib/cards'
|
||||||
|
|
||||||
|
// Warm the obstacle-table cache so card tooltips can describe cards as Obstacles
|
||||||
|
loadObstacleTable()
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
234
frontend/src/pages/Admin.svelte
Normal file
234
frontend/src/pages/Admin.svelte
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { setPageTitle } from '../lib/title';
|
||||||
|
import { getSessions } from '../lib/sessions';
|
||||||
|
|
||||||
|
export let params = {};
|
||||||
|
let gameId = params.id;
|
||||||
|
let adminKey = '';
|
||||||
|
|
||||||
|
// Return to this browser's own Pi-Rat dashboard for the game (the most
|
||||||
|
// recently used session), not the public join screen. Falls back to join
|
||||||
|
// if this browser has no saved session for the game.
|
||||||
|
$: mySession = getSessions().find(s => s.gameId === gameId);
|
||||||
|
$: backLink = mySession
|
||||||
|
? `/#/game/${gameId}/player/${mySession.playerId}`
|
||||||
|
: `/#/game/${gameId}/join`;
|
||||||
|
$: backLabel = mySession ? '← Back to Game' : 'Back to Game Join Page';
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
let error = '';
|
||||||
|
let copiedPlayerId = null;
|
||||||
|
let copiedTimer = null;
|
||||||
|
let copiedInvite = false;
|
||||||
|
let copiedInviteTimer = null;
|
||||||
|
let copiedJoinCode = false;
|
||||||
|
let copiedJoinCodeTimer = null;
|
||||||
|
|
||||||
|
$: setPageTitle('Admin', data?.game?.crew_name);
|
||||||
|
|
||||||
|
function rejoinLink(playerId) {
|
||||||
|
return `${window.location.origin}/#/game/${gameId}/player/${playerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteLink() {
|
||||||
|
return `${window.location.origin}/#/game/${gameId}/join`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyRejoinLink(playerId) {
|
||||||
|
navigator.clipboard.writeText(rejoinLink(playerId));
|
||||||
|
copiedPlayerId = playerId;
|
||||||
|
clearTimeout(copiedTimer);
|
||||||
|
copiedTimer = setTimeout(() => { copiedPlayerId = null; }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyInviteLink() {
|
||||||
|
navigator.clipboard.writeText(inviteLink());
|
||||||
|
copiedInvite = true;
|
||||||
|
clearTimeout(copiedInviteTimer);
|
||||||
|
copiedInviteTimer = setTimeout(() => { copiedInvite = false; }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyJoinCode() {
|
||||||
|
navigator.clipboard.writeText(data.game.join_code);
|
||||||
|
copiedJoinCode = true;
|
||||||
|
clearTimeout(copiedJoinCodeTimer);
|
||||||
|
copiedJoinCodeTimer = setTimeout(() => { copiedJoinCode = false; }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Try to load admin_key from local storage
|
||||||
|
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
|
||||||
|
if (adminKey) {
|
||||||
|
loadAdminData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAdminData() {
|
||||||
|
if (!adminKey) return;
|
||||||
|
try {
|
||||||
|
data = await apiRequest(`/game/${gameId}/admin?key=${encodeURIComponent(adminKey)}`);
|
||||||
|
error = '';
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAdmin(playerId, makeAdmin) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${gameId}/admin/set-admin`, 'POST', {
|
||||||
|
key: adminKey,
|
||||||
|
target_player_id: playerId,
|
||||||
|
is_admin: makeAdmin,
|
||||||
|
});
|
||||||
|
await loadAdminData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last remaining Admin can't be kicked, so the table is never locked out.
|
||||||
|
$: adminCount = data ? data.players.filter((p) => p.is_admin).length : 0;
|
||||||
|
function canKick(p) {
|
||||||
|
return !(p.is_admin && adminCount <= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kickPlayer(p) {
|
||||||
|
const who = p.player_name || p.name;
|
||||||
|
if (!confirm(`Kick ${who} from the game? Their cards go to the discard and they'll have to rejoin. This can't be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${gameId}/admin/kick`, 'POST', {
|
||||||
|
key: adminKey,
|
||||||
|
target_player_id: p.id,
|
||||||
|
});
|
||||||
|
await loadAdminData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="admin-page">
|
||||||
|
<h1>Admin Panel</h1>
|
||||||
|
|
||||||
|
{#if !data}
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="mb-4">Enter Admin Key</h2>
|
||||||
|
<form on:submit|preventDefault={loadAdminData} class="flex gap-4">
|
||||||
|
<input type="text" bind:value={adminKey} class="input-field flex-grow" placeholder="Admin Key" required/>
|
||||||
|
<button type="submit" class="btn btn-primary">Login</button>
|
||||||
|
</form>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger mt-4">{error}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card">
|
||||||
|
<div class="admin-summary">
|
||||||
|
<div class="admin-summary-details">
|
||||||
|
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
|
||||||
|
<p><strong>Phase:</strong> {data.game.phase}</p>
|
||||||
|
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-join-code"
|
||||||
|
class:copied={copiedJoinCode}
|
||||||
|
aria-label="Copy join code {data.game.join_code}"
|
||||||
|
on:click={copyJoinCode}
|
||||||
|
>
|
||||||
|
<span class="admin-join-code-label">Join Code</span>
|
||||||
|
<strong>{data.game.join_code}</strong>
|
||||||
|
<span class="admin-join-code-hint">{copiedJoinCode ? '✓ Copied!' : 'Click to copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger mt-4">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h3 class="mt-8 mb-4">Invite Link</h3>
|
||||||
|
<p class="info-text">Share this link to let new players join the crew.</p>
|
||||||
|
<div class="link-copy-action">
|
||||||
|
<input type="text" readonly value={inviteLink()} class="input-field flex-grow" />
|
||||||
|
<a href={inviteLink()} class="btn btn-secondary btn-small">Open</a>
|
||||||
|
<button on:click={copyInviteLink} class="btn btn-secondary btn-small">
|
||||||
|
{copiedInvite ? '✓ Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-8 mb-4">Players</h3>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Pi-Rat</th>
|
||||||
|
<th>Player</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Rank</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Re-join Link</th>
|
||||||
|
<th>Kick</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.players as p}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{#if p.name && p.name !== p.player_name}
|
||||||
|
{p.name}
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted">Not chosen yet</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>{p.player_name || p.name}</td>
|
||||||
|
<td>
|
||||||
|
{#if p.role === 'deep'} 🌊 Deep
|
||||||
|
{:else if p.role === 'pirat'} 🐀 Pi-Rat
|
||||||
|
{:else} None
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if p.is_ghost} 👻 Ghost
|
||||||
|
{:else if p.is_dead} 💀 Dead
|
||||||
|
{:else} Alive
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>{p.rank}</td>
|
||||||
|
<td>
|
||||||
|
{#if p.is_creator}
|
||||||
|
🗝 Creator
|
||||||
|
{:else if p.is_admin}
|
||||||
|
<button on:click={() => setAdmin(p.id, false)} class="btn btn-secondary btn-small">Revoke Admin</button>
|
||||||
|
{:else}
|
||||||
|
<button on:click={() => setAdmin(p.id, true)} class="btn btn-secondary btn-small">Grant Admin</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="link-copy-action">
|
||||||
|
<a href={rejoinLink(p.id)} class="btn btn-secondary btn-small">Open</a>
|
||||||
|
<button on:click={() => copyRejoinLink(p.id)} class="btn btn-secondary btn-small">
|
||||||
|
{copiedPlayerId === p.id ? '✓ Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if canKick(p)}
|
||||||
|
<button on:click={() => kickPlayer(p)} class="btn btn-danger btn-small">Kick</button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<a href={backLink} class="btn btn-secondary">{backLabel}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
284
frontend/src/pages/Dashboard.svelte
Normal file
284
frontend/src/pages/Dashboard.svelte
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { push } from 'svelte-spa-router';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { extraMenuLinks } from '../lib/menu';
|
||||||
|
import { getSuggestion } from '../lib/suggestions';
|
||||||
|
import { displayName } from '../lib/cards';
|
||||||
|
import { setPageTitle } from '../lib/title';
|
||||||
|
import { saveSession, removeSession } from '../lib/sessions';
|
||||||
|
|
||||||
|
import LobbyPhase from '../components/LobbyPhase.svelte';
|
||||||
|
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
|
||||||
|
import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
|
||||||
|
import ScenePhase from '../components/ScenePhase.svelte';
|
||||||
|
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
||||||
|
import RecruitPhase from '../components/RecruitPhase.svelte';
|
||||||
|
import GameOverPhase from '../components/GameOverPhase.svelte';
|
||||||
|
import CrewSidebar from '../components/CrewSidebar.svelte';
|
||||||
|
import EventLog from '../components/EventLog.svelte';
|
||||||
|
import NameModal from '../components/NameModal.svelte';
|
||||||
|
import GatModal from '../components/GatModal.svelte';
|
||||||
|
import RankBonusModal from '../components/RankBonusModal.svelte';
|
||||||
|
|
||||||
|
export let params = {};
|
||||||
|
let gameId = params.id;
|
||||||
|
let playerId = params.pid;
|
||||||
|
|
||||||
|
let state = null;
|
||||||
|
let error = '';
|
||||||
|
let intervalId;
|
||||||
|
let ws = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let reconnectDelay = 1000;
|
||||||
|
let destroyed = false;
|
||||||
|
let staleSession = false;
|
||||||
|
// Non-scene phases use the same pinned Event Log column and fixed corner
|
||||||
|
// toggle that ScenePhase owns for the main play screen.
|
||||||
|
let phaseLogOpen = true;
|
||||||
|
|
||||||
|
function discardStaleSession(message) {
|
||||||
|
staleSession = true;
|
||||||
|
removeSession(gameId, playerId);
|
||||||
|
state = null;
|
||||||
|
error = message;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
if (ws) ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchState() {
|
||||||
|
if (staleSession) return;
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
|
||||||
|
// Preserve the Game Over screen for players who were already present
|
||||||
|
// when play ended, but discard stale home-screen rejoin attempts.
|
||||||
|
if (!state && data.game.phase === 'ended') {
|
||||||
|
discardStaleSession('This game has ended and can no longer be rejoined.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = data;
|
||||||
|
error = '';
|
||||||
|
} catch (err) {
|
||||||
|
if (!state && err.status === 404) {
|
||||||
|
discardStaleSession('This saved game can no longer be rejoined. You may have been removed from the crew.');
|
||||||
|
} else {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server pushes a ping over this socket whenever the game changes;
|
||||||
|
// each ping triggers a refetch of the state blob.
|
||||||
|
function connectSocket() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
ws = new WebSocket(`${proto}://${location.host}/api/game/${gameId}/ws`);
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectDelay = 1000;
|
||||||
|
fetchState(); // catch up on anything missed while disconnected
|
||||||
|
};
|
||||||
|
ws.onmessage = () => fetchState();
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (destroyed || staleSession) return;
|
||||||
|
reconnectTimer = setTimeout(connectSocket, reconnectDelay);
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchState();
|
||||||
|
connectSocket();
|
||||||
|
// Slow fallback poll in case a push is ever missed
|
||||||
|
intervalId = setInterval(fetchState, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Creator-only corner menu entries: Dev Mode toggle (all phases), the
|
||||||
|
// Skip Character Creation shortcut (Dev Mode only), and the Admin link.
|
||||||
|
async function toggleDevMode() {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${gameId}/player/${playerId}/dev-mode`, 'POST', {
|
||||||
|
enabled: !state.game.dev_mode
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipCharacterCreation() {
|
||||||
|
// Suggested values for everyone's free-text fields; the backend only
|
||||||
|
// applies them to fields players haven't filled in themselves.
|
||||||
|
const fills = {};
|
||||||
|
for (const p of state.players) {
|
||||||
|
fills[p.id] = {
|
||||||
|
avatar_look: getSuggestion('look'),
|
||||||
|
avatar_smell: getSuggestion('smell'),
|
||||||
|
first_words: getSuggestion('first_words'),
|
||||||
|
good_at_math: getSuggestion('good_at_math'),
|
||||||
|
like: getSuggestion('like'),
|
||||||
|
hate: getSuggestion('hate'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${gameId}/player/${playerId}/skip-character-creation`, 'POST', {
|
||||||
|
fills: JSON.stringify(fills)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin escape hatch: hand out every technique randomly and auto-assign J/Q/K
|
||||||
|
async function autoAssignTechniques() {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${gameId}/player/${playerId}/auto-assign-techniques`, 'POST');
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voluntarily remove yourself from the game (cards go to the discard)
|
||||||
|
async function leaveGame() {
|
||||||
|
if (!confirm("Leave this game? Your cards go to the discard and you'll need a new invite to rejoin. This can't be undone.")) return;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${gameId}/player/${playerId}/leave`, 'POST');
|
||||||
|
removeSession(gameId, playerId);
|
||||||
|
push('/');
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinguish tabs/history by the Pi-Rat (and crew) this dashboard is for
|
||||||
|
$: setPageTitle(state ? displayName(state.player) : null, state?.game?.crew_name);
|
||||||
|
|
||||||
|
// Keep this browser's saved session up to date so the home page can rejoin
|
||||||
|
// with current crew/rat names (the rat name changes when a Name is earned)
|
||||||
|
$: if (state?.game && state?.player) {
|
||||||
|
saveSession({
|
||||||
|
gameId,
|
||||||
|
playerId,
|
||||||
|
crewName: state.game.crew_name,
|
||||||
|
playerName: state.player.player_name,
|
||||||
|
ratName: state.player.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins get the admin key in their state blob; stash it so the Admin page works for them too
|
||||||
|
$: if (state?.player?.is_admin && state?.game?.admin_key) {
|
||||||
|
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
const items = [];
|
||||||
|
if (state?.player?.is_admin) {
|
||||||
|
items.push({
|
||||||
|
label: `🧪 Dev Mode: ${state.game.dev_mode ? 'On' : 'Off'}`,
|
||||||
|
action: toggleDevMode,
|
||||||
|
title: 'Toggle Dev Mode testing shortcuts for the whole table',
|
||||||
|
});
|
||||||
|
if (state.game.dev_mode && ['character_creation', 'swap_techniques', 'assign_techniques'].includes(state.game.phase)) {
|
||||||
|
items.push({
|
||||||
|
label: '⏭️ Skip Character Creation',
|
||||||
|
action: skipCharacterCreation,
|
||||||
|
title: 'Auto-fill anything unfinished for all players and jump to scene setup',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques') {
|
||||||
|
items.push({
|
||||||
|
label: '🎲 Auto-Assign Techniques',
|
||||||
|
action: autoAssignTechniques,
|
||||||
|
title: 'Randomly distribute all techniques to eligible players and assign them to J/Q/K',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push({ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' });
|
||||||
|
}
|
||||||
|
if (state?.player) {
|
||||||
|
items.push({
|
||||||
|
label: '🚪 Leave Game',
|
||||||
|
action: leaveGame,
|
||||||
|
title: 'Remove yourself from this game (your cards go to the discard)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
extraMenuLinks.set(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
destroyed = true;
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
if (ws) ws.close();
|
||||||
|
extraMenuLinks.set([]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger" style="margin: 20px;">
|
||||||
|
Error connecting to game: {error}
|
||||||
|
{#if staleSession}
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="#/" class="btn btn-secondary btn-small">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if state}
|
||||||
|
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
||||||
|
{#if state.game.phase === 'scene'}
|
||||||
|
<ScenePhase {state} />
|
||||||
|
{:else}
|
||||||
|
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
|
||||||
|
<CrewSidebar {state} />
|
||||||
|
<main class="phase-main-column">
|
||||||
|
{#if state.game.phase === 'lobby'}
|
||||||
|
<LobbyPhase {state} />
|
||||||
|
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
|
||||||
|
<CharacterCreationPhase {state} />
|
||||||
|
{:else if state.game.phase === 'scene_setup'}
|
||||||
|
<SceneSetupPhase {state} />
|
||||||
|
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
||||||
|
<UpkeepPhase {state} />
|
||||||
|
{:else if state.game.phase === 'recruit_creation'}
|
||||||
|
<RecruitPhase {state} />
|
||||||
|
{:else if state.game.phase === 'ended'}
|
||||||
|
<GameOverPhase {state} />
|
||||||
|
{:else}
|
||||||
|
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
{#if phaseLogOpen}
|
||||||
|
<div class="log-column">
|
||||||
|
<EventLog {state} inline on:collapse={() => (phaseLogOpen = false)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if state.game.phase !== 'scene'}
|
||||||
|
<button
|
||||||
|
class="log-reopen-btn"
|
||||||
|
title={phaseLogOpen ? 'Hide the event log' : 'Show the event log'}
|
||||||
|
on:click={() => (phaseLogOpen = !phaseLogOpen)}
|
||||||
|
>
|
||||||
|
{phaseLogOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<NameModal {state} />
|
||||||
|
<GatModal {state} />
|
||||||
|
<RankBonusModal {state} />
|
||||||
|
{:else if !error}
|
||||||
|
<div class="loading-container">
|
||||||
|
<p>Loading game state...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
245
frontend/src/pages/Home.svelte
Normal file
245
frontend/src/pages/Home.svelte
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { push } from 'svelte-spa-router';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { setPageTitle } from '../lib/title';
|
||||||
|
import { getSessions, saveSession, removeSession } from '../lib/sessions';
|
||||||
|
|
||||||
|
let sessions = [];
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setPageTitle();
|
||||||
|
sessions = getSessions();
|
||||||
|
});
|
||||||
|
|
||||||
|
let crewName = '';
|
||||||
|
let error = '';
|
||||||
|
let creating = false;
|
||||||
|
let joinCode = '';
|
||||||
|
let joinCodeError = '';
|
||||||
|
let joiningByCode = false;
|
||||||
|
|
||||||
|
function sessionRat(s) {
|
||||||
|
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
|
||||||
|
return s.ratName || s.playerName || 'Unknown rat';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete is a soft, undoable removal: drop it from localStorage right away
|
||||||
|
// (so a refresh finalizes it) but keep the greyed row visible with an Undo.
|
||||||
|
function deleteSession(s) {
|
||||||
|
removeSession(s.gameId, s.playerId);
|
||||||
|
s.deleted = true;
|
||||||
|
sessions = sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoDelete(s) {
|
||||||
|
saveSession({
|
||||||
|
gameId: s.gameId, playerId: s.playerId,
|
||||||
|
crewName: s.crewName, playerName: s.playerName, ratName: s.ratName,
|
||||||
|
});
|
||||||
|
s.deleted = false;
|
||||||
|
sessions = sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGame() {
|
||||||
|
if (!crewName.trim()) return;
|
||||||
|
creating = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/game', 'POST', { crew_name: crewName });
|
||||||
|
if (data.admin_key) {
|
||||||
|
localStorage.setItem(`admin_key_${data.id}`, data.admin_key);
|
||||||
|
}
|
||||||
|
push(`/game/${data.id}/join?creator=true`);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJoinCode(event) {
|
||||||
|
joinCode = event.currentTarget.value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, '')
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinByCode() {
|
||||||
|
if (joinCode.length !== 5) return;
|
||||||
|
joiningByCode = true;
|
||||||
|
joinCodeError = '';
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/game/code/${joinCode}`);
|
||||||
|
push(`/game/${data.id}/join`);
|
||||||
|
} catch (err) {
|
||||||
|
joinCodeError = err.message;
|
||||||
|
joiningByCode = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="welcome-container">
|
||||||
|
<header class="welcome-hero">
|
||||||
|
<p class="hero-overline">A Remote Play Companion for</p>
|
||||||
|
<h1 class="wordmark">Rats <span class="amp">with</span> Gats</h1>
|
||||||
|
<p class="subtitle">Stowaway rats, transformed by math magic, out to steal a ship and commit some piracy.</p>
|
||||||
|
<div class="hero-flourish" aria-hidden="true">🐀 ⚓ 🐀</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if sessions.length > 0}
|
||||||
|
<div class="glass-panel creation-card saved-sessions">
|
||||||
|
<h2>Rejoin a Crew</h2>
|
||||||
|
<p class="info-text" style="margin-bottom: 1rem;">Games you've joined from this browser. Removing one only forgets it here — it stays in the game.</p>
|
||||||
|
<ul class="session-list">
|
||||||
|
{#each sessions as s (s.gameId + s.playerId)}
|
||||||
|
<li class="session-row {s.deleted ? 'is-deleted' : ''}">
|
||||||
|
<div class="session-info">
|
||||||
|
<span class="session-crew">{s.crewName || 'Unnamed Crew'}</span>
|
||||||
|
<span class="session-rat text-muted">{sessionRat(s)}</span>
|
||||||
|
</div>
|
||||||
|
{#if s.deleted}
|
||||||
|
<div class="session-actions">
|
||||||
|
<span class="text-muted italic">Removed</span>
|
||||||
|
<button class="btn btn-secondary btn-small" on:click={() => undoDelete(s)}>Undo</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="session-actions">
|
||||||
|
<a href="#/game/{s.gameId}/player/{s.playerId}" class="btn btn-primary btn-small">Rejoin</a>
|
||||||
|
<button class="btn btn-secondary btn-small" title="Forget this session" on:click={() => deleteSession(s)}>✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="glass-panel creation-card join-code-card">
|
||||||
|
<h2>Join a Crew</h2>
|
||||||
|
<p class="info-text">Enter the five-character code shared by your game’s Admin.</p>
|
||||||
|
<form class="join-code-form" on:submit|preventDefault={joinByCode}>
|
||||||
|
<label for="joinCode">Join Code</label>
|
||||||
|
<div class="join-code-action">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="joinCode"
|
||||||
|
value={joinCode}
|
||||||
|
on:input={updateJoinCode}
|
||||||
|
required
|
||||||
|
minlength="5"
|
||||||
|
maxlength="5"
|
||||||
|
placeholder="ABCDE"
|
||||||
|
class="input-field input-large join-code-input"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="characters"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary btn-large" disabled={joiningByCode || joinCode.length !== 5}>
|
||||||
|
{joiningByCode ? 'Joining...' : 'Join'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if joinCodeError}
|
||||||
|
<div class="alert alert-danger mt-4">{joinCodeError}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-panel creation-card">
|
||||||
|
<h2>Muster a New Crew</h2>
|
||||||
|
<form on:submit|preventDefault={createGame}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="crewName">Crew Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="crewName"
|
||||||
|
bind:value={crewName}
|
||||||
|
required
|
||||||
|
placeholder="e.g. The Salty Dogs"
|
||||||
|
class="input-field input-large"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
|
||||||
|
{creating ? 'Creating...' : 'Create Game'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="welcome-links">
|
||||||
|
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Space the Rejoin box off the Muster box below it. */
|
||||||
|
.saved-sessions {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
.join-code-card {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
.join-code-form {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.join-code-action {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.join-code-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.session-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.session-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px solid var(--edge-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.session-row.is-deleted {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.session-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.session-crew {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.session-rat {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
82
frontend/src/pages/Join.svelte
Normal file
82
frontend/src/pages/Join.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { push } from 'svelte-spa-router';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { setPageTitle } from '../lib/title';
|
||||||
|
import { saveSession } from '../lib/sessions';
|
||||||
|
export let params = {};
|
||||||
|
|
||||||
|
const PLAYER_NAME_KEY = 'pirats-player-name';
|
||||||
|
|
||||||
|
let gameId = params.id;
|
||||||
|
let playerName = '';
|
||||||
|
let error = '';
|
||||||
|
let joining = false;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setPageTitle('Join the Crew');
|
||||||
|
playerName = localStorage.getItem(PLAYER_NAME_KEY) || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// We can extract ?creator=true from $querystring if needed,
|
||||||
|
// though the backend determines creator based on if they are the first player.
|
||||||
|
// For UI purposes, we could show "You are the creator" if we want.
|
||||||
|
|
||||||
|
async function joinGame() {
|
||||||
|
if (!playerName.trim()) return;
|
||||||
|
joining = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
||||||
|
localStorage.setItem(PLAYER_NAME_KEY, playerName.trim());
|
||||||
|
// Remember this session so the home page can offer a rejoin link;
|
||||||
|
// the dashboard enriches it with crew/rat names once state loads.
|
||||||
|
saveSession({ gameId, playerId: data.id, playerName });
|
||||||
|
push(`/game/${gameId}/player/${data.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
joining = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="welcome-container">
|
||||||
|
<header class="welcome-hero">
|
||||||
|
<p class="hero-overline">Rats with Gats</p>
|
||||||
|
<h1 class="wordmark">Join the Crew</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="glass-panel creation-card">
|
||||||
|
<h2>Enter your name</h2>
|
||||||
|
<p class="info-text" style="margin-bottom: 1rem;">
|
||||||
|
This is <strong>your</strong> name as a player, not your Pi-Rat's. Your Pi-Rat will be known
|
||||||
|
by their smell (e.g. "Recruit Gunpowder") until they earn a proper Name in play.
|
||||||
|
</p>
|
||||||
|
<form on:submit|preventDefault={joinGame}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="playerName">Your Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="playerName"
|
||||||
|
bind:value={playerName}
|
||||||
|
required
|
||||||
|
class="input-field input-large"
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-large btn-full mt-4" disabled={joining}>
|
||||||
|
{joining ? 'Joining...' : 'Join Game'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="welcome-links">
|
||||||
|
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
2
frontend/svelte.config.js
Normal file
2
frontend/svelte.config.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||||
|
export default {}
|
||||||
20
frontend/vite.config.js
Normal file
20
frontend/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/rules': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -9,10 +9,11 @@ description = "Rats with Gats Remote Play web application"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"alembic",
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"sqlmodel",
|
"sqlmodel",
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
"jinja2",
|
"websockets",
|
||||||
"python-multipart",
|
"python-multipart",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ pirats = "pirats.main:main"
|
|||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
pirats = ["static/**/*", "templates/**/*.html", "templates/*.html"]
|
pirats = ["static/**/*", "rules.html", "migrations/**/*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import random
|
import random
|
||||||
from typing import Dict, Any, List, Tuple
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
SUITS = {
|
SUITS = {
|
||||||
"C": {"symbol": "♣", "name": "Clubs", "color": "black", "narration": "Shootin' and Stabbin' (violence/combat)"},
|
"C": {"symbol": "♣", "name": "Clubs", "color": "black", "narration": "Shootin' and Stabbin' (violence/combat)"},
|
||||||
@@ -74,28 +74,209 @@ OBSTACLES_DATA = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 20 funny Secret Pirate Technique suggestions for when players need ideas
|
# Secret Pirate Technique suggestions: served to the frontend's Suggest buttons
|
||||||
|
# via GET /api/suggestions/techniques and used to equip re-rolled recruits.
|
||||||
TECHNIQUE_SUGGESTIONS = [
|
TECHNIQUE_SUGGESTIONS = [
|
||||||
"I missed on purpose",
|
"I meant to do that",
|
||||||
"Carlos will figure it out",
|
"The floor was always a trap",
|
||||||
"Never trust a hatless captain",
|
"Carlos owes me one",
|
||||||
"I'm not left handed",
|
"Barry already disarmed it",
|
||||||
"The Albatross is always watching",
|
"Jaspar took the blame",
|
||||||
"I've got a bomb",
|
"Greg always comes back",
|
||||||
"Divide by Pi",
|
"We left Greg on purpose",
|
||||||
"Cheese-scented smoke screen",
|
"The seagulls work for me",
|
||||||
"Look behind you, a three-headed parrot!",
|
"My whiskers told me so",
|
||||||
"Parabolic boarding leap",
|
"I was behind you the whole time",
|
||||||
"The Pocket Cannon",
|
"I am wearing two eyepatches",
|
||||||
"Aggressive Math-whining",
|
"Nobody suspects the small one",
|
||||||
"Unspeakable Tail Swish",
|
"I licked it, so it's mine",
|
||||||
"Bandana Whip",
|
"The grog made me do it",
|
||||||
"Double-barrelled cheese blaster",
|
"I know a guy",
|
||||||
"Mathematical Scurvy cure",
|
"The guy knows another guy",
|
||||||
"I was hiding under the carpet",
|
"I greased the entire ship",
|
||||||
"Batten down my feelings",
|
"I hid the cannonball in my cheeks",
|
||||||
"Wait, is that cheese?",
|
"I taught the parrot everything it knows",
|
||||||
"Sudden pirate flip"
|
"Hold my grog",
|
||||||
|
"Watch this",
|
||||||
|
"I counted to Pi",
|
||||||
|
"Pi is exactly three when I'm in a hurry",
|
||||||
|
"I cast Pie, again",
|
||||||
|
"I memorized the wrong map on purpose",
|
||||||
|
"I sneezed at exactly the right moment",
|
||||||
|
"I read about this in a book I ate",
|
||||||
|
"I learned this from a ghost",
|
||||||
|
"The barrel was empty on purpose",
|
||||||
|
"This is my emotional support cannon",
|
||||||
|
"That wasn't even my final form",
|
||||||
|
"I've been holding my breath since Tuesday",
|
||||||
|
"It's a rental",
|
||||||
|
"The ship likes me better",
|
||||||
|
"My cousin is a cannonball",
|
||||||
|
"My scars spell out a map",
|
||||||
|
"My other gat is a crossbow",
|
||||||
|
"I prepared this speech in advance",
|
||||||
|
"I read the rulebook upside down",
|
||||||
|
"Technically, that's legal",
|
||||||
|
"Objection!",
|
||||||
|
"It's called a heist now",
|
||||||
|
"You wouldn't hit a rat with glasses",
|
||||||
|
"These aren't even my real glasses",
|
||||||
|
"I have the high ground",
|
||||||
|
"You activated my trap card",
|
||||||
|
"It was me all along",
|
||||||
|
"Behold, a distraction",
|
||||||
|
"We meet again, stairs",
|
||||||
|
"I've seen this in a dream",
|
||||||
|
"The prophecy said nothing about Tuesdays",
|
||||||
|
"Catch!",
|
||||||
|
"Think fast",
|
||||||
|
"Look behind you",
|
||||||
|
"No, seriously, look behind you",
|
||||||
|
"This one goes to eleven",
|
||||||
|
"Critical squeak",
|
||||||
|
"Nothing up my sleeves but more sleeves",
|
||||||
|
"The anchor was a decoy",
|
||||||
|
"Six rats in a coat",
|
||||||
|
"The Deep blinked first",
|
||||||
|
"The Squid owed me a favor",
|
||||||
|
"The Albatross vouches for me",
|
||||||
|
"The Mermaid pinky-swore",
|
||||||
|
"It worked in the bathtub",
|
||||||
|
"We rehearsed this exactly once",
|
||||||
|
"The smell did the negotiating",
|
||||||
|
"Carry me, I'm dramatic",
|
||||||
|
"It's only stealing if you get caught",
|
||||||
|
"Borrowed permanently",
|
||||||
|
"Never trust a dry deck",
|
||||||
|
"Never trust a barefoot vampire",
|
||||||
|
"Never duel before lunch",
|
||||||
|
"Never sing the second verse",
|
||||||
|
"Never play cards with a Goblin",
|
||||||
|
"Never wake a sleeping Bean Whale",
|
||||||
|
"Never bite the hand that feeds you cheese",
|
||||||
|
"Never bring a sword to a math fight",
|
||||||
|
"A rat always has an exit",
|
||||||
|
"Two exits, actually",
|
||||||
|
"A wet rat fears no rain",
|
||||||
|
"A captain never looks up",
|
||||||
|
"A good plan smells like cheese",
|
||||||
|
"The loudest rat is rarely the problem",
|
||||||
|
"Dead men file no complaints",
|
||||||
|
"The best sword is a longer sword",
|
||||||
|
"If you can't tie a knot, tie a lot",
|
||||||
|
"When in doubt, scream and leap",
|
||||||
|
"Every storm is someone's fault",
|
||||||
|
"All maps lead to treasure if you're stubborn enough",
|
||||||
|
"The sea forgives nothing but forgets everything",
|
||||||
|
"Steal the boat first, the hearts will follow",
|
||||||
|
"You miss every shot you don't blame on the wind",
|
||||||
|
"There's no such thing as too much rope",
|
||||||
|
"It is always squid o'clock somewhere",
|
||||||
|
"Fortune favors the furry",
|
||||||
|
"Don't count your doubloons until they're stolen",
|
||||||
|
"Cheese before glory",
|
||||||
|
"Glory before breakfast",
|
||||||
|
"Gravity is a suggestion",
|
||||||
|
"Rule one: there are no rules",
|
||||||
|
"Rule two: see rule one",
|
||||||
|
"The knot unties itself if you believe",
|
||||||
|
"Smile until they get nervous",
|
||||||
|
"Just keep nodding",
|
||||||
|
"Pretend it's Tuesday",
|
||||||
|
"Speak softly and carry a loud gat",
|
||||||
|
"Improvise, adapt, overboard",
|
||||||
|
"Cry first, ask questions later",
|
||||||
|
"The tide pays its debts",
|
||||||
|
"Apologize mid-swing",
|
||||||
|
"Bite first, monologue later",
|
||||||
|
"Lose dramatically, win quietly",
|
||||||
|
"Fight like the ghosts are watching",
|
||||||
|
"Trip over the right thing",
|
||||||
|
"Be the cannonball",
|
||||||
|
"Always fall on the soft pirate",
|
||||||
|
"Flee now, gloat later",
|
||||||
|
"Dramatic timing is a weapon",
|
||||||
|
"Cheese knows the way home",
|
||||||
|
"Trust the mustache",
|
||||||
|
"The plank is a state of mind",
|
||||||
|
"Every plank is a trapdoor if you stomp hard enough",
|
||||||
|
"The pointy end goes in the other guy",
|
||||||
|
"An apology and a gat beats an apology",
|
||||||
|
"It's not a bribe, it's a gift",
|
||||||
|
"The fourth wall is load-bearing",
|
||||||
|
"Vampires can't resist counting spilled rice",
|
||||||
|
"Distract them with interpretive dance",
|
||||||
|
"Sing the shanty of unreasonable confidence",
|
||||||
|
"The Cheddar Gambit",
|
||||||
|
"The Gouda Guillotine",
|
||||||
|
"The Brie Breach",
|
||||||
|
"The Camembert Cannonade",
|
||||||
|
"The Reverse Plank Walk",
|
||||||
|
"The Drunken Compass Defense",
|
||||||
|
"The Whisker Feint",
|
||||||
|
"The Triple Barrel Roll",
|
||||||
|
"The Stowaway Shuffle",
|
||||||
|
"The Bilge Rat Backflip",
|
||||||
|
"The Captain's Distraction Waltz",
|
||||||
|
"The Forbidden Knot",
|
||||||
|
"The Unforgivable Cannonball",
|
||||||
|
"The Polite Mutiny",
|
||||||
|
"The Reverse Mutiny",
|
||||||
|
"The Squeak of a Thousand Regrets",
|
||||||
|
"The Tail-First Retreat",
|
||||||
|
"The Upside-Down Boarding Party",
|
||||||
|
"The Invisible Rope Trick",
|
||||||
|
"The Two-Hat Bamboozle",
|
||||||
|
"The Ghost Pepper Defense",
|
||||||
|
"The Barnacle Embrace",
|
||||||
|
"The Crow's Nest Cannonball",
|
||||||
|
"The Rigging Tango",
|
||||||
|
"The Anchor Yo-Yo",
|
||||||
|
"The Powder Keg Lullaby",
|
||||||
|
"The Mermaid's IOU",
|
||||||
|
"The Goblin Refund Policy",
|
||||||
|
"The Fifth Ace",
|
||||||
|
"The Sixth Ace",
|
||||||
|
"The Emergency Wedding",
|
||||||
|
"The Slow-Motion Walk Away",
|
||||||
|
"The Borrowed Thunder",
|
||||||
|
"The Cheese Wheel of Fortune",
|
||||||
|
"The Last Rat Standing",
|
||||||
|
"The First Rat Running",
|
||||||
|
"The Old Switcheroo",
|
||||||
|
"The Old Double Switcheroo",
|
||||||
|
"The Long Con (Short Version)",
|
||||||
|
"The Accidental Masterpiece",
|
||||||
|
"The Sympathy Limp",
|
||||||
|
"The Decoy Funeral",
|
||||||
|
"The Surprise Encore",
|
||||||
|
"The Overly Specific Alibi",
|
||||||
|
"The Group Hug Ambush",
|
||||||
|
"The Cannonball Curveball",
|
||||||
|
"The Soup of Truth",
|
||||||
|
"The Hat Trick (With Actual Hats)",
|
||||||
|
"The Thousand-Yard Squint",
|
||||||
|
"The Completely Legal Salvage",
|
||||||
|
"Carry the one",
|
||||||
|
"Round down, shoot up",
|
||||||
|
"Divide and run away",
|
||||||
|
"Subtract yourself from the situation",
|
||||||
|
"Multiply by chaos",
|
||||||
|
"Add insult to injury",
|
||||||
|
"The remainder goes in my pocket",
|
||||||
|
"Long division, short fuse",
|
||||||
|
"Triangulate, then detonate",
|
||||||
|
"Roll for cheese",
|
||||||
|
"Yo ho ho and a barrel of math",
|
||||||
|
"Ask the cook, he's seen worse",
|
||||||
|
"Barry has a boat for this",
|
||||||
|
"Tell Jaspar I said hello",
|
||||||
|
"The first mate signs my alibis",
|
||||||
|
"Piss Whiskers takes the watch",
|
||||||
|
"We voted, you lost",
|
||||||
|
"The cheese was a lie",
|
||||||
|
"One more verse won't hurt",
|
||||||
|
"Abandon ship, but stylishly"
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_fresh_deck() -> List[str]:
|
def get_fresh_deck() -> List[str]:
|
||||||
|
|||||||
@@ -1,811 +1,7 @@
|
|||||||
import json
|
# Facade for backward compatibility and centralized access
|
||||||
import random
|
from .crud_base import *
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from .crud_character import *
|
||||||
from sqlmodel import Session, select
|
from .crud_scene import *
|
||||||
from .models import Game, Player, Obstacle, Challenge, Vote
|
from .crud_challenge import *
|
||||||
from . import cards
|
from .crud_upkeep import *
|
||||||
|
from .crud_rollback import *
|
||||||
# --- 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()
|
|
||||||
|
|||||||
347
src/pirats/crud_base.py
Normal file
347
src/pirats/crud_base.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from .models import Game, Player, GameEvent, new_join_code
|
||||||
|
from . import cards
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FACE_VALUES = ("J", "Q", "K")
|
||||||
|
|
||||||
|
# --- 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 technique_for_face_card(player: Player, value: str) -> str:
|
||||||
|
"""The Secret Pirate Technique a player has assigned to a face card value (J/Q/K)."""
|
||||||
|
return {
|
||||||
|
"J": player.tech_jack or "Jack Secret Technique",
|
||||||
|
"Q": player.tech_queen or "Queen Secret Technique",
|
||||||
|
"K": player.tech_king or "King Secret Technique",
|
||||||
|
}[value]
|
||||||
|
|
||||||
|
def evaluate_card_play(
|
||||||
|
player: Player,
|
||||||
|
card_code: str,
|
||||||
|
obstacle_card_code: str,
|
||||||
|
obstacle_value: int,
|
||||||
|
acting_rank: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Shared Challenge resolution rules for one (non-Joker) card played by a Pi-Rat
|
||||||
|
(callers must have already rejected Jokers and Deep players):
|
||||||
|
- J/Q/K triggers the player's assigned Secret Pirate Technique: automatic success.
|
||||||
|
- A face card acting as the Obstacle is worth the challenged Pi-Rat's Rank
|
||||||
|
(acting_rank); any other Obstacle card is worth obstacle_value.
|
||||||
|
- Gat privilege: Black cards +1. Name privilege: Red cards +1.
|
||||||
|
Returns {"success", "details", "is_technique", "tech_name"}.
|
||||||
|
"""
|
||||||
|
played = cards.parse_card(card_code)
|
||||||
|
|
||||||
|
if played["value"] in FACE_VALUES:
|
||||||
|
tech_name = technique_for_face_card(player, played["value"])
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"details": f"Used Secret Pirate Technique: '{tech_name}'!",
|
||||||
|
"is_technique": True,
|
||||||
|
"tech_name": tech_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cards.parse_card(obstacle_card_code)["value"] in FACE_VALUES:
|
||||||
|
target_value = acting_rank
|
||||||
|
obs_detail = f"Rank {acting_rank} (Face Card Obstacle)"
|
||||||
|
else:
|
||||||
|
target_value = obstacle_value
|
||||||
|
obs_detail = str(target_value)
|
||||||
|
|
||||||
|
privilege_bonus = 0
|
||||||
|
if player.completed_personal_1 and played["suit"] in ("C", "S"):
|
||||||
|
privilege_bonus += 1
|
||||||
|
if player.completed_personal_2 and played["suit"] in ("H", "D"):
|
||||||
|
privilege_bonus += 1
|
||||||
|
final_value = played["numeric_value"] + privilege_bonus
|
||||||
|
|
||||||
|
success = final_value > target_value
|
||||||
|
outcome = "Success" if success else "Failure"
|
||||||
|
return {
|
||||||
|
"success": success,
|
||||||
|
"details": f"{outcome}! Played {played['display']} ({final_value}) vs Obstacle value {obs_detail}.",
|
||||||
|
"is_technique": False,
|
||||||
|
"tech_name": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def calculate_max_hand_size(player: Player, players_in_scene: List[Player], captain_id: Optional[str] = None) -> int:
|
||||||
|
"""
|
||||||
|
Calculates a player's maximum hand size based on their Rank relative to others,
|
||||||
|
plus Captain privileges (+1, regardless of Rank).
|
||||||
|
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
|
||||||
|
If everyone shares a Rank, they are all 'highest' and get 4 cards.
|
||||||
|
Pi-Rats are ranked against the Pi-Rats in the scene; Deep players (whose hand size
|
||||||
|
only matters for the between-scenes redraw) are ranked against all players.
|
||||||
|
"""
|
||||||
|
if player.role == "deep":
|
||||||
|
pool = players_in_scene
|
||||||
|
else:
|
||||||
|
pool = [p for p in players_in_scene if p.role != "deep"]
|
||||||
|
if not pool:
|
||||||
|
pool = [player]
|
||||||
|
|
||||||
|
ranks = [p.rank for p in pool]
|
||||||
|
if player.rank >= max(ranks):
|
||||||
|
base_size = 4
|
||||||
|
elif player.rank <= min(ranks):
|
||||||
|
base_size = 2
|
||||||
|
else:
|
||||||
|
base_size = 3
|
||||||
|
|
||||||
|
if captain_id is not None and player.id == captain_id:
|
||||||
|
return base_size + 1
|
||||||
|
return base_size
|
||||||
|
|
||||||
|
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict:
|
||||||
|
"""Snapshot every player's max hand size, for detecting increases after a Rank/Captain change."""
|
||||||
|
return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
|
||||||
|
|
||||||
|
def apply_hand_increases(db: Session, game: Game, old_maxes: dict):
|
||||||
|
"""
|
||||||
|
'Anytime your Hand size increases, you immediately draw a card.'
|
||||||
|
Compares current max hand sizes against a snapshot and draws the difference
|
||||||
|
for any player whose max increased. (Decreases never force a discard.)
|
||||||
|
"""
|
||||||
|
for p in game.players:
|
||||||
|
new_max = calculate_max_hand_size(p, game.players, game.captain_player_id)
|
||||||
|
old_max = old_maxes.get(p.id, new_max)
|
||||||
|
if new_max > old_max:
|
||||||
|
draw_cards_for_player(db, game, p, new_max - old_max)
|
||||||
|
|
||||||
|
def change_player_rank(db: Session, game: Game, player: Player, delta: int):
|
||||||
|
"""Changes a player's Rank and grants immediate draws for any resulting hand size increases."""
|
||||||
|
old_maxes = capture_hand_maxes(game.players, game.captain_player_id)
|
||||||
|
player.rank = max(1, player.rank + delta)
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
apply_hand_increases(db, game, old_maxes)
|
||||||
|
|
||||||
|
def is_eligible_nominee(voter, nominee) -> bool:
|
||||||
|
"""A rank-up nominee must be another living, in-play Pi-Rat. Excludes the
|
||||||
|
voter, previous-scene Deep players, and dead / awaiting-recruit players
|
||||||
|
(ranking them up is wasted — recruits reset to Rank 1). Shared by the
|
||||||
|
between-scenes rank vote and the 3rd-objective story bonus."""
|
||||||
|
return (
|
||||||
|
nominee.id != voter.id
|
||||||
|
and nominee.role != "deep"
|
||||||
|
and not nominee.is_dead
|
||||||
|
and not nominee.needs_reroll
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""):
|
||||||
|
"""Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
|
||||||
|
if game.captain_player_id == player_id:
|
||||||
|
return
|
||||||
|
old_maxes = capture_hand_maxes(game.players, game.captain_player_id)
|
||||||
|
game.captain_player_id = player_id
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
apply_hand_increases(db, game, old_maxes)
|
||||||
|
if player_id:
|
||||||
|
new_captain = db.get(Player, player_id)
|
||||||
|
if new_captain:
|
||||||
|
msg = f"{new_captain.name} is now the Captain!"
|
||||||
|
if reason:
|
||||||
|
msg += f" ({reason})"
|
||||||
|
add_game_event(db, game.id, msg, kind="captain")
|
||||||
|
elif reason:
|
||||||
|
add_game_event(db, game.id, f"The Captain's position is vacant. ({reason})", kind="captain")
|
||||||
|
|
||||||
|
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
|
||||||
|
# Obstacle cards and their whole played-card columns stay on the table until the Obstacle is discarded
|
||||||
|
active_table_cards = set()
|
||||||
|
for obs in game.obstacles:
|
||||||
|
active_table_cards.add(obs.original_card)
|
||||||
|
played = json.loads(obs.played_cards)
|
||||||
|
for entry in played:
|
||||||
|
active_table_cards.add(entry["card"])
|
||||||
|
# A PvP challenger's temporary Obstacle is on the table until the duel resolves
|
||||||
|
for challenge in game.challenges:
|
||||||
|
if challenge.status == "open" and challenge.temp_card:
|
||||||
|
active_table_cards.add(challenge.temp_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 dev_mode_default() -> bool:
|
||||||
|
"""
|
||||||
|
Initial Dev Mode state for new games, from $PIRATS_DEV_MODE. Unset means a
|
||||||
|
local checkout, where defaulting on is convenient for testing; production
|
||||||
|
deployments (the Nix service) always set it, "false" by default.
|
||||||
|
"""
|
||||||
|
val = os.environ.get("PIRATS_DEV_MODE")
|
||||||
|
if val is None:
|
||||||
|
return True
|
||||||
|
return val.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
MIN_PLAYERS = 3
|
||||||
|
|
||||||
|
def has_min_players(game: Game) -> bool:
|
||||||
|
"""The crew needs at least MIN_PLAYERS to start a game or a new scene; Dev Mode bypasses this for testing."""
|
||||||
|
return game.dev_mode or len(game.players) >= MIN_PLAYERS
|
||||||
|
|
||||||
|
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
||||||
|
deck = cards.get_fresh_deck()
|
||||||
|
join_code = ""
|
||||||
|
for _ in range(100):
|
||||||
|
candidate = new_join_code()
|
||||||
|
if not db.exec(select(Game).where(Game.join_code == candidate)).first():
|
||||||
|
join_code = candidate
|
||||||
|
break
|
||||||
|
if not join_code:
|
||||||
|
raise RuntimeError("Could not allocate a unique join code")
|
||||||
|
game = Game(
|
||||||
|
join_code=join_code,
|
||||||
|
deck_cards=json.dumps(deck),
|
||||||
|
phase="lobby",
|
||||||
|
current_scene_number=1,
|
||||||
|
extra_obstacles=0,
|
||||||
|
crew_name=crew_name,
|
||||||
|
dev_mode=dev_mode_default()
|
||||||
|
)
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(game)
|
||||||
|
logger.info("Game %s created (crew_name=%r, dev_mode=%s)", game.id, crew_name, game.dev_mode)
|
||||||
|
return game
|
||||||
|
|
||||||
|
def get_game(db: Session, game_id: str) -> Optional[Game]:
|
||||||
|
return db.get(Game, game_id)
|
||||||
|
|
||||||
|
def get_game_by_join_code(db: Session, join_code: str) -> Optional[Game]:
|
||||||
|
return db.exec(select(Game).where(Game.join_code == join_code)).first()
|
||||||
|
|
||||||
|
def get_player(db: Session, player_id: str) -> Optional[Player]:
|
||||||
|
return db.get(Player, player_id)
|
||||||
|
|
||||||
|
def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player:
|
||||||
|
game = db.get(Game, game_id)
|
||||||
|
|
||||||
|
# Players who join after initial character creation create their Pi-Rat in
|
||||||
|
# the between-scenes recruit_creation phase; until then they spectate.
|
||||||
|
needs_reroll = bool(game and game.phase not in ("lobby", "character_creation"))
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
game_id=game_id,
|
||||||
|
name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
|
||||||
|
player_name=name,
|
||||||
|
is_creator=is_creator,
|
||||||
|
is_admin=is_creator, # the creator starts as an Admin; more can be granted from the admin panel
|
||||||
|
rank=2, # default starting rank (will be set properly when starting character creation)
|
||||||
|
hand_cards="[]",
|
||||||
|
is_ready=False,
|
||||||
|
role=None,
|
||||||
|
needs_reroll=needs_reroll
|
||||||
|
)
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(player)
|
||||||
|
|
||||||
|
if game:
|
||||||
|
db.refresh(game)
|
||||||
|
if game.phase == "character_creation":
|
||||||
|
# Late joiner to creation: hand out their Like/Hate questions
|
||||||
|
from .crud_character import auto_delegate_all
|
||||||
|
auto_delegate_all(db, game)
|
||||||
|
elif game.phase == "recruit_creation":
|
||||||
|
# Recruit creation is already underway; join it immediately
|
||||||
|
from .crud_character import activate_recruit
|
||||||
|
activate_recruit(db, game, player)
|
||||||
|
|
||||||
|
msg = f"{name} joined the crew!"
|
||||||
|
if needs_reroll and game and game.phase != "recruit_creation":
|
||||||
|
msg += " They'll create their Pi-Rat with the crew between scenes."
|
||||||
|
add_game_event(db, game_id, msg, kind="join")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Player %s (%r) joined game %s (phase=%s, creator=%s)",
|
||||||
|
player.id, name, game_id, game.phase if game else "?", is_creator,
|
||||||
|
)
|
||||||
|
return player
|
||||||
|
|
||||||
|
def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
|
||||||
|
event = GameEvent(game_id=game_id, message=message, kind=kind)
|
||||||
|
db.add(event)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def get_events_page(db: Session, game_id: str, before: Optional[float] = None, limit: int = 50):
|
||||||
|
"""Returns the newest `limit` events older than `before` (newest overall if None),
|
||||||
|
in ascending timestamp order, plus a flag for whether older events remain."""
|
||||||
|
query = select(GameEvent).where(GameEvent.game_id == game_id)
|
||||||
|
if before is not None:
|
||||||
|
query = query.where(GameEvent.timestamp < before)
|
||||||
|
query = query.order_by(GameEvent.timestamp.desc()).limit(limit + 1)
|
||||||
|
events = db.exec(query).all()
|
||||||
|
has_more = len(events) > limit
|
||||||
|
return list(reversed(events[:limit])), has_more
|
||||||
494
src/pirats/crud_challenge.py
Normal file
494
src/pirats/crud_challenge.py
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import json
|
||||||
|
import random
|
||||||
|
from typing import Tuple, Dict, Any, List, Optional
|
||||||
|
from sqlmodel import Session
|
||||||
|
from .models import Game, Player, Obstacle, Challenge
|
||||||
|
from . import cards
|
||||||
|
from .crud_base import (
|
||||||
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
|
draw_cards_for_player, add_game_event, change_player_rank, evaluate_card_play
|
||||||
|
)
|
||||||
|
from .crud_scene import resolve_card_against_obstacle
|
||||||
|
from .crud_character import recruit_name
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
def get_challenge(db: Session, challenge_id: str) -> Optional[Challenge]:
|
||||||
|
return db.get(Challenge, challenge_id)
|
||||||
|
|
||||||
|
def find_open_challenge_for_obstacle(game: Game, obstacle_id: str) -> Optional[Challenge]:
|
||||||
|
for challenge in game.challenges:
|
||||||
|
if challenge.status == "open" and obstacle_id in json.loads(challenge.obstacle_ids):
|
||||||
|
return challenge
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _apply_captain_tax(db: Session, game: Game, acting_player: Player):
|
||||||
|
"""Captains lose 1 Rank each time they personally fail a Challenge."""
|
||||||
|
if game.captain_player_id == acting_player.id and acting_player.rank > 1:
|
||||||
|
change_player_rank(db, game, acting_player, -1)
|
||||||
|
add_game_event(db, game.id, f"Captain Tax! {acting_player.name} failed a Challenge and drops to Rank {acting_player.rank}.", kind="rank")
|
||||||
|
|
||||||
|
# --- Deep Challenges ---
|
||||||
|
|
||||||
|
def create_challenge(
|
||||||
|
db: Session,
|
||||||
|
game_id: str,
|
||||||
|
deep_player_id: str,
|
||||||
|
target_player_id: str,
|
||||||
|
obstacle_ids: List[str],
|
||||||
|
stakes_success: str = "",
|
||||||
|
stakes_failure: str = ""
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat."""
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
deep_player = get_player(db, deep_player_id)
|
||||||
|
target = get_player(db, target_player_id)
|
||||||
|
if not game or not deep_player or not target:
|
||||||
|
return False, "Game entities not found."
|
||||||
|
if deep_player.role != "deep":
|
||||||
|
return False, "Only Deep Players can call for Challenges."
|
||||||
|
if target.role == "deep":
|
||||||
|
return False, "The Deep cannot challenge itself. Pick a Pi-Rat!"
|
||||||
|
if target.is_dead:
|
||||||
|
return False, f"{target.name} is dead. The Deep has no quarrel with the deceased."
|
||||||
|
if target.needs_reroll:
|
||||||
|
return False, f"{target.name} has no Pi-Rat in this scene."
|
||||||
|
if not obstacle_ids:
|
||||||
|
return False, "Apply at least one Obstacle to the Challenge."
|
||||||
|
|
||||||
|
valid_ids = {o.id for o in game.obstacles}
|
||||||
|
for oid in obstacle_ids:
|
||||||
|
if oid not in valid_ids:
|
||||||
|
return False, "Obstacle not found."
|
||||||
|
existing = find_open_challenge_for_obstacle(game, oid)
|
||||||
|
if existing:
|
||||||
|
return False, "One of those Obstacles is already part of an open Challenge."
|
||||||
|
|
||||||
|
for challenge in game.challenges:
|
||||||
|
if challenge.status == "open" and challenge.target_player_id == target_player_id:
|
||||||
|
return False, f"{target.name} is already facing an open Challenge. Resolve it first."
|
||||||
|
|
||||||
|
challenge = Challenge(
|
||||||
|
game_id=game.id,
|
||||||
|
challenge_type="deep",
|
||||||
|
target_player_id=target.id,
|
||||||
|
acting_player_id=target.id,
|
||||||
|
challenger_player_id=deep_player.id,
|
||||||
|
obstacle_ids=json.dumps(obstacle_ids),
|
||||||
|
stakes_success=stakes_success.strip(),
|
||||||
|
stakes_failure=stakes_failure.strip(),
|
||||||
|
)
|
||||||
|
db.add(challenge)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids)
|
||||||
|
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
|
||||||
|
success_text = stakes_success.strip()
|
||||||
|
failure_text = stakes_failure.strip()
|
||||||
|
if success_text:
|
||||||
|
msg += f" On success: {success_text}"
|
||||||
|
if failure_text:
|
||||||
|
msg += f" On failure: {failure_text}"
|
||||||
|
add_game_event(db, game.id, msg, kind="challenge")
|
||||||
|
return True, "Challenge called!"
|
||||||
|
|
||||||
|
def play_challenge_card(
|
||||||
|
db: Session,
|
||||||
|
player_id: str,
|
||||||
|
obstacle_id: str,
|
||||||
|
card_code: str
|
||||||
|
) -> Tuple[bool, str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Plays a card from a Pi-Rat's hand against an Obstacle that is part of an open
|
||||||
|
Challenge. The challenged Pi-Rat (or whoever takes it over via a Gat/Name Tax)
|
||||||
|
draws back on suit-color matches; assisting Pi-Rats play but never draw.
|
||||||
|
"""
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
obstacle = db.get(Obstacle, obstacle_id)
|
||||||
|
if not player or not obstacle:
|
||||||
|
return False, "Game entities not found.", {}
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if not game:
|
||||||
|
return False, "Game not found.", {}
|
||||||
|
|
||||||
|
if player.role == "deep":
|
||||||
|
return False, "The Deep applies Obstacles; it does not play cards against them.", {}
|
||||||
|
|
||||||
|
challenge = find_open_challenge_for_obstacle(game, obstacle_id)
|
||||||
|
if not challenge:
|
||||||
|
return False, "This Obstacle is not part of an open Challenge. The Deep must call a Challenge first.", {}
|
||||||
|
if challenge.tax_state == "requested":
|
||||||
|
return False, "A Gat/Name Tax is pending. Wait for an answer before playing cards.", {}
|
||||||
|
|
||||||
|
# An Obstacle beaten as many times as there are players is spent
|
||||||
|
played_list = json.loads(obstacle.played_cards)
|
||||||
|
current_successes = sum(1 for c in played_list if c.get("success") is True)
|
||||||
|
if current_successes >= len(game.players):
|
||||||
|
return False, "This obstacle is already completed.", {}
|
||||||
|
|
||||||
|
hand = get_player_hand(player)
|
||||||
|
if card_code not in hand:
|
||||||
|
return False, "Card not in hand.", {}
|
||||||
|
if cards.parse_card(card_code)["is_joker"]:
|
||||||
|
return False, "Jokers discard Obstacles outright; play it directly on the Obstacle instead.", {}
|
||||||
|
|
||||||
|
hand.remove(card_code)
|
||||||
|
set_player_hand(player, hand)
|
||||||
|
db.add(player)
|
||||||
|
|
||||||
|
acting = get_player(db, challenge.acting_player_id) or player
|
||||||
|
result = resolve_card_against_obstacle(db, game, player, obstacle, card_code, acting_rank=acting.rank)
|
||||||
|
|
||||||
|
# Record the play on the Challenge
|
||||||
|
plays = json.loads(challenge.plays)
|
||||||
|
plays.append({
|
||||||
|
"obstacle_id": obstacle_id,
|
||||||
|
"card": card_code,
|
||||||
|
"player_id": player.id,
|
||||||
|
"player_name": player.name,
|
||||||
|
"success": result["success"],
|
||||||
|
"details": result["details"]
|
||||||
|
})
|
||||||
|
challenge.plays = json.dumps(plays)
|
||||||
|
db.add(challenge)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Step 6: Draw back on suit-color match — only the attempting Pi-Rat draws.
|
||||||
|
drew_card = None
|
||||||
|
if player.id == challenge.acting_player_id:
|
||||||
|
if cards.match_suit_color(card_code, obstacle.original_card):
|
||||||
|
drawn = draw_cards_for_player(db, game, player, 1)
|
||||||
|
if drawn:
|
||||||
|
drew_card = drawn[0]
|
||||||
|
result["details"] += " (Drew a card back due to matching suit colors!)"
|
||||||
|
else:
|
||||||
|
result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)"
|
||||||
|
|
||||||
|
role_note = "" if player.id == challenge.acting_player_id else " (assist)"
|
||||||
|
add_game_event(db, game.id, f"{player.name} played {cards.parse_card(card_code)['display']} on '{obstacle.title}'{role_note}. {result['details']}", kind="card")
|
||||||
|
|
||||||
|
result["drew_card"] = drew_card
|
||||||
|
result["is_joker"] = False
|
||||||
|
return True, "Card played successfully.", result
|
||||||
|
|
||||||
|
def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
The Deep resolves the Challenge: it succeeds if at least one played card beat an
|
||||||
|
Obstacle. Applies the Captain Tax and settles any refused Gat/Name Tax.
|
||||||
|
"""
|
||||||
|
challenge = get_challenge(db, challenge_id)
|
||||||
|
resolver = get_player(db, resolver_id)
|
||||||
|
if not challenge or not resolver:
|
||||||
|
return False, "Challenge not found."
|
||||||
|
if challenge.status != "open":
|
||||||
|
return False, "This Challenge is already resolved."
|
||||||
|
if resolver.role != "deep":
|
||||||
|
return False, "Only Deep Players resolve Challenges."
|
||||||
|
if challenge.tax_state == "requested":
|
||||||
|
return False, "A Gat/Name Tax is pending. Wait for an answer before resolving."
|
||||||
|
|
||||||
|
game = get_game(db, challenge.game_id)
|
||||||
|
acting = get_player(db, challenge.acting_player_id)
|
||||||
|
target = get_player(db, challenge.target_player_id)
|
||||||
|
if not game or not acting or not target:
|
||||||
|
return False, "Game entities not found."
|
||||||
|
|
||||||
|
plays = json.loads(challenge.plays)
|
||||||
|
success = any(p.get("success") for p in plays)
|
||||||
|
challenge.status = "succeeded" if success else "failed"
|
||||||
|
db.add(challenge)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
outcome = "succeeded" if success else "failed"
|
||||||
|
add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!", kind="challenge")
|
||||||
|
|
||||||
|
# Settle a refused Gat/Name Tax: keep the prize on success, return it on failure.
|
||||||
|
if challenge.tax_state == "refused" and challenge.tax_target_id:
|
||||||
|
refuser = get_player(db, challenge.tax_target_id)
|
||||||
|
thing = "Gat" if challenge.tax_type == "gat" else "Name"
|
||||||
|
if refuser:
|
||||||
|
if success:
|
||||||
|
add_game_event(db, game.id, f"{acting.name} succeeded and keeps the stolen {thing}! {refuser.name} must find a new one.", kind="tax")
|
||||||
|
else:
|
||||||
|
if challenge.tax_type == "gat":
|
||||||
|
acting.completed_personal_1 = False
|
||||||
|
refuser.completed_personal_1 = True
|
||||||
|
# The Gat (and its description) returns to its owner.
|
||||||
|
refuser.gat_description = acting.gat_description
|
||||||
|
acting.gat_description = ""
|
||||||
|
else:
|
||||||
|
# Return the stolen name string; the failed thief reverts to
|
||||||
|
# their smell-based recruit identity.
|
||||||
|
acting.completed_personal_2 = False
|
||||||
|
refuser.completed_personal_2 = True
|
||||||
|
refuser.name = acting.name
|
||||||
|
acting.name = recruit_name(acting)
|
||||||
|
acting.tax_banned = True
|
||||||
|
db.add(refuser)
|
||||||
|
db.add(acting)
|
||||||
|
db.commit()
|
||||||
|
change_player_rank(db, game, acting, -1)
|
||||||
|
add_game_event(db, game.id, f"{acting.name} failed and returns the {thing} to {refuser.name}. No more Gat/Name Taxes for {acting.name} this scene!", kind="tax")
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
_apply_captain_tax(db, game, acting)
|
||||||
|
|
||||||
|
return True, f"Challenge {outcome}."
|
||||||
|
|
||||||
|
def cancel_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple[bool, str]:
|
||||||
|
"""The Deep withdraws an open Challenge (e.g. called by mistake). Played cards stay in the columns."""
|
||||||
|
challenge = get_challenge(db, challenge_id)
|
||||||
|
resolver = get_player(db, resolver_id)
|
||||||
|
if not challenge or not resolver:
|
||||||
|
return False, "Challenge not found."
|
||||||
|
if challenge.status != "open":
|
||||||
|
return False, "This Challenge is already resolved."
|
||||||
|
if resolver.role != "deep":
|
||||||
|
return False, "Only Deep Players can withdraw Challenges."
|
||||||
|
game = get_game(db, challenge.game_id)
|
||||||
|
target = get_player(db, challenge.target_player_id)
|
||||||
|
db.delete(challenge)
|
||||||
|
db.commit()
|
||||||
|
if game and target:
|
||||||
|
add_game_event(db, game.id, f"The Deep withdrew the Challenge against {target.name}.", kind="challenge")
|
||||||
|
return True, "Challenge withdrawn."
|
||||||
|
|
||||||
|
# --- Gat Tax & Name Tax ---
|
||||||
|
|
||||||
|
def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
A Gatless Pi-Rat may ask a Gatted Pi-Rat (or a Gatted-but-Nameless Pi-Rat may ask
|
||||||
|
a Named one) to attempt their Challenge for them.
|
||||||
|
"""
|
||||||
|
challenge = get_challenge(db, challenge_id)
|
||||||
|
requester = get_player(db, requester_id)
|
||||||
|
target = get_player(db, tax_target_id)
|
||||||
|
if not challenge or not requester or not target:
|
||||||
|
return False, "Game entities not found."
|
||||||
|
if challenge.status != "open" or challenge.challenge_type != "deep":
|
||||||
|
return False, "That Challenge cannot be taxed."
|
||||||
|
if challenge.acting_player_id != requester.id:
|
||||||
|
return False, "Only the challenged Pi-Rat can call a Gat/Name Tax."
|
||||||
|
if challenge.tax_state is not None:
|
||||||
|
return False, "A Tax has already been called on this Challenge."
|
||||||
|
if requester.tax_banned:
|
||||||
|
return False, "You failed a refused Tax this scene — no more Gat/Name Taxes for you!"
|
||||||
|
if target.id == requester.id or target.role == "deep" or target.is_dead or target.needs_reroll:
|
||||||
|
return False, "Pick another living Pi-Rat in the scene."
|
||||||
|
|
||||||
|
if not requester.completed_personal_1:
|
||||||
|
if not target.completed_personal_1:
|
||||||
|
return False, f"{target.name} has no Gat either!"
|
||||||
|
tax_type = "gat"
|
||||||
|
elif not requester.completed_personal_2:
|
||||||
|
if not target.completed_personal_2:
|
||||||
|
return False, f"{target.name} has no Name either!"
|
||||||
|
tax_type = "name"
|
||||||
|
else:
|
||||||
|
return False, "You already have a Gat and a Name. Face your own Challenges!"
|
||||||
|
|
||||||
|
challenge.tax_type = tax_type
|
||||||
|
challenge.tax_target_id = target.id
|
||||||
|
challenge.tax_state = "requested"
|
||||||
|
db.add(challenge)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
game = get_game(db, challenge.game_id)
|
||||||
|
thing = "Gat" if tax_type == "gat" else "Name"
|
||||||
|
add_game_event(db, game.id, f"{requester.name} calls a {thing} Tax on {target.name}: 'Take this Challenge for me!'", kind="tax")
|
||||||
|
return True, f"{thing} Tax called on {target.name}."
|
||||||
|
|
||||||
|
def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Accept: the requester hands over one random card and the responder attempts the
|
||||||
|
Challenge, sharing successes and failures.
|
||||||
|
Refuse: the responder hands over their Gat/Name (the requester immediately
|
||||||
|
completes that Objective) and the requester attempts the Challenge — winner keeps it.
|
||||||
|
"""
|
||||||
|
challenge = get_challenge(db, challenge_id)
|
||||||
|
responder = get_player(db, responder_id)
|
||||||
|
if not challenge or not responder:
|
||||||
|
return False, "Game entities not found."
|
||||||
|
if challenge.status != "open" or challenge.tax_state != "requested":
|
||||||
|
return False, "There is no pending Tax on this Challenge."
|
||||||
|
if challenge.tax_target_id != responder.id:
|
||||||
|
return False, "This Tax was not called on you."
|
||||||
|
|
||||||
|
requester = get_player(db, challenge.acting_player_id)
|
||||||
|
game = get_game(db, challenge.game_id)
|
||||||
|
if not requester or not game:
|
||||||
|
return False, "Game entities not found."
|
||||||
|
|
||||||
|
thing = "Gat" if challenge.tax_type == "gat" else "Name"
|
||||||
|
|
||||||
|
if accept:
|
||||||
|
# One random card from the requester's hand, chosen without looking
|
||||||
|
requester_hand = get_player_hand(requester)
|
||||||
|
card_note = ""
|
||||||
|
if requester_hand:
|
||||||
|
card = random.choice(requester_hand)
|
||||||
|
requester_hand.remove(card)
|
||||||
|
set_player_hand(requester, requester_hand)
|
||||||
|
responder_hand = get_player_hand(responder)
|
||||||
|
responder_hand.append(card)
|
||||||
|
set_player_hand(responder, responder_hand)
|
||||||
|
db.add(requester)
|
||||||
|
db.add(responder)
|
||||||
|
card_note = " One random card changed paws."
|
||||||
|
challenge.acting_player_id = responder.id
|
||||||
|
challenge.tax_state = "accepted"
|
||||||
|
db.add(challenge)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, f"{responder.name} accepted the {thing} Tax and takes over the Challenge!{card_note}", kind="tax")
|
||||||
|
return True, "Tax accepted."
|
||||||
|
|
||||||
|
# Refused: hand over the Gat/Name. The requester attempts the Challenge themselves;
|
||||||
|
# the transfer is settled (kept or returned) when the Challenge resolves.
|
||||||
|
if challenge.tax_type == "gat":
|
||||||
|
requester.completed_personal_1 = True
|
||||||
|
responder.completed_personal_1 = False
|
||||||
|
# The Gat changes hands along with its description (like a stolen Name).
|
||||||
|
requester.gat_description = responder.gat_description
|
||||||
|
responder.gat_description = ""
|
||||||
|
event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!"
|
||||||
|
else:
|
||||||
|
# The Name itself is stolen: the requester literally takes the responder's
|
||||||
|
# name string, demoting the responder back to their smell-based identity.
|
||||||
|
stolen_name = responder.name
|
||||||
|
old_requester_name = requester.name
|
||||||
|
requester.completed_personal_2 = True
|
||||||
|
responder.completed_personal_2 = False
|
||||||
|
responder.name = recruit_name(responder)
|
||||||
|
requester.name = stolen_name
|
||||||
|
event_msg = f"{stolen_name} refused the Name Tax and is demoted to {responder.name}! {old_requester_name} steals the name '{stolen_name}' and attempts the Challenge — succeed to keep it!"
|
||||||
|
challenge.tax_state = "refused"
|
||||||
|
db.add(requester)
|
||||||
|
db.add(responder)
|
||||||
|
db.add(challenge)
|
||||||
|
db.commit()
|
||||||
|
change_player_rank(db, game, requester, 1)
|
||||||
|
add_game_event(db, game.id, event_msg, kind="tax")
|
||||||
|
return True, "Tax refused. The prize is on the line!"
|
||||||
|
|
||||||
|
# --- Pi-Rat vs Pi-Rat Challenges ---
|
||||||
|
|
||||||
|
def create_pvp_challenge(
|
||||||
|
db: Session,
|
||||||
|
game_id: str,
|
||||||
|
challenger_id: str,
|
||||||
|
defender_id: str,
|
||||||
|
card_code: str
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
A Pi-Rat challenges another: the challenger plays one card face up as a temporary
|
||||||
|
Obstacle (narrating based on suit). The defender plays a card against it.
|
||||||
|
"""
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
challenger = get_player(db, challenger_id)
|
||||||
|
defender = get_player(db, defender_id)
|
||||||
|
if not game or not challenger or not defender:
|
||||||
|
return False, "Game entities not found."
|
||||||
|
if challenger.role == "deep" or defender.role == "deep":
|
||||||
|
return False, "Only Pi-Rats can duel each other."
|
||||||
|
if challenger.id == defender.id:
|
||||||
|
return False, "You cannot challenge yourself. (Well, you can, but not with cards.)"
|
||||||
|
if defender.is_dead:
|
||||||
|
return False, f"{defender.name} is dead. Let them rest."
|
||||||
|
if challenger.needs_reroll or defender.needs_reroll:
|
||||||
|
return False, "Pending recruits have no Pi-Rat to duel with."
|
||||||
|
|
||||||
|
for challenge in game.challenges:
|
||||||
|
if challenge.status == "open" and defender.id in (challenge.target_player_id, challenge.acting_player_id):
|
||||||
|
return False, f"{defender.name} is already facing an open Challenge."
|
||||||
|
|
||||||
|
hand = get_player_hand(challenger)
|
||||||
|
if card_code not in hand:
|
||||||
|
return False, "Card not in hand."
|
||||||
|
if cards.parse_card(card_code)["is_joker"]:
|
||||||
|
return False, "A Joker cannot serve as a temporary Obstacle."
|
||||||
|
|
||||||
|
hand.remove(card_code)
|
||||||
|
set_player_hand(challenger, hand)
|
||||||
|
db.add(challenger)
|
||||||
|
|
||||||
|
challenge = Challenge(
|
||||||
|
game_id=game.id,
|
||||||
|
challenge_type="pvp",
|
||||||
|
target_player_id=defender.id,
|
||||||
|
acting_player_id=defender.id,
|
||||||
|
challenger_player_id=challenger.id,
|
||||||
|
temp_card=card_code,
|
||||||
|
)
|
||||||
|
db.add(challenge)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
parsed = cards.parse_card(card_code)
|
||||||
|
narration = cards.SUITS[parsed["suit"]]["narration"]
|
||||||
|
add_game_event(db, game.id, f"{challenger.name} challenges {defender.name}, playing {parsed['display']} as a temporary Obstacle — {narration}!", kind="challenge")
|
||||||
|
return True, "Duel called!"
|
||||||
|
|
||||||
|
def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code: str) -> Tuple[bool, str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
The defender plays a card against the temporary Obstacle. Resolved immediately;
|
||||||
|
both cards are discarded and only the defender draws back on a suit-color match.
|
||||||
|
"""
|
||||||
|
challenge = get_challenge(db, challenge_id)
|
||||||
|
defender = get_player(db, defender_id)
|
||||||
|
if not challenge or not defender:
|
||||||
|
return False, "Game entities not found.", {}
|
||||||
|
if challenge.status != "open" or challenge.challenge_type != "pvp":
|
||||||
|
return False, "This duel is already settled.", {}
|
||||||
|
if challenge.acting_player_id != defender.id:
|
||||||
|
return False, "You are not the one being challenged.", {}
|
||||||
|
|
||||||
|
game = get_game(db, challenge.game_id)
|
||||||
|
challenger = get_player(db, challenge.challenger_player_id)
|
||||||
|
if not game or not challenger:
|
||||||
|
return False, "Game entities not found.", {}
|
||||||
|
|
||||||
|
hand = get_player_hand(defender)
|
||||||
|
if card_code not in hand:
|
||||||
|
return False, "Card not in hand.", {}
|
||||||
|
played_parsed = cards.parse_card(card_code)
|
||||||
|
if played_parsed["is_joker"]:
|
||||||
|
return False, "Jokers discard Obstacles from the list; they can't parry a duel.", {}
|
||||||
|
|
||||||
|
hand.remove(card_code)
|
||||||
|
set_player_hand(defender, hand)
|
||||||
|
db.add(defender)
|
||||||
|
|
||||||
|
# The temp card is the Obstacle: both its value source and its face-card check.
|
||||||
|
temp_value = cards.parse_card(challenge.temp_card)["numeric_value"]
|
||||||
|
result = evaluate_card_play(defender, card_code, challenge.temp_card, temp_value, defender.rank)
|
||||||
|
success = result["success"]
|
||||||
|
|
||||||
|
plays = json.loads(challenge.plays)
|
||||||
|
plays.append({
|
||||||
|
"obstacle_id": None,
|
||||||
|
"card": card_code,
|
||||||
|
"player_id": defender.id,
|
||||||
|
"player_name": defender.name,
|
||||||
|
"success": success,
|
||||||
|
"details": result["details"]
|
||||||
|
})
|
||||||
|
challenge.plays = json.dumps(plays)
|
||||||
|
challenge.status = "succeeded" if success else "failed"
|
||||||
|
db.add(challenge)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
drew_card = None
|
||||||
|
if cards.match_suit_color(card_code, challenge.temp_card):
|
||||||
|
drawn = draw_cards_for_player(db, game, defender, 1)
|
||||||
|
if drawn:
|
||||||
|
drew_card = drawn[0]
|
||||||
|
result["details"] += " (Drew a card back due to matching suit colors!)"
|
||||||
|
|
||||||
|
outcome = "wins" if success else "loses"
|
||||||
|
add_game_event(db, game.id, f"{defender.name} defends against {challenger.name} and {outcome}! {result['details']}", kind="challenge")
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
_apply_captain_tax(db, game, defender)
|
||||||
|
|
||||||
|
result["drew_card"] = drew_card
|
||||||
|
result["is_joker"] = False
|
||||||
|
return True, "Duel resolved.", result
|
||||||
826
src/pirats/crud_character.py
Normal file
826
src/pirats/crud_character.py
Normal file
@@ -0,0 +1,826 @@
|
|||||||
|
import json
|
||||||
|
import random
|
||||||
|
from sqlmodel import Session
|
||||||
|
from .models import Game
|
||||||
|
from .crud_base import get_player, get_game, add_game_event
|
||||||
|
|
||||||
|
# --- Character Creation Operations ---
|
||||||
|
|
||||||
|
def recruit_name_from_smell(avatar_smell: str, fallback: str = "") -> str:
|
||||||
|
"""
|
||||||
|
A nameless Pi-Rat goes by "Recruit {Smell}". Strips lead-ins like "smells like"
|
||||||
|
or "faintly of" (the suggestion pool uses these) so the name reads naturally.
|
||||||
|
"""
|
||||||
|
clean_smell = avatar_smell.strip()
|
||||||
|
if not clean_smell:
|
||||||
|
return f"Recruit {fallback}".strip() if fallback else "Recruit"
|
||||||
|
lead_ins = [
|
||||||
|
"suspiciously smelling of", "pleasantly smelling of", "reminiscent of",
|
||||||
|
"smells like", "a mix of", "like a mix of", "strongly of", "faintly of",
|
||||||
|
"like", "of",
|
||||||
|
]
|
||||||
|
stripped = True
|
||||||
|
while stripped:
|
||||||
|
stripped = False
|
||||||
|
for lead in lead_ins:
|
||||||
|
if clean_smell.lower().startswith(lead + " "):
|
||||||
|
clean_smell = clean_smell[len(lead) + 1:]
|
||||||
|
stripped = True
|
||||||
|
clean_smell = clean_smell[:1].upper() + clean_smell[1:]
|
||||||
|
return f"Recruit {clean_smell}"
|
||||||
|
|
||||||
|
def recruit_name(player) -> str:
|
||||||
|
"""The smell-based temporary identity for a player's Pi-Rat."""
|
||||||
|
return recruit_name_from_smell(player.avatar_smell, fallback=player.player_name or player.name)
|
||||||
|
|
||||||
|
def auto_delegate_all(db: Session, game: Game):
|
||||||
|
"""
|
||||||
|
Assigns the Like/Hate delegated questions for every player at once.
|
||||||
|
Uses a shuffled cycle so each player answers exactly one Like and one Hate,
|
||||||
|
never their own, and (with 3+ players) from two different crewmates.
|
||||||
|
"""
|
||||||
|
players = list(game.players)
|
||||||
|
if len(players) < 2:
|
||||||
|
return
|
||||||
|
random.shuffle(players)
|
||||||
|
n = len(players)
|
||||||
|
for i, p in enumerate(players):
|
||||||
|
if not p.other_like_from_player_id:
|
||||||
|
p.other_like_from_player_id = players[(i + 1) % n].id
|
||||||
|
p.other_like = ""
|
||||||
|
if not p.other_hate_from_player_id:
|
||||||
|
p.other_hate_from_player_id = players[(i - 1) % n].id
|
||||||
|
p.other_hate = ""
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
Saves a player's 3 techniques. Returns an error string if any of them collide
|
||||||
|
(case-insensitively) with each other or with techniques other players already
|
||||||
|
submitted, so the player can fix it immediately instead of the whole pool
|
||||||
|
being reset after everyone submits.
|
||||||
|
"""
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
if not player:
|
||||||
|
return None
|
||||||
|
|
||||||
|
techs = [tech1, tech2, tech3]
|
||||||
|
cleaned = [t.strip().lower() for t in techs]
|
||||||
|
if len(set(cleaned)) < 3:
|
||||||
|
return "Your 3 techniques must all be different."
|
||||||
|
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if game:
|
||||||
|
taken = set()
|
||||||
|
for p in game.players:
|
||||||
|
if p.id == player.id:
|
||||||
|
continue
|
||||||
|
for t in json.loads(p.created_techniques):
|
||||||
|
taken.add(t.strip().lower())
|
||||||
|
clashes = [techs[i] for i, c in enumerate(cleaned) if c in taken]
|
||||||
|
if clashes:
|
||||||
|
return f"Already taken by a crewmate: {', '.join(clashes)}. Pick something more original!"
|
||||||
|
|
||||||
|
player.created_techniques = json.dumps(techs)
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Check if all players have submitted techniques. If so, trigger the swap!
|
||||||
|
if not game:
|
||||||
|
return None
|
||||||
|
maybe_trigger_technique_swap(db, game)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def unsubmit_techniques(db: Session, player_id: str):
|
||||||
|
"""Unlocks a player's submitted techniques for revision. Only possible
|
||||||
|
while the crew is still in character_creation: clearing the submission
|
||||||
|
also keeps the swap phase from triggering until they resubmit."""
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found."
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if not game or game.phase != "character_creation":
|
||||||
|
return False, "Techniques can't be revised once swapping has begun."
|
||||||
|
if player.needs_reroll:
|
||||||
|
return False, "You're spectating until recruit creation."
|
||||||
|
if not json.loads(player.created_techniques):
|
||||||
|
return False, "You haven't submitted techniques yet."
|
||||||
|
player.created_techniques = "[]"
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
return True, "Techniques unlocked for revision."
|
||||||
|
|
||||||
|
def unassign_face_techniques(db: Session, player_id: str):
|
||||||
|
"""Unlocks a player's J/Q/K assignment for revision. Clearing is_ready
|
||||||
|
keeps the game from advancing to scene setup until they reassign."""
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found."
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if not game or game.phase != "assign_techniques":
|
||||||
|
return False, "Face-card assignment isn't in progress."
|
||||||
|
if not player.is_ready:
|
||||||
|
return False, "You haven't assigned your face cards yet."
|
||||||
|
player.tech_jack = ""
|
||||||
|
player.tech_queen = ""
|
||||||
|
player.tech_king = ""
|
||||||
|
player.is_ready = False
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
return True, "Face cards unlocked for revision."
|
||||||
|
|
||||||
|
def swap_participants(game: Game) -> list:
|
||||||
|
"""Players taking part in initial character creation (late joiners with a
|
||||||
|
queued recruit re-roll spectate and are excluded)."""
|
||||||
|
return [p for p in game.players if not p.needs_reroll]
|
||||||
|
|
||||||
|
def holds_own_technique(player) -> bool:
|
||||||
|
return any(t["creator_id"] == player.id for t in json.loads(player.held_techniques))
|
||||||
|
|
||||||
|
def maybe_trigger_technique_swap(db: Session, game: Game):
|
||||||
|
"""Begin the swap phase once every participant has submitted 3 techniques.
|
||||||
|
Safe to call after a roster change (e.g. a kick) to unblock the phase."""
|
||||||
|
participants = swap_participants(game)
|
||||||
|
if not participants:
|
||||||
|
return
|
||||||
|
for p in participants:
|
||||||
|
techs = json.loads(p.created_techniques)
|
||||||
|
if len(techs) < 3 or not all(techs):
|
||||||
|
return
|
||||||
|
trigger_technique_swap(db, game)
|
||||||
|
|
||||||
|
def trigger_technique_swap(db: Session, game: Game):
|
||||||
|
"""
|
||||||
|
All techniques are in: everyone enters the swap phase holding their own 3
|
||||||
|
creations (tagged with their creator) and must trade them all away through
|
||||||
|
blind swap offers before the game moves on to face-card assignment.
|
||||||
|
"""
|
||||||
|
players = swap_participants(game)
|
||||||
|
if not players:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reject the pool if any two techniques collide (case-insensitively)
|
||||||
|
seen_texts = set()
|
||||||
|
has_duplicates = False
|
||||||
|
for p in players:
|
||||||
|
for t in json.loads(p.created_techniques):
|
||||||
|
t_clean = t.strip().lower()
|
||||||
|
if t_clean in seen_texts:
|
||||||
|
has_duplicates = True
|
||||||
|
seen_texts.add(t_clean)
|
||||||
|
|
||||||
|
if has_duplicates:
|
||||||
|
# Force players to resubmit
|
||||||
|
for p in players:
|
||||||
|
p.created_techniques = "[]"
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, "Duplicate techniques submitted! The pool has been cleared. All players must try again with unique technique names.", kind="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
for p in players:
|
||||||
|
p.held_techniques = json.dumps([{"text": t, "creator_id": p.id} for t in json.loads(p.created_techniques)])
|
||||||
|
p.swapped_techniques = "[]"
|
||||||
|
p.swap_offer_to_id = None
|
||||||
|
p.swap_offer_technique = ""
|
||||||
|
p.is_ready = False
|
||||||
|
db.add(p)
|
||||||
|
game.phase = "swap_techniques"
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, "Phase changed: Swap Techniques — trade away all 3 of your own techniques with blind swap offers!", kind="phase")
|
||||||
|
|
||||||
|
def offer_swap(db: Session, player_id: str, target_player_id: str, technique: str):
|
||||||
|
"""Offers one held technique to another player as a blind swap. The target
|
||||||
|
learns only that an offer exists, never which technique is on the table."""
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
target = get_player(db, target_player_id)
|
||||||
|
if not player or not target or player.game_id != target.game_id:
|
||||||
|
return False, "Player not found."
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if not game or game.phase != "swap_techniques":
|
||||||
|
return False, "Technique swapping isn't in progress."
|
||||||
|
if player.id == target.id:
|
||||||
|
return False, "You can't swap techniques with yourself."
|
||||||
|
if target.needs_reroll:
|
||||||
|
return False, "That player is spectating until recruit creation."
|
||||||
|
if player.swap_offer_to_id:
|
||||||
|
return False, "You already have a swap offer pending. Cancel it first."
|
||||||
|
held = json.loads(player.held_techniques)
|
||||||
|
entry = next((t for t in held if t["text"] == technique), None)
|
||||||
|
if not entry:
|
||||||
|
return False, "You don't hold that technique."
|
||||||
|
if entry["creator_id"] == target.id:
|
||||||
|
return False, "You can't offer a technique back to its creator."
|
||||||
|
player.swap_offer_to_id = target.id
|
||||||
|
player.swap_offer_technique = technique
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, f"{player.name} offered a mystery technique swap to {target.name}.", kind="info")
|
||||||
|
return True, "Swap offered."
|
||||||
|
|
||||||
|
def cancel_swap_offer(db: Session, player_id: str):
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found."
|
||||||
|
if not player.swap_offer_to_id:
|
||||||
|
return False, "You have no pending swap offer."
|
||||||
|
target = get_player(db, player.swap_offer_to_id)
|
||||||
|
player.swap_offer_to_id = None
|
||||||
|
player.swap_offer_technique = ""
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
if target:
|
||||||
|
add_game_event(db, player.game_id, f"{player.name} withdrew their swap offer to {target.name}.", kind="info")
|
||||||
|
return True, "Offer withdrawn."
|
||||||
|
|
||||||
|
def respond_swap_offer(db: Session, player_id: str, offerer_id: str, accept: bool, technique: str = ""):
|
||||||
|
"""Accepts (trading back one of your held techniques) or declines a swap
|
||||||
|
offer. On accept the two techniques change hands, creators and all."""
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
offerer = get_player(db, offerer_id)
|
||||||
|
if not player or not offerer or player.game_id != offerer.game_id:
|
||||||
|
return False, "Player not found."
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if not game or game.phase != "swap_techniques":
|
||||||
|
return False, "Technique swapping isn't in progress."
|
||||||
|
if offerer.swap_offer_to_id != player.id:
|
||||||
|
return False, "That player hasn't offered you a swap."
|
||||||
|
|
||||||
|
if not accept:
|
||||||
|
offerer.swap_offer_to_id = None
|
||||||
|
offerer.swap_offer_technique = ""
|
||||||
|
db.add(offerer)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, f"{player.name} declined {offerer.name}'s swap offer.", kind="info")
|
||||||
|
return True, "Offer declined."
|
||||||
|
|
||||||
|
my_held = json.loads(player.held_techniques)
|
||||||
|
my_entry = next((t for t in my_held if t["text"] == technique), None)
|
||||||
|
if not my_entry:
|
||||||
|
return False, "You don't hold that technique."
|
||||||
|
if my_entry["creator_id"] == offerer.id:
|
||||||
|
return False, "You can't trade a technique back to its creator."
|
||||||
|
their_held = json.loads(offerer.held_techniques)
|
||||||
|
their_entry = next((t for t in their_held if t["text"] == offerer.swap_offer_technique), None)
|
||||||
|
if not their_entry:
|
||||||
|
return False, "The offered technique is no longer available."
|
||||||
|
|
||||||
|
my_held.remove(my_entry)
|
||||||
|
my_held.append(their_entry)
|
||||||
|
their_held.remove(their_entry)
|
||||||
|
their_held.append(my_entry)
|
||||||
|
player.held_techniques = json.dumps(my_held)
|
||||||
|
offerer.held_techniques = json.dumps(their_held)
|
||||||
|
offerer.swap_offer_to_id = None
|
||||||
|
offerer.swap_offer_technique = ""
|
||||||
|
# If the technique we just gave away was promised to someone else, withdraw that offer
|
||||||
|
if player.swap_offer_technique == my_entry["text"]:
|
||||||
|
player.swap_offer_to_id = None
|
||||||
|
player.swap_offer_technique = ""
|
||||||
|
db.add(player)
|
||||||
|
db.add(offerer)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, f"{player.name} and {offerer.name} swapped techniques!", kind="info")
|
||||||
|
return True, "Techniques swapped."
|
||||||
|
|
||||||
|
def set_swap_ready(db: Session, player_id: str, ready: bool):
|
||||||
|
"""Marks a player done (or not done) swapping. Readying up requires having
|
||||||
|
traded away all self-created techniques; once everyone is ready the game
|
||||||
|
moves on to face-card assignment."""
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found."
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if not game or game.phase != "swap_techniques":
|
||||||
|
return False, "Technique swapping isn't in progress."
|
||||||
|
if ready:
|
||||||
|
if holds_own_technique(player):
|
||||||
|
return False, "You still hold techniques you created. Swap them away first!"
|
||||||
|
if player.swap_offer_to_id:
|
||||||
|
return False, "You have a swap offer pending. Cancel it or wait for an answer first."
|
||||||
|
player.is_ready = ready
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
maybe_finish_technique_swap(db, game)
|
||||||
|
return True, "Ready!" if ready else "No longer ready."
|
||||||
|
|
||||||
|
def maybe_finish_technique_swap(db: Session, game: Game):
|
||||||
|
"""Advance to face-card assignment once everyone has readied up in the swap
|
||||||
|
phase. Safe to call after a roster change to unblock the phase."""
|
||||||
|
participants = swap_participants(game)
|
||||||
|
if participants and all(p.is_ready for p in participants):
|
||||||
|
finish_technique_swap(db, game)
|
||||||
|
|
||||||
|
def finish_technique_swap(db: Session, game: Game):
|
||||||
|
"""Locks in everyone's held techniques and advances to J/Q/K assignment."""
|
||||||
|
for p in swap_participants(game):
|
||||||
|
p.swapped_techniques = json.dumps([t["text"] for t in json.loads(p.held_techniques)])
|
||||||
|
p.swap_offer_to_id = None
|
||||||
|
p.swap_offer_technique = ""
|
||||||
|
p.is_ready = False # Reset ready flag for the J/Q/K assignment step
|
||||||
|
db.add(p)
|
||||||
|
game.phase = "assign_techniques"
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, "Phase changed: Assign Techniques — place your new techniques on your Jack, Queen, and King.", kind="phase")
|
||||||
|
|
||||||
|
def auto_assign_techniques(db: Session, game: Game):
|
||||||
|
"""
|
||||||
|
Admin escape hatch: randomly redistributes every created technique to an
|
||||||
|
eligible player (never its creator) and assigns them to J/Q/K, finishing
|
||||||
|
the whole technique distribution step in one click.
|
||||||
|
"""
|
||||||
|
if game.phase not in ("swap_techniques", "assign_techniques"):
|
||||||
|
return False, "Technique distribution isn't in progress."
|
||||||
|
|
||||||
|
if game.phase == "swap_techniques":
|
||||||
|
players = swap_participants(game)
|
||||||
|
pool = []
|
||||||
|
for p in players:
|
||||||
|
for t in json.loads(p.created_techniques):
|
||||||
|
pool.append({"text": t, "creator_id": p.id})
|
||||||
|
|
||||||
|
# Random shuffling with retries: for a small table this finds a valid
|
||||||
|
# creator != assignee distribution almost immediately.
|
||||||
|
assignments = None
|
||||||
|
for attempt in range(200):
|
||||||
|
random.shuffle(pool)
|
||||||
|
candidate = {p.id: [] for p in players}
|
||||||
|
temp_pool = pool.copy()
|
||||||
|
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:
|
||||||
|
# With 1-2 players a non-owned distribution can be impossible;
|
||||||
|
# fall back to allowing own techniques.
|
||||||
|
if len(players) < 3:
|
||||||
|
valid_for_p = temp_pool.copy()
|
||||||
|
else:
|
||||||
|
failed = True
|
||||||
|
break
|
||||||
|
chosen = valid_for_p[:3]
|
||||||
|
for c in chosen:
|
||||||
|
candidate[p.id].append(c)
|
||||||
|
temp_pool.remove(c)
|
||||||
|
if not failed:
|
||||||
|
assignments = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if assignments is None:
|
||||||
|
# Last-resort fallback: deal the pool out in order
|
||||||
|
random.shuffle(pool)
|
||||||
|
assignments = {p.id: pool[i * 3:(i + 1) * 3] for i, p in enumerate(players)}
|
||||||
|
|
||||||
|
for p in players:
|
||||||
|
p.held_techniques = json.dumps(assignments[p.id])
|
||||||
|
p.swapped_techniques = json.dumps([t["text"] for t in assignments[p.id]])
|
||||||
|
p.swap_offer_to_id = None
|
||||||
|
p.swap_offer_technique = ""
|
||||||
|
p.is_ready = False
|
||||||
|
db.add(p)
|
||||||
|
game.phase = "assign_techniques"
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, "Techniques were auto-distributed to the crew!", kind="info")
|
||||||
|
|
||||||
|
# Random J/Q/K for anyone who hasn't assigned yet; the last assignment
|
||||||
|
# triggers the rank rolls and the scene_setup transition.
|
||||||
|
for p in list(swap_participants(game)):
|
||||||
|
if not (p.tech_jack and p.tech_queen and p.tech_king):
|
||||||
|
swapped = json.loads(p.swapped_techniques)
|
||||||
|
random.shuffle(swapped)
|
||||||
|
assign_face_card_techniques(db, p.id, swapped[0], swapped[1], swapped[2])
|
||||||
|
elif not p.is_ready:
|
||||||
|
assign_face_card_techniques(db, p.id, p.tech_jack, p.tech_queen, p.tech_king)
|
||||||
|
|
||||||
|
db.refresh(game)
|
||||||
|
if game.phase != "scene_setup":
|
||||||
|
return False, "Auto-assignment couldn't finish; check the event log."
|
||||||
|
return True, "Techniques auto-assigned."
|
||||||
|
|
||||||
|
def maybe_finish_assign_techniques(db: Session, game: Game):
|
||||||
|
"""Once everyone has assigned their J/Q/K, roll starting ranks and move to
|
||||||
|
scene setup. Safe to call after a roster change to unblock the phase."""
|
||||||
|
players_list = swap_participants(game)
|
||||||
|
if not players_list or not all(p.is_ready for p in players_list):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Randomly assign starting ranks
|
||||||
|
random.shuffle(players_list)
|
||||||
|
|
||||||
|
# Teaching mode: an admin opted to be the forced Rank-3 Deep for the first
|
||||||
|
# scene, so seed them at the front instead of leaving it to chance.
|
||||||
|
if game.teaching_deep_player_id:
|
||||||
|
teacher = next((p for p in players_list if p.id == game.teaching_deep_player_id), None)
|
||||||
|
if teacher:
|
||||||
|
players_list.remove(teacher)
|
||||||
|
players_list.insert(0, teacher)
|
||||||
|
|
||||||
|
# "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()
|
||||||
|
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
||||||
|
|
||||||
|
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 False, "Player not found."
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if not game or game.phase != "assign_techniques":
|
||||||
|
return False, "Face-card assignment isn't in progress."
|
||||||
|
if sorted([jack, queen, king]) != sorted(json.loads(player.swapped_techniques)):
|
||||||
|
return False, "Assign each of your 3 swapped techniques to exactly one face card."
|
||||||
|
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!
|
||||||
|
maybe_finish_assign_techniques(db, game)
|
||||||
|
return True, "Techniques assigned."
|
||||||
|
|
||||||
|
def skip_character_creation(db: Session, game: Game, fills: dict):
|
||||||
|
"""
|
||||||
|
Dev Mode shortcut: auto-completes every unfinished part of character
|
||||||
|
creation for ALL players and advances to scene setup. `fills` maps
|
||||||
|
player_id -> suggested values for the free-text fields (avatar_look,
|
||||||
|
avatar_smell, first_words, good_at_math, like, hate), supplied by the
|
||||||
|
admin's client from the frontend suggestion pools. Techniques come from
|
||||||
|
the backend pool. Already-filled fields are left untouched.
|
||||||
|
"""
|
||||||
|
from .cards import TECHNIQUE_SUGGESTIONS
|
||||||
|
|
||||||
|
if game.phase not in ("character_creation", "swap_techniques", "assign_techniques"):
|
||||||
|
return False, "Character creation isn't in progress."
|
||||||
|
|
||||||
|
if game.phase == "character_creation":
|
||||||
|
# 1. Basic Rat Records
|
||||||
|
for p in game.players:
|
||||||
|
f = fills.get(p.id, {})
|
||||||
|
p.avatar_look = p.avatar_look or f.get("avatar_look", "A nondescript test rat")
|
||||||
|
p.avatar_smell = p.avatar_smell or f.get("avatar_smell", "of fresh paint")
|
||||||
|
p.first_words = p.first_words or f.get("first_words", "Is this thing on?")
|
||||||
|
p.good_at_math = p.good_at_math or f.get("good_at_math", "Untested")
|
||||||
|
if not p.completed_personal_2:
|
||||||
|
p.name = recruit_name(p)
|
||||||
|
db.add(p)
|
||||||
|
|
||||||
|
# 2. Delegated Like/Hate questions
|
||||||
|
auto_delegate_all(db, game)
|
||||||
|
for p in game.players:
|
||||||
|
f = fills.get(p.id, {})
|
||||||
|
if p.other_like_from_player_id and not p.other_like:
|
||||||
|
p.other_like = f.get("like", "Their impeccable punctuality")
|
||||||
|
if p.other_hate_from_player_id and not p.other_hate:
|
||||||
|
p.other_hate = f.get("hate", "Their impeccable punctuality")
|
||||||
|
db.add(p)
|
||||||
|
|
||||||
|
# 3. Created techniques, drawn from the backend pool without collisions
|
||||||
|
taken = set()
|
||||||
|
for p in game.players:
|
||||||
|
for t in json.loads(p.created_techniques):
|
||||||
|
taken.add(t.strip().lower())
|
||||||
|
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in taken]
|
||||||
|
random.shuffle(available)
|
||||||
|
for p in game.players:
|
||||||
|
techs = json.loads(p.created_techniques)
|
||||||
|
if len(techs) < 3 or not all(techs):
|
||||||
|
techs = [available.pop() for _ in range(3)]
|
||||||
|
p.created_techniques = json.dumps(techs)
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 4. Move to the swap phase (rejects the pool if there are duplicates)
|
||||||
|
trigger_technique_swap(db, game)
|
||||||
|
db.refresh(game)
|
||||||
|
if game.phase != "swap_techniques":
|
||||||
|
return False, "Technique swap failed; check the event log."
|
||||||
|
|
||||||
|
# 5. Auto-distribute any unswapped techniques and random-assign J/Q/K;
|
||||||
|
# the last assignment triggers the rank rolls and the scene_setup transition.
|
||||||
|
ok, msg = auto_assign_techniques(db, game)
|
||||||
|
if not ok:
|
||||||
|
return False, "Could not finish character creation; check the event log."
|
||||||
|
return True, "Character creation skipped."
|
||||||
|
|
||||||
|
# --- Recruit Creation (death re-rolls and late joiners) ---
|
||||||
|
#
|
||||||
|
# Dead Pi-Rats who choose to re-roll (and players who joined after initial
|
||||||
|
# character creation) are queued with needs_reroll=True. Once between-scenes
|
||||||
|
# upkeep finishes, the game detours through the "recruit_creation" phase:
|
||||||
|
# the recruit answers the basic sheet questions while crewmates answer their
|
||||||
|
# Like/Hate questions and write their 3 replacement techniques. The recruit
|
||||||
|
# then assigns those techniques to J/Q/K and finalizes, and once every pending
|
||||||
|
# recruit has finalized the game moves on to scene_setup.
|
||||||
|
|
||||||
|
def choose_reroll(db: Session, player_id: str):
|
||||||
|
"""A dead Pi-Rat opts to come back as a fresh recruit (vs. becoming a Ghost).
|
||||||
|
The actual creation happens in the recruit_creation phase after upkeep."""
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found."
|
||||||
|
if player.needs_reroll:
|
||||||
|
return True, "Already queued for a new recruit."
|
||||||
|
if not player.is_dead or player.is_ghost:
|
||||||
|
return False, "Only dead Pi-Rats can roll a new recruit."
|
||||||
|
player.needs_reroll = True
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, player.game_id, f"{player.name} will return as a fresh recruit! The crew will help create them after upkeep.", kind="join")
|
||||||
|
return True, "Queued for recruit creation."
|
||||||
|
|
||||||
|
def techniques_in_use(game: Game, exclude_player_id: str = None) -> set:
|
||||||
|
"""Lowercased technique names already on someone's sheet or pending for a recruit."""
|
||||||
|
in_use = set()
|
||||||
|
for p in game.players:
|
||||||
|
if p.id == exclude_player_id:
|
||||||
|
continue
|
||||||
|
texts = [p.tech_jack, p.tech_queen, p.tech_king]
|
||||||
|
texts += json.loads(p.created_techniques) + json.loads(p.swapped_techniques)
|
||||||
|
texts += [t["text"] for t in json.loads(p.held_techniques)]
|
||||||
|
texts += [s["text"] for s in json.loads(p.incoming_techniques)]
|
||||||
|
for t in texts:
|
||||||
|
if t:
|
||||||
|
in_use.add(t.strip().lower())
|
||||||
|
return in_use
|
||||||
|
|
||||||
|
def activate_recruit(db: Session, game: Game, player):
|
||||||
|
"""Wipes a pending recruit's sheet and assigns crewmates to answer their
|
||||||
|
Like/Hate questions and write their 3 replacement techniques."""
|
||||||
|
from .crud_base import get_player_hand, set_player_hand, get_game_deck, set_game_deck
|
||||||
|
|
||||||
|
# Return any leftover hand to the deck
|
||||||
|
hand = get_player_hand(player)
|
||||||
|
if hand:
|
||||||
|
deck = get_game_deck(game)
|
||||||
|
deck.extend(hand)
|
||||||
|
random.shuffle(deck)
|
||||||
|
set_game_deck(game, deck)
|
||||||
|
set_player_hand(player, [])
|
||||||
|
|
||||||
|
# A dead Captain's position does not pass to their replacement recruit
|
||||||
|
if game.captain_player_id == player.id:
|
||||||
|
game.captain_player_id = None
|
||||||
|
|
||||||
|
# New recruits always start at Rank 1 with a blank sheet
|
||||||
|
player.rank = 1
|
||||||
|
player.role = None
|
||||||
|
player.is_ready = False
|
||||||
|
player.is_dead = False
|
||||||
|
player.is_ghost = False
|
||||||
|
player.tax_banned = False
|
||||||
|
player.needs_name = False
|
||||||
|
player.needs_rank_3_bonus = False
|
||||||
|
player.completed_personal_1 = False
|
||||||
|
player.completed_personal_2 = False
|
||||||
|
player.completed_personal_3 = False
|
||||||
|
player.avatar_look = ""
|
||||||
|
player.avatar_smell = ""
|
||||||
|
player.first_words = ""
|
||||||
|
player.good_at_math = ""
|
||||||
|
player.created_techniques = "[]"
|
||||||
|
player.held_techniques = "[]"
|
||||||
|
player.swapped_techniques = "[]"
|
||||||
|
player.swap_offer_to_id = None
|
||||||
|
player.swap_offer_technique = ""
|
||||||
|
player.tech_jack = ""
|
||||||
|
player.tech_queen = ""
|
||||||
|
player.tech_king = ""
|
||||||
|
player.name = recruit_name(player)
|
||||||
|
|
||||||
|
# Crewmates fill in the social questions and write the recruit's techniques.
|
||||||
|
# Prefer helpers who aren't busy creating their own recruit.
|
||||||
|
others = [p for p in game.players if p.id != player.id]
|
||||||
|
helpers = [p for p in others if not p.needs_reroll] or others
|
||||||
|
if helpers:
|
||||||
|
random.shuffle(helpers)
|
||||||
|
player.other_like_from_player_id = helpers[0].id
|
||||||
|
player.other_like = ""
|
||||||
|
player.other_hate_from_player_id = helpers[1 % len(helpers)].id
|
||||||
|
player.other_hate = ""
|
||||||
|
slots = [{"from_id": helpers[i % len(helpers)].id, "text": ""} for i in range(3)]
|
||||||
|
player.incoming_techniques = json.dumps(slots)
|
||||||
|
else:
|
||||||
|
# Solo game: nobody to ask, so fall back to the suggestion pool
|
||||||
|
from .cards import TECHNIQUE_SUGGESTIONS
|
||||||
|
player.other_like_from_player_id = None
|
||||||
|
player.other_like = ""
|
||||||
|
player.other_hate_from_player_id = None
|
||||||
|
player.other_hate = ""
|
||||||
|
in_use = techniques_in_use(game, exclude_player_id=player.id)
|
||||||
|
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use]
|
||||||
|
if len(available) < 3:
|
||||||
|
available = TECHNIQUE_SUGGESTIONS
|
||||||
|
chosen = random.sample(available, 3)
|
||||||
|
player.incoming_techniques = json.dumps([{"from_id": None, "text": t} for t in chosen])
|
||||||
|
|
||||||
|
db.add(player)
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def start_recruit_creation(db: Session, game: Game):
|
||||||
|
"""Enters the recruit_creation phase and activates every pending recruit."""
|
||||||
|
game.phase = "recruit_creation"
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
for p in game.players:
|
||||||
|
if p.needs_reroll:
|
||||||
|
activate_recruit(db, game, p)
|
||||||
|
add_game_event(db, game.id, "Phase changed: New Recruits — help your crewmates create their fresh Rank 1 Pi-Rats!", kind="phase")
|
||||||
|
|
||||||
|
def contribute_recruit_technique(db: Session, contributor_id: str, recruit_id: str, slot: int, text: str):
|
||||||
|
"""A crewmate writes (or revises) one of their assigned technique slots for a recruit."""
|
||||||
|
recruit = get_player(db, recruit_id)
|
||||||
|
contributor = get_player(db, contributor_id)
|
||||||
|
if not recruit or not contributor or recruit.game_id != contributor.game_id:
|
||||||
|
return False, "Player not found."
|
||||||
|
game = get_game(db, recruit.game_id)
|
||||||
|
if not game or game.phase != "recruit_creation" or not recruit.needs_reroll:
|
||||||
|
return False, "That recruit isn't being created right now."
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return False, "Technique can't be empty."
|
||||||
|
slots = json.loads(recruit.incoming_techniques)
|
||||||
|
if slot < 0 or slot >= len(slots):
|
||||||
|
return False, "Invalid technique slot."
|
||||||
|
if slots[slot]["from_id"] != contributor_id:
|
||||||
|
return False, "That technique slot belongs to another crewmate."
|
||||||
|
in_use = techniques_in_use(game, exclude_player_id=recruit.id)
|
||||||
|
for i, s in enumerate(slots):
|
||||||
|
if i != slot and s["text"]:
|
||||||
|
in_use.add(s["text"].strip().lower())
|
||||||
|
if text.lower() in in_use:
|
||||||
|
return False, f'"{text}" is already in play. Pick something more original!'
|
||||||
|
slots[slot]["text"] = text
|
||||||
|
recruit.incoming_techniques = json.dumps(slots)
|
||||||
|
db.add(recruit)
|
||||||
|
db.commit()
|
||||||
|
return True, "Technique contributed."
|
||||||
|
|
||||||
|
def finalize_recruit(db: Session, player_id: str, jack: str, queen: str, king: str):
|
||||||
|
"""The recruit assigns their crew-written techniques to J/Q/K and joins the crew.
|
||||||
|
Once the last pending recruit finalizes, the game advances to scene_setup."""
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found."
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if not game or game.phase != "recruit_creation" or not player.needs_reroll:
|
||||||
|
return False, "No recruit creation in progress."
|
||||||
|
if not (player.avatar_look and player.avatar_smell and player.first_words and player.good_at_math):
|
||||||
|
return False, "Fill out your Rat Records first."
|
||||||
|
if player.other_like_from_player_id and not player.other_like:
|
||||||
|
return False, "A crewmate still owes you a LIKE answer."
|
||||||
|
if player.other_hate_from_player_id and not player.other_hate:
|
||||||
|
return False, "A crewmate still owes you a HATE answer."
|
||||||
|
texts = [s["text"] for s in json.loads(player.incoming_techniques)]
|
||||||
|
if len(texts) < 3 or not all(texts):
|
||||||
|
return False, "Your crewmates haven't finished writing your techniques."
|
||||||
|
if sorted([jack, queen, king]) != sorted(texts):
|
||||||
|
return False, "Assign each of your 3 new techniques to exactly one face card."
|
||||||
|
|
||||||
|
player.swapped_techniques = json.dumps(texts)
|
||||||
|
player.held_techniques = json.dumps([{"text": s["text"], "creator_id": s["from_id"]} for s in json.loads(player.incoming_techniques)])
|
||||||
|
player.tech_jack = jack
|
||||||
|
player.tech_queen = queen
|
||||||
|
player.tech_king = king
|
||||||
|
player.needs_reroll = False
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Deal the new recruit's starting hand
|
||||||
|
from .crud_base import calculate_max_hand_size, draw_cards_for_player
|
||||||
|
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
|
||||||
|
draw_cards_for_player(db, game, player, max_size)
|
||||||
|
add_game_event(db, game.id, f"{player.name} has joined the crew as a fresh Rank {player.rank} recruit!", kind="join")
|
||||||
|
|
||||||
|
maybe_finish_recruit_creation(db, game)
|
||||||
|
return True, "Welcome aboard!"
|
||||||
|
|
||||||
|
def maybe_finish_recruit_creation(db: Session, game: Game):
|
||||||
|
"""Leave recruit creation for scene setup once no pending recruits remain.
|
||||||
|
Safe to call after a roster change to unblock the phase."""
|
||||||
|
if not any(p.needs_reroll for p in game.players):
|
||||||
|
from .crud_upkeep import begin_next_scene_setup
|
||||||
|
begin_next_scene_setup(db, game)
|
||||||
|
|
||||||
|
def _unused_recruit_technique(game: Game, recruit_id: str) -> str:
|
||||||
|
"""A technique name not already in play, for backfilling a recruit slot whose
|
||||||
|
assigned crewmate is gone (no one left to write it)."""
|
||||||
|
from .cards import TECHNIQUE_SUGGESTIONS
|
||||||
|
in_use = techniques_in_use(game, exclude_player_id=recruit_id)
|
||||||
|
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use] or list(TECHNIQUE_SUGGESTIONS)
|
||||||
|
return random.choice(available)
|
||||||
|
|
||||||
|
def repair_references_to_removed_player(db: Session, game: Game, removed_id: str):
|
||||||
|
"""Fix every other player's pointers to a player who is being removed, so
|
||||||
|
nothing dangles or stalls: pending swap offers are dropped, delegated
|
||||||
|
Like/Hate questions and recruit technique slots are handed to a present
|
||||||
|
crewmate (or filled from the pool when nobody is left to do them)."""
|
||||||
|
others = [p for p in game.players if p.id != removed_id]
|
||||||
|
helpers = [p for p in others if not p.needs_reroll]
|
||||||
|
|
||||||
|
def pick_helper(exclude_id):
|
||||||
|
pool = [h for h in helpers if h.id != exclude_id]
|
||||||
|
return random.choice(pool) if pool else None
|
||||||
|
|
||||||
|
for p in others:
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# A swap offer aimed at the removed player can never be answered.
|
||||||
|
if p.swap_offer_to_id == removed_id:
|
||||||
|
p.swap_offer_to_id = None
|
||||||
|
p.swap_offer_technique = ""
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Delegated Like/Hate the removed player still owed: hand to another
|
||||||
|
# present crewmate, or drop the question if there's no one left.
|
||||||
|
if p.other_like_from_player_id == removed_id and not p.other_like:
|
||||||
|
alt = pick_helper(p.id)
|
||||||
|
p.other_like_from_player_id = alt.id if alt else None
|
||||||
|
changed = True
|
||||||
|
if p.other_hate_from_player_id == removed_id and not p.other_hate:
|
||||||
|
alt = pick_helper(p.id)
|
||||||
|
p.other_hate_from_player_id = alt.id if alt else None
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Recruit technique slots the removed player was assigned to write.
|
||||||
|
if p.needs_reroll:
|
||||||
|
slots = json.loads(p.incoming_techniques)
|
||||||
|
slots_changed = False
|
||||||
|
for s in slots:
|
||||||
|
if s.get("from_id") == removed_id:
|
||||||
|
alt = pick_helper(p.id)
|
||||||
|
if alt:
|
||||||
|
s["from_id"] = alt.id
|
||||||
|
elif not s.get("text"):
|
||||||
|
# Nobody left to write it — backfill so the recruit can
|
||||||
|
# still finalize.
|
||||||
|
s["from_id"] = None
|
||||||
|
s["text"] = _unused_recruit_technique(game, p.id)
|
||||||
|
else:
|
||||||
|
s["from_id"] = None
|
||||||
|
slots_changed = True
|
||||||
|
if slots_changed:
|
||||||
|
p.incoming_techniques = json.dumps(slots)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
216
src/pirats/crud_rollback.py
Normal file
216
src/pirats/crud_rollback.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""Game-state rollback via full snapshots.
|
||||||
|
|
||||||
|
The whole of a game's gameplay state lives in a handful of small, `game_id`-scoped
|
||||||
|
tables, so a "checkpoint" is just a JSON dump of those rows. Rolling back = restoring
|
||||||
|
the dump. This sidesteps deterministic replay entirely (the deck uses random.shuffle,
|
||||||
|
whose result is materialized into state and captured verbatim).
|
||||||
|
|
||||||
|
Scope (deliberately confined to keep the edge cases out — see the plan):
|
||||||
|
- Checkpoints are captured only while the game is in the `scene` phase, one per action.
|
||||||
|
- Leaving a scene purges that scene's checkpoints, so any surviving checkpoint is in
|
||||||
|
the current scene. Rollback therefore can never cross a scene boundary.
|
||||||
|
|
||||||
|
The event log (`GameEvent`) is an append-only spine and is NOT part of a snapshot.
|
||||||
|
Each event carries a `checkpoint_id` instead (see models.py):
|
||||||
|
None = in-flight (created since the last capture; the next capture tags it)
|
||||||
|
> 0 = a live rollback target within the current scene
|
||||||
|
0 = sealed/historical (a past scene or non-scene event; never rollback-able)
|
||||||
|
|
||||||
|
Rolling back is non-destructive: it restores a checkpoint's snapshot into the live
|
||||||
|
tables and points `Game.rollback_head_checkpoint_id` at it, leaving the later
|
||||||
|
checkpoints/events in place (the frontend greys them). Rolling forward to a later
|
||||||
|
checkpoint "undoes" the rollback. The abandoned future is only discarded when a new
|
||||||
|
action is taken from a rolled-back state (see maintain_rollback_history), which is
|
||||||
|
what commits the new timeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from .models import Game, Player, Obstacle, Challenge, Vote, GameEvent, Checkpoint
|
||||||
|
|
||||||
|
# Game columns that are rollback *control* state, not gameplay — never snapshotted,
|
||||||
|
# and preserved (not overwritten) when a snapshot is restored.
|
||||||
|
SNAPSHOT_EXCLUDE = {"rollback_timeline_version", "rollback_head_checkpoint_id"}
|
||||||
|
# Additionally never reassigned on restore (identity / secrets that don't change).
|
||||||
|
_APPLY_SKIP = SNAPSHOT_EXCLUDE | {"id", "admin_key"}
|
||||||
|
|
||||||
|
# The child tables captured in a snapshot, in the order rows are re-inserted.
|
||||||
|
_CHILD_MODELS = (Player, Obstacle, Challenge, Vote)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_game_state(game: Game) -> str:
|
||||||
|
"""Dump a game's gameplay state (Game minus control columns, plus all child rows)
|
||||||
|
to a JSON string. The event log is intentionally excluded."""
|
||||||
|
return json.dumps({
|
||||||
|
"game": game.model_dump(exclude=SNAPSHOT_EXCLUDE),
|
||||||
|
"players": [p.model_dump() for p in game.players],
|
||||||
|
"obstacles": [o.model_dump() for o in game.obstacles],
|
||||||
|
"challenges": [c.model_dump() for c in game.challenges],
|
||||||
|
"votes": [v.model_dump() for v in game.votes],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def apply_game_state(db: Session, game: Game, state_json: str) -> None:
|
||||||
|
"""Restore a serialized snapshot into the live tables: overwrite the Game row's
|
||||||
|
gameplay columns, then replace all child rows (re-inserted with their original ids
|
||||||
|
so foreign keys and frontend references stay stable)."""
|
||||||
|
blob = json.loads(state_json)
|
||||||
|
|
||||||
|
for key, value in blob["game"].items():
|
||||||
|
if key not in _APPLY_SKIP:
|
||||||
|
setattr(game, key, value)
|
||||||
|
db.add(game)
|
||||||
|
|
||||||
|
# Wipe existing children via the ORM (keeps the identity map consistent so we can
|
||||||
|
# re-insert rows with the same primary keys), then flush before re-inserting.
|
||||||
|
for model in _CHILD_MODELS:
|
||||||
|
for row in db.exec(select(model).where(model.game_id == game.id)).all():
|
||||||
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for row in blob["players"]:
|
||||||
|
db.add(Player(**row))
|
||||||
|
for row in blob["obstacles"]:
|
||||||
|
db.add(Obstacle(**row))
|
||||||
|
for row in blob["challenges"]:
|
||||||
|
db.add(Challenge(**row))
|
||||||
|
for row in blob["votes"]:
|
||||||
|
db.add(Vote(**row))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def capture_checkpoint(db: Session, game: Game) -> Checkpoint:
|
||||||
|
"""Snapshot the post-action state and tag this action's events with it.
|
||||||
|
|
||||||
|
Called from the middleware after a successful mutation while in the `scene` phase.
|
||||||
|
The in-flight events (checkpoint_id IS NULL) are exactly this action's events, since
|
||||||
|
every prior event is already tagged (>0) or sealed (0)."""
|
||||||
|
cp = Checkpoint(game_id=game.id, state_json=serialize_game_state(game))
|
||||||
|
db.add(cp)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cp)
|
||||||
|
|
||||||
|
untagged = db.exec(
|
||||||
|
select(GameEvent).where(
|
||||||
|
GameEvent.game_id == game.id,
|
||||||
|
GameEvent.checkpoint_id.is_(None),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for e in untagged:
|
||||||
|
e.checkpoint_id = cp.id
|
||||||
|
db.add(e)
|
||||||
|
db.commit()
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
def seal_and_purge(db: Session, game: Game) -> None:
|
||||||
|
"""Clear a game's rollback history. Called from the middleware after any mutation
|
||||||
|
outside the `scene` phase, so leaving a scene tidies up and the next scene starts
|
||||||
|
clean: every still-active event is sealed to 0 (committed history) and all
|
||||||
|
checkpoints are deleted."""
|
||||||
|
active = db.exec(
|
||||||
|
select(GameEvent).where(
|
||||||
|
GameEvent.game_id == game.id,
|
||||||
|
# NULL (in-flight) or >0 (current-scene targets); 0 is already sealed.
|
||||||
|
(GameEvent.checkpoint_id.is_(None)) | (GameEvent.checkpoint_id > 0),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for e in active:
|
||||||
|
e.checkpoint_id = 0
|
||||||
|
db.add(e)
|
||||||
|
|
||||||
|
for cp in db.exec(select(Checkpoint).where(Checkpoint.game_id == game.id)).all():
|
||||||
|
db.delete(cp)
|
||||||
|
game.rollback_head_checkpoint_id = None
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _discard_future(db: Session, game: Game, after_checkpoint_id: int) -> None:
|
||||||
|
"""Delete the checkpoints (and their events) created after a checkpoint — the
|
||||||
|
abandoned timeline when a new action is taken from a rolled-back state."""
|
||||||
|
for e in db.exec(
|
||||||
|
select(GameEvent).where(
|
||||||
|
GameEvent.game_id == game.id,
|
||||||
|
GameEvent.checkpoint_id > after_checkpoint_id,
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
db.delete(e)
|
||||||
|
for c in db.exec(
|
||||||
|
select(Checkpoint).where(
|
||||||
|
Checkpoint.game_id == game.id,
|
||||||
|
Checkpoint.id > after_checkpoint_id,
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
db.delete(c)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_checkpoint_id(db: Session, game_id: str) -> Optional[int]:
|
||||||
|
"""The newest checkpoint id for a game (None if there are none). During live play
|
||||||
|
this is the point the game is at — the frontend hides the rollback button on the
|
||||||
|
event sitting at the current position, since rolling there would be a no-op."""
|
||||||
|
cp = db.exec(
|
||||||
|
select(Checkpoint).where(Checkpoint.game_id == game_id)
|
||||||
|
.order_by(Checkpoint.id.desc())
|
||||||
|
).first()
|
||||||
|
return cp.id if cp else None
|
||||||
|
|
||||||
|
|
||||||
|
def maintain_rollback_history(db: Session, game: Game) -> None:
|
||||||
|
"""Called after every successful mutation: snapshot the state while a scene is in
|
||||||
|
progress, otherwise clear the (now-irrelevant) rollback history. This is the only
|
||||||
|
decision the middleware makes; kept here so it can be unit-tested without HTTP."""
|
||||||
|
if game.phase != "scene":
|
||||||
|
seal_and_purge(db, game)
|
||||||
|
return
|
||||||
|
|
||||||
|
# A new action taken from a rolled-back state commits the new timeline: discard the
|
||||||
|
# abandoned future first (its events are tagged with checkpoints past the head),
|
||||||
|
# then clear the head and bump the version so the frontend drops those events.
|
||||||
|
# (SQLite may reuse a discarded checkpoint's id for the next capture; that's safe
|
||||||
|
# since every event referencing it was just deleted.)
|
||||||
|
head = game.rollback_head_checkpoint_id
|
||||||
|
if head is not None:
|
||||||
|
_discard_future(db, game, head)
|
||||||
|
game.rollback_head_checkpoint_id = None
|
||||||
|
game.rollback_timeline_version += 1
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
capture_checkpoint(db, game)
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_to_checkpoint(
|
||||||
|
db: Session, game: Game, player: Player, checkpoint_id: int
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""Restore the game to the given checkpoint and point the head at it.
|
||||||
|
|
||||||
|
Non-destructive: later checkpoints/events are kept (the frontend greys them) so the
|
||||||
|
rollback can be undone by rolling forward again; the same call serves both rollback
|
||||||
|
and redo. Rolling to the latest checkpoint clears the head (back to live play).
|
||||||
|
|
||||||
|
Server-enforced: only during a scene, only by an Admin or a Deep player, and only
|
||||||
|
to a checkpoint belonging to this game (which guarantees the current scene, since
|
||||||
|
older scenes' checkpoints have been purged)."""
|
||||||
|
if game.phase != "scene":
|
||||||
|
return False, "Rollback is only available during a scene."
|
||||||
|
if not (player.is_admin or player.role == "deep"):
|
||||||
|
return False, "Only an Admin or a Deep player can roll back the game."
|
||||||
|
|
||||||
|
cp = db.get(Checkpoint, checkpoint_id)
|
||||||
|
if not cp or cp.game_id != game.id:
|
||||||
|
return False, "That rollback point is no longer available."
|
||||||
|
|
||||||
|
apply_game_state(db, game, cp.state_json)
|
||||||
|
|
||||||
|
latest = db.exec(
|
||||||
|
select(Checkpoint).where(Checkpoint.game_id == game.id)
|
||||||
|
.order_by(Checkpoint.id.desc())
|
||||||
|
).first()
|
||||||
|
game.rollback_head_checkpoint_id = None if checkpoint_id == latest.id else checkpoint_id
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
return True, "Rolled back."
|
||||||
421
src/pirats/crud_scene.py
Normal file
421
src/pirats/crud_scene.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Tuple, Dict, Any
|
||||||
|
from sqlmodel import Session
|
||||||
|
from .models import Game, Player, Obstacle
|
||||||
|
from . import cards
|
||||||
|
from .crud_base import (
|
||||||
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
|
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
||||||
|
change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS,
|
||||||
|
is_eligible_nominee
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# --- Scene Setup Operations ---
|
||||||
|
|
||||||
|
VALID_ROLES = ("pirat", "deep")
|
||||||
|
|
||||||
|
def update_scene_role(db: Session, player_id: str, role: str):
|
||||||
|
# Constrain to the two real roles so the endpoint can't stash an arbitrary
|
||||||
|
# string in the column (defensive: it only ever drives gameplay branches).
|
||||||
|
if role not in VALID_ROLES:
|
||||||
|
return
|
||||||
|
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. First scene: the Rank 3 player must play the Deep, the Rank 1 player must play their Pi-Rat.
|
||||||
|
3. Any player who was Deep in the previous scene must play Pi-Rat in this scene.
|
||||||
|
If valid, starts the scene: shuffles discards into the deck, tops up the Obstacle List
|
||||||
|
(unbeaten Obstacles persist across scenes), and deals starting hands in the first scene.
|
||||||
|
"""
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
if not game:
|
||||||
|
return False, "Game not found."
|
||||||
|
|
||||||
|
if not has_min_players(game):
|
||||||
|
return False, f"You need at least {MIN_PLAYERS} players to start a scene."
|
||||||
|
|
||||||
|
players = game.players
|
||||||
|
|
||||||
|
# Default any unselected roles to 'pirat'. Pending recruits have no Pi-Rat
|
||||||
|
# yet, so they stay roleless and spectate until the next between-scenes.
|
||||||
|
for p in players:
|
||||||
|
if p.role is None and not p.needs_reroll:
|
||||||
|
p.role = "pirat"
|
||||||
|
db.add(p)
|
||||||
|
elif p.needs_reroll:
|
||||||
|
p.role = None
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if game.current_scene_number == 1:
|
||||||
|
# The Rank 3 starter must play the Deep; the Rank 1 starter must play their Pi-Rat.
|
||||||
|
# Rank 2 players may choose either role.
|
||||||
|
if len(players) >= 2:
|
||||||
|
for p in players:
|
||||||
|
if p.rank >= 3 and p.role != "deep":
|
||||||
|
return False, f"{p.name} starts at Rank 3 and must play The Deep in the first scene."
|
||||||
|
if p.rank <= 1 and p.role != "pirat":
|
||||||
|
return False, f"{p.name} starts at Rank 1 and must play their Pi-Rat in the first scene."
|
||||||
|
|
||||||
|
# 1. Check previous Deep players
|
||||||
|
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
|
||||||
|
# Note: Only enforce if this is scene 2+, and we have enough players to make a rotation.
|
||||||
|
if game.current_scene_number > 1 and len(players) >= 3:
|
||||||
|
for p in players:
|
||||||
|
if p.previous_role == "deep" and p.role == "deep":
|
||||||
|
return False, f"{p.name} was Deep in the last scene and must play a Pi-Rat in this scene."
|
||||||
|
|
||||||
|
# 2. Check single eligible Deep player
|
||||||
|
# If only one player is eligible to play the Deep, they must play the Deep.
|
||||||
|
if game.current_scene_number > 1:
|
||||||
|
eligible_deeps = [p for p in players if p.previous_role != "deep" and not p.needs_reroll]
|
||||||
|
if len(eligible_deeps) == 1:
|
||||||
|
must_be_deep_player = eligible_deeps[0]
|
||||||
|
if must_be_deep_player.role != "deep":
|
||||||
|
return False, f"{must_be_deep_player.name} is the only player eligible to play the Deep and must play the Deep in this scene."
|
||||||
|
|
||||||
|
# 3. Check counts
|
||||||
|
pirats_count = sum(1 for p in players if p.role == "pirat")
|
||||||
|
deeps_count = sum(1 for p in players if p.role == "deep")
|
||||||
|
|
||||||
|
if pirats_count < 1:
|
||||||
|
return False, "You need at least one Pi-Rat player to play."
|
||||||
|
if deeps_count < 1:
|
||||||
|
return False, "You need at least one Deep player."
|
||||||
|
|
||||||
|
# Everything is valid! Start the scene.
|
||||||
|
# A. Clear votes and stale challenges. Obstacles persist across scenes.
|
||||||
|
for vote in game.votes:
|
||||||
|
db.delete(vote)
|
||||||
|
for challenge in game.challenges:
|
||||||
|
db.delete(challenge)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# B. Set previous_role to current role, reset per-scene flags
|
||||||
|
for p in players:
|
||||||
|
p.previous_role = p.role
|
||||||
|
p.is_ready = False
|
||||||
|
p.tax_banned = False
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# C. Return discards to the deck and shuffle (cards in hands or on the table stay where they are)
|
||||||
|
reshuffle_discard_pile(db, game)
|
||||||
|
deck = get_game_deck(game)
|
||||||
|
|
||||||
|
# D. Top up the Obstacle List until it holds at least Deep Players + 1 Obstacles,
|
||||||
|
# plus the permanent increase from Jokers previously drawn as Obstacles.
|
||||||
|
required_obstacles = deeps_count + 1 + game.extra_obstacles
|
||||||
|
db.refresh(game)
|
||||||
|
active_count = len(game.obstacles)
|
||||||
|
drawn_count = 0
|
||||||
|
while active_count + drawn_count < required_obstacles:
|
||||||
|
if not deck:
|
||||||
|
break
|
||||||
|
card = deck.pop(0)
|
||||||
|
parsed = cards.parse_card(card)
|
||||||
|
|
||||||
|
if parsed["is_joker"]:
|
||||||
|
# Joker drawn as an Obstacle: discard it and permanently increase the
|
||||||
|
# number of Obstacles required for following scenes by 1.
|
||||||
|
game.extra_obstacles += 1
|
||||||
|
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.", kind="joker")
|
||||||
|
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_count += 1
|
||||||
|
|
||||||
|
# E. Deal starting hands in the first scene. In later scenes hands carry over;
|
||||||
|
# players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw.
|
||||||
|
if game.current_scene_number == 1:
|
||||||
|
for p in players:
|
||||||
|
if p.needs_reroll:
|
||||||
|
continue
|
||||||
|
max_size = calculate_max_hand_size(p, players, game.captain_player_id)
|
||||||
|
current_hand = get_player_hand(p)
|
||||||
|
while len(current_hand) < max_size and deck:
|
||||||
|
current_hand.append(deck.pop(0))
|
||||||
|
p.hand_cards = json.dumps(current_hand)
|
||||||
|
db.add(p)
|
||||||
|
|
||||||
|
game.deck_cards = json.dumps(deck)
|
||||||
|
game.phase = "scene"
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
total_obstacles = active_count + drawn_count
|
||||||
|
add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {drawn_count} new obstacle(s) drawn ({total_obstacles} active).", kind="scene")
|
||||||
|
|
||||||
|
return True, "Scene started successfully!"
|
||||||
|
|
||||||
|
def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
if not game or not player:
|
||||||
|
return []
|
||||||
|
|
||||||
|
players = game.players
|
||||||
|
allowed = ["pirat", "deep"]
|
||||||
|
|
||||||
|
if player.needs_reroll:
|
||||||
|
# Pending recruits spectate until their Pi-Rat is created between scenes
|
||||||
|
return []
|
||||||
|
|
||||||
|
if game.current_scene_number == 1:
|
||||||
|
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either.
|
||||||
|
if len(players) >= 2:
|
||||||
|
if player.rank >= 3:
|
||||||
|
allowed = ["deep"]
|
||||||
|
elif player.rank <= 1:
|
||||||
|
allowed = ["pirat"]
|
||||||
|
elif game.current_scene_number > 1:
|
||||||
|
# Rule 1: Previous Deep must play Pi-Rat (if >= 3 players)
|
||||||
|
if len(players) >= 3 and player.previous_role == "deep":
|
||||||
|
if "deep" in allowed:
|
||||||
|
allowed.remove("deep")
|
||||||
|
|
||||||
|
# Rule 2: If only 1 eligible Deep, they MUST play Deep
|
||||||
|
eligible_deeps = [p for p in players if p.previous_role != "deep" and not p.needs_reroll]
|
||||||
|
if len(eligible_deeps) == 1 and eligible_deeps[0].id == player_id:
|
||||||
|
if "pirat" in allowed:
|
||||||
|
allowed.remove("pirat")
|
||||||
|
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
# --- Scene Gameplay Operations ---
|
||||||
|
|
||||||
|
def resolve_card_against_obstacle(
|
||||||
|
db: Session,
|
||||||
|
game: Game,
|
||||||
|
player: Player,
|
||||||
|
obstacle: Obstacle,
|
||||||
|
card_code: str,
|
||||||
|
acting_rank: int
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Resolves one (non-Joker) card from a player against an Obstacle via the shared
|
||||||
|
rules in evaluate_card_play, appends the card to the Obstacle's column, and
|
||||||
|
updates the Obstacle's current value. Does not touch hands or draws.
|
||||||
|
"""
|
||||||
|
# The last card in the column (or the original Obstacle card) determines the value.
|
||||||
|
played_list = json.loads(obstacle.played_cards)
|
||||||
|
obstacle_card = played_list[-1]["card"] if played_list else obstacle.original_card
|
||||||
|
|
||||||
|
result = evaluate_card_play(player, card_code, obstacle_card, obstacle.current_value, acting_rank)
|
||||||
|
|
||||||
|
# Place the card in the Obstacle's column; it becomes the Obstacle's new value.
|
||||||
|
played_list.append({
|
||||||
|
"card": card_code,
|
||||||
|
"player_id": player.id,
|
||||||
|
"player_name": player.name,
|
||||||
|
"success": result["success"],
|
||||||
|
"details": result["details"]
|
||||||
|
})
|
||||||
|
obstacle.played_cards = json.dumps(played_list)
|
||||||
|
obstacle.current_value = cards.parse_card(card_code)["numeric_value"]
|
||||||
|
db.add(obstacle)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Pirate Luck! A Joker may be played at any time to discard one Obstacle (and its
|
||||||
|
column) from the list entirely and draw a new one. The Joker is then discarded.
|
||||||
|
"""
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
obstacle = db.get(Obstacle, obstacle_id)
|
||||||
|
if not player or not obstacle:
|
||||||
|
return False, "Game entities not found."
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if not game:
|
||||||
|
return False, "Game not found."
|
||||||
|
|
||||||
|
parsed = cards.parse_card(card_code)
|
||||||
|
if not parsed["is_joker"]:
|
||||||
|
return False, "That card is not a Joker."
|
||||||
|
|
||||||
|
hand = get_player_hand(player)
|
||||||
|
if card_code not in hand:
|
||||||
|
return False, "Card not in hand."
|
||||||
|
hand.remove(card_code)
|
||||||
|
set_player_hand(player, hand)
|
||||||
|
db.add(player)
|
||||||
|
|
||||||
|
obstacle_title = obstacle.title
|
||||||
|
|
||||||
|
# Remove the obstacle from any open challenge it was applied to
|
||||||
|
for challenge in game.challenges:
|
||||||
|
if challenge.status != "open":
|
||||||
|
continue
|
||||||
|
obstacle_ids = json.loads(challenge.obstacle_ids)
|
||||||
|
if obstacle_id in obstacle_ids:
|
||||||
|
obstacle_ids.remove(obstacle_id)
|
||||||
|
challenge.obstacle_ids = json.dumps(obstacle_ids)
|
||||||
|
db.add(challenge)
|
||||||
|
|
||||||
|
db.delete(obstacle)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Draw a replacement Obstacle from the deck
|
||||||
|
deck = get_game_deck(game)
|
||||||
|
new_title = None
|
||||||
|
while deck:
|
||||||
|
new_card = deck.pop(0)
|
||||||
|
new_parsed = cards.parse_card(new_card)
|
||||||
|
if new_parsed["is_joker"]:
|
||||||
|
game.extra_obstacles += 1
|
||||||
|
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.", kind="joker")
|
||||||
|
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)
|
||||||
|
new_title = info["title"]
|
||||||
|
break
|
||||||
|
game.deck_cards = json.dumps(deck)
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
msg = f"{player.name} played a Joker! '{obstacle_title}' is discarded"
|
||||||
|
msg += f" and replaced by '{new_title}'." if new_title else " (no replacement left in the deck)."
|
||||||
|
add_game_event(db, game.id, msg, kind="joker")
|
||||||
|
|
||||||
|
return True, msg
|
||||||
|
|
||||||
|
def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool):
|
||||||
|
"""Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly."""
|
||||||
|
player = get_player(db, target_id)
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
if not game:
|
||||||
|
return
|
||||||
|
|
||||||
|
if obj_type.startswith("crew_"):
|
||||||
|
if obj_type == "crew_1":
|
||||||
|
# Stealing a ship no longer auto-anoints a Captain — the crew has to
|
||||||
|
# earn and choose one themselves (Crew Objective 2 + the Captaincy UI).
|
||||||
|
game.completed_crew_1 = status
|
||||||
|
elif obj_type == "crew_2":
|
||||||
|
game.completed_crew_2 = status
|
||||||
|
elif obj_type == "crew_3":
|
||||||
|
game.completed_crew_3 = status
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
obj_name = obj_type.replace('_', ' ').title()
|
||||||
|
status_text = "completed" if status else "unmarked"
|
||||||
|
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
rank_diff = 0
|
||||||
|
if obj_type == "personal_1":
|
||||||
|
if status and not player.completed_personal_1:
|
||||||
|
rank_diff = 1
|
||||||
|
player.needs_gat_description = True
|
||||||
|
elif not status and player.completed_personal_1:
|
||||||
|
rank_diff = -1
|
||||||
|
player.needs_gat_description = False
|
||||||
|
player.gat_description = ""
|
||||||
|
player.completed_personal_1 = status
|
||||||
|
|
||||||
|
elif obj_type == "personal_2":
|
||||||
|
if status and not player.completed_personal_2:
|
||||||
|
rank_diff = 1
|
||||||
|
player.needs_name = True
|
||||||
|
elif not status and player.completed_personal_2:
|
||||||
|
rank_diff = -1
|
||||||
|
player.needs_name = False
|
||||||
|
player.completed_personal_2 = status
|
||||||
|
|
||||||
|
elif obj_type == "personal_3":
|
||||||
|
if status and not player.completed_personal_3:
|
||||||
|
player.needs_rank_3_bonus = True
|
||||||
|
player.is_dead = True
|
||||||
|
elif not status and player.completed_personal_3:
|
||||||
|
player.needs_rank_3_bonus = False
|
||||||
|
player.is_dead = False
|
||||||
|
player.completed_personal_3 = status
|
||||||
|
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if rank_diff != 0:
|
||||||
|
change_player_rank(db, game, player, rank_diff)
|
||||||
|
|
||||||
|
# The Captain remains in power until they give up the position, are defeated, or die.
|
||||||
|
if obj_type == "personal_3" and status and game.captain_player_id == player.id:
|
||||||
|
set_captain(db, game, None, f"{player.name}'s story arc is complete")
|
||||||
|
|
||||||
|
obj_name = obj_type.replace('_', ' ').title()
|
||||||
|
status_text = "completed" if status else "unmarked"
|
||||||
|
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
|
||||||
|
|
||||||
|
def grant_story_bonus_rank(db: Session, game: Game, granter: Player, target):
|
||||||
|
"""A Pi-Rat who finished their story (3rd Personal Objective) grants another
|
||||||
|
eligible Pi-Rat +1 Rank. `target=None` forfeits the bonus (e.g. no eligible
|
||||||
|
crewmate); either way the needs_rank_3_bonus prompt clears. Naming an
|
||||||
|
ineligible target is refused so the prompt stays up rather than waste the Rank."""
|
||||||
|
if not granter.needs_rank_3_bonus:
|
||||||
|
return False, "There is no story Rank bonus to grant."
|
||||||
|
if target is not None and not is_eligible_nominee(granter, target):
|
||||||
|
return False, "Pick a living crewmate who isn't this scene's Deep."
|
||||||
|
if target is not None:
|
||||||
|
change_player_rank(db, game, target, 1)
|
||||||
|
add_game_event(db, game.id, f"{granter.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.", kind="rank")
|
||||||
|
else:
|
||||||
|
add_game_event(db, game.id, f"{granter.name} completed their story, but had no crewmate to pass a Rank to.", kind="rank")
|
||||||
|
granter.needs_rank_3_bonus = False
|
||||||
|
db.add(granter)
|
||||||
|
db.commit()
|
||||||
|
return True, "Rank granted."
|
||||||
|
|
||||||
|
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
||||||
|
obstacle = db.get(Obstacle, obstacle_id)
|
||||||
|
if obstacle:
|
||||||
|
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.", kind="obstacle")
|
||||||
|
db.delete(obstacle)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def finish_game(db: Session, game_id: str):
|
||||||
|
"""Ends the story. The wrap-up screen shows how the crew measured up."""
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
if not game:
|
||||||
|
return
|
||||||
|
game.phase = "ended"
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉", kind="victory")
|
||||||
|
logger.info("Game %s ended after %s scene(s)", game_id, game.current_scene_number)
|
||||||
306
src/pirats/crud_upkeep.py
Normal file
306
src/pirats/crud_upkeep.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from typing import List, Tuple
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from .models import Game, Player, Vote
|
||||||
|
from .crud_base import (
|
||||||
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
|
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
|
||||||
|
calculate_max_hand_size, change_player_rank, set_captain, is_eligible_nominee
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# --- Between Scenes Operations ---
|
||||||
|
|
||||||
|
def eligible_vote_nominees(game: Game, voter) -> list:
|
||||||
|
"""The crewmates `voter` may nominate to rank up. An empty list means the
|
||||||
|
voter has no valid pick and skips voting entirely (no deadlock)."""
|
||||||
|
return [p for p in game.players if is_eligible_nominee(voter, p)]
|
||||||
|
|
||||||
|
def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Every Player (including last scene's Deep Players) nominates one *other* Pi-Rat
|
||||||
|
Player to Rank up. Deep Players from the previous scene cannot be nominated,
|
||||||
|
nor can dead or awaiting-recruit Pi-Rats. A voter whose only options are
|
||||||
|
ineligible skips voting (the frontend lets them ready up without a vote).
|
||||||
|
"""
|
||||||
|
voter = get_player(db, voter_id)
|
||||||
|
nominee = get_player(db, nominated_id)
|
||||||
|
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
|
||||||
|
return False, "Player not found."
|
||||||
|
if not is_eligible_nominee(voter, nominee):
|
||||||
|
return False, "You can only nominate a living crewmate who isn't this scene's Deep — and not yourself."
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
return True, "Vote submitted."
|
||||||
|
|
||||||
|
def unchallenged_pirats(game: Game):
|
||||||
|
"""Living Pi-Rats who have not yet been the target of a Deep Challenge this scene."""
|
||||||
|
challenged_ids = {c.target_player_id for c in game.challenges if c.challenge_type == "deep"}
|
||||||
|
return [p for p in game.players
|
||||||
|
if p.role == "pirat" and not p.is_dead and p.id not in challenged_ids]
|
||||||
|
|
||||||
|
def end_scene_and_transition(db: Session, game_id: str):
|
||||||
|
"""Moves game phase to between_scenes.
|
||||||
|
|
||||||
|
Unless Dev Mode is on, the scene can't end until every living Pi-Rat has
|
||||||
|
faced at least one Deep Challenge, or the Obstacle List has been cleared.
|
||||||
|
(Whether each Pi-Rat had a chance at their Objective is left to table talk.)
|
||||||
|
"""
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
if not game:
|
||||||
|
return False, "Game not found."
|
||||||
|
|
||||||
|
if not game.dev_mode and game.obstacles:
|
||||||
|
waiting = unchallenged_pirats(game)
|
||||||
|
if waiting:
|
||||||
|
names = ", ".join(p.name for p in waiting)
|
||||||
|
return False, (
|
||||||
|
f"Every Pi-Rat must face a Challenge before the scene can end "
|
||||||
|
f"(still waiting on: {names}). Clear the Obstacle List to end early."
|
||||||
|
)
|
||||||
|
|
||||||
|
game.phase = "between_scenes"
|
||||||
|
|
||||||
|
# Clear ready flags for players
|
||||||
|
for p in game.players:
|
||||||
|
p.is_ready = False
|
||||||
|
db.add(p)
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
|
||||||
|
return True, "Scene ended."
|
||||||
|
|
||||||
|
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
|
||||||
|
# Reset the previous scene's winner before tallying this one.
|
||||||
|
game.last_rank_up_player_id = None
|
||||||
|
db.add(game)
|
||||||
|
if not votes:
|
||||||
|
db.commit()
|
||||||
|
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:
|
||||||
|
change_player_rank(db, game, winner, 1)
|
||||||
|
game.last_rank_up_player_id = winner.id
|
||||||
|
db.add(game)
|
||||||
|
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank")
|
||||||
|
|
||||||
|
# Clear all votes
|
||||||
|
for v in votes:
|
||||||
|
db.delete(v)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def begin_next_scene_setup(db: Session, game: Game):
|
||||||
|
"""Advances to the next scene's setup: bump the scene counter and clear roles."""
|
||||||
|
game.current_scene_number += 1
|
||||||
|
game.phase = "scene_setup"
|
||||||
|
game.last_rank_up_player_id = None # Stale once we leave the post-voting screen
|
||||||
|
for p in game.players:
|
||||||
|
p.role = None
|
||||||
|
p.is_ready = False
|
||||||
|
db.add(p)
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
||||||
|
|
||||||
|
def advance_after_upkeep(db: Session, game: Game):
|
||||||
|
"""After votes (and any resting-Deep hand refresh), detour through recruit
|
||||||
|
creation if anyone is waiting on a new Pi-Rat; otherwise straight to setup."""
|
||||||
|
if any(p.needs_reroll for p in game.players):
|
||||||
|
from .crud_character import start_recruit_creation
|
||||||
|
start_recruit_creation(db, game)
|
||||||
|
else:
|
||||||
|
begin_next_scene_setup(db, game)
|
||||||
|
|
||||||
|
def maybe_transition_to_deep_upkeep(db: Session, game: Game):
|
||||||
|
"""In between_scenes, move on once every player has readied up. Safe to call
|
||||||
|
after a roster change to unblock the phase."""
|
||||||
|
if game.players and all(p.is_ready for p in game.players):
|
||||||
|
transition_to_deep_upkeep(db, game.id)
|
||||||
|
|
||||||
|
def transition_to_deep_upkeep(db: Session, game_id: str):
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
if not game:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process votes & rank up winner
|
||||||
|
process_between_scenes_votes(db, game)
|
||||||
|
|
||||||
|
# Reset ready flags
|
||||||
|
for p in game.players:
|
||||||
|
p.is_ready = False
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Check if we have resting Deep players (previous_role was 'deep')
|
||||||
|
has_resting_deep = any(p.previous_role == "deep" for p in game.players)
|
||||||
|
|
||||||
|
if has_resting_deep:
|
||||||
|
game.phase = "deep_upkeep"
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
|
||||||
|
else:
|
||||||
|
# No resting Deep players: skip straight past the deep_upkeep phase
|
||||||
|
advance_after_upkeep(db, game)
|
||||||
|
|
||||||
|
def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
game = get_game(db, player.game_id)
|
||||||
|
if not game:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Discard cards
|
||||||
|
hand = get_player_hand(player)
|
||||||
|
deck = get_game_deck(game)
|
||||||
|
|
||||||
|
for card in discard_cards:
|
||||||
|
if card in hand:
|
||||||
|
hand.remove(card)
|
||||||
|
deck.append(card)
|
||||||
|
|
||||||
|
random.shuffle(deck)
|
||||||
|
set_game_deck(game, deck)
|
||||||
|
set_player_hand(player, hand)
|
||||||
|
db.add(game)
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2. Draw cards back up to max size (based on Rank, per the hand size table)
|
||||||
|
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
|
||||||
|
if len(hand) < max_size:
|
||||||
|
diff = max_size - len(hand)
|
||||||
|
draw_cards_for_player(db, game, player, diff)
|
||||||
|
|
||||||
|
# Mark as ready
|
||||||
|
player.is_ready = True
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Once every resting Deep player has refreshed their hand, move on.
|
||||||
|
maybe_advance_after_deep_upkeep(db, game)
|
||||||
|
|
||||||
|
def maybe_advance_after_deep_upkeep(db: Session, game: Game):
|
||||||
|
"""Leave deep upkeep once every resting Deep player has refreshed. With no
|
||||||
|
resting Deep left (e.g. the last one was kicked) this advances immediately.
|
||||||
|
Safe to call after a roster change to unblock the phase."""
|
||||||
|
resting_deep = [p for p in game.players if p.previous_role == "deep"]
|
||||||
|
if all(p.is_ready for p in resting_deep):
|
||||||
|
advance_after_upkeep(db, game)
|
||||||
|
|
||||||
|
# --- Kicking a player ---
|
||||||
|
|
||||||
|
def recheck_phase_completion(db: Session, game: Game):
|
||||||
|
"""Re-evaluate the current phase's "everyone is done" gate and advance if it
|
||||||
|
is now satisfied. Called after the roster changes (a kick) so a vanished
|
||||||
|
player can't leave the table stuck on a step they'll never complete."""
|
||||||
|
from . import crud_character as cc
|
||||||
|
phase = game.phase
|
||||||
|
if phase == "character_creation":
|
||||||
|
cc.maybe_trigger_technique_swap(db, game)
|
||||||
|
elif phase == "swap_techniques":
|
||||||
|
cc.maybe_finish_technique_swap(db, game)
|
||||||
|
elif phase == "assign_techniques":
|
||||||
|
cc.maybe_finish_assign_techniques(db, game)
|
||||||
|
elif phase == "between_scenes":
|
||||||
|
maybe_transition_to_deep_upkeep(db, game)
|
||||||
|
elif phase == "deep_upkeep":
|
||||||
|
maybe_advance_after_deep_upkeep(db, game)
|
||||||
|
elif phase == "recruit_creation":
|
||||||
|
cc.maybe_finish_recruit_creation(db, game)
|
||||||
|
|
||||||
|
def _remove_player(db: Session, game: Game, target: Player, event_message: str,
|
||||||
|
captain_reason: str, event_kind: str = "warning"):
|
||||||
|
"""Shared mechanics for taking a player out of a game (a kick or a voluntary
|
||||||
|
leave): drop their captaincy, votes and Challenges, repair everyone else's
|
||||||
|
references to them, then delete the row. Their hand and any PvP temp card
|
||||||
|
simply stop being "in play", so reshuffle_discard_pile folds them back into
|
||||||
|
the deck as discards — no hand clearing needed. Finally re-check the current
|
||||||
|
phase's completion gate so a departed blocker can't stall the table."""
|
||||||
|
from . import crud_character as cc
|
||||||
|
target_id = target.id
|
||||||
|
logger.info("Removing player %s (%r) from game %s (%s)", target_id, target.name, game.id, event_kind)
|
||||||
|
|
||||||
|
if game.captain_player_id == target_id:
|
||||||
|
set_captain(db, game, None, reason=captain_reason)
|
||||||
|
|
||||||
|
for v in list(game.votes):
|
||||||
|
if target_id in (v.voter_player_id, v.nominated_player_id):
|
||||||
|
db.delete(v)
|
||||||
|
for ch in list(game.challenges):
|
||||||
|
if target_id in (ch.target_player_id, ch.acting_player_id, ch.challenger_player_id, ch.tax_target_id):
|
||||||
|
db.delete(ch)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
cc.repair_references_to_removed_player(db, game, target_id)
|
||||||
|
|
||||||
|
db.delete(target)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(game)
|
||||||
|
|
||||||
|
add_game_event(db, game.id, event_message, kind=event_kind)
|
||||||
|
recheck_phase_completion(db, game)
|
||||||
|
|
||||||
|
def kick_player(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
|
||||||
|
"""An Admin removes a player mid-game (e.g. one who vanished) so the table
|
||||||
|
isn't left waiting on them."""
|
||||||
|
if target.game_id != game.id:
|
||||||
|
return False, "That player isn't in this crew."
|
||||||
|
admin_count = sum(1 for p in game.players if p.is_admin)
|
||||||
|
if target.is_admin and admin_count <= 1:
|
||||||
|
return False, "You can't kick the last Admin. Grant someone else Admin first."
|
||||||
|
name = target.name
|
||||||
|
_remove_player(db, game, target,
|
||||||
|
f"{name} was kicked from the crew. Their cards return to the discard.",
|
||||||
|
f"{name} was kicked")
|
||||||
|
return True, f"{name} was kicked from the crew."
|
||||||
|
|
||||||
|
def leave_game(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
|
||||||
|
"""A player removes themselves from the game. Same mechanics as a kick; the
|
||||||
|
last Admin must hand off Admin first so the table isn't left without one."""
|
||||||
|
if target.game_id != game.id:
|
||||||
|
return False, "You're not in this crew."
|
||||||
|
admin_count = sum(1 for p in game.players if p.is_admin)
|
||||||
|
if target.is_admin and admin_count <= 1:
|
||||||
|
return False, "You're the last Admin — grant someone else Admin before you leave."
|
||||||
|
name = target.name
|
||||||
|
_remove_player(db, game, target,
|
||||||
|
f"{name} left the crew. Their cards return to the discard.",
|
||||||
|
f"{name} left", event_kind="join")
|
||||||
|
return True, f"{name} left the crew."
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
from sqlmodel import SQLModel, create_engine, Session
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from sqlmodel import Session, create_engine
|
||||||
|
|
||||||
# Allow configuring via environment variable
|
# Allow configuring via environment variable
|
||||||
sqlite_url = os.environ.get("DATABASE_URL")
|
sqlite_url = os.environ.get("DATABASE_URL")
|
||||||
if not sqlite_url:
|
if not sqlite_url:
|
||||||
@@ -11,8 +15,68 @@ if not sqlite_url:
|
|||||||
# Use connect_args={"check_same_thread": False} for SQLite in FastAPI
|
# Use connect_args={"check_same_thread": False} for SQLite in FastAPI
|
||||||
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
|
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
def create_db_and_tables():
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
SQLModel.metadata.create_all(engine)
|
# The revision that matches the schema produced by the pre-Alembic
|
||||||
|
# create_all + ad-hoc ALTER TABLE era.
|
||||||
|
BASELINE_REVISION = "0001"
|
||||||
|
|
||||||
|
|
||||||
|
def _alembic_config() -> Config:
|
||||||
|
cfg = Config()
|
||||||
|
cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
|
||||||
|
cfg.attributes["engine"] = engine
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _upgrade_legacy_db() -> None:
|
||||||
|
"""Bring a pre-Alembic database up to the baseline (0001) schema.
|
||||||
|
|
||||||
|
This is the old ad-hoc migration list, kept only so existing databases
|
||||||
|
can be stamped at the baseline. New columns go in Alembic revisions,
|
||||||
|
not here.
|
||||||
|
"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
columns = [
|
||||||
|
("game", "crew_name", "VARCHAR"),
|
||||||
|
("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("game", "captain_player_id", "VARCHAR"),
|
||||||
|
("player", "needs_name", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("player", "needs_rank_3_bonus", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("player", "player_name", "VARCHAR DEFAULT ''"),
|
||||||
|
("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
|
||||||
|
]
|
||||||
|
for table, col, def_type in columns:
|
||||||
|
try:
|
||||||
|
session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}"))
|
||||||
|
except Exception:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
|
# Backfill: pre-existing players used a single name for both player and Pi-Rat
|
||||||
|
session.execute(
|
||||||
|
text("UPDATE player SET player_name = name WHERE player_name = '' OR player_name IS NULL")
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations() -> None:
|
||||||
|
"""Migrate the database to the latest schema.
|
||||||
|
|
||||||
|
Fresh databases are built entirely by Alembic. Databases that predate
|
||||||
|
Alembic (tables exist but no alembic_version) are first normalized to
|
||||||
|
the baseline schema, stamped, and then upgraded normally.
|
||||||
|
"""
|
||||||
|
cfg = _alembic_config()
|
||||||
|
inspector = inspect(engine)
|
||||||
|
if inspector.has_table("game") and not inspector.has_table("alembic_version"):
|
||||||
|
_upgrade_legacy_db()
|
||||||
|
command.stamp(cfg, BASELINE_REVISION)
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
def get_session():
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
|
|||||||
@@ -1,54 +1,256 @@
|
|||||||
import json
|
import asyncio
|
||||||
from typing import List, Optional
|
import contextlib
|
||||||
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status
|
from contextlib import asynccontextmanager
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from typing import Optional
|
||||||
|
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import crud
|
from . import crud
|
||||||
from . import cards
|
from . import maintenance
|
||||||
from .database import create_db_and_tables, get_session
|
from .database import run_migrations, get_session, engine
|
||||||
from .models import Game, Player, Obstacle, Challenge, Vote
|
from .validation import sanitize_name
|
||||||
|
from .ws import manager
|
||||||
|
|
||||||
app = FastAPI(title="Rats with Gats Remote Play")
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Get the directory of this module
|
EVENT_PAGE_SIZE = 50
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
|
||||||
|
|
||||||
# Mount static files
|
BASE_DIR = Path(__file__).parent
|
||||||
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
|
||||||
|
|
||||||
# Templates setup
|
_logging_configured = False
|
||||||
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
|
||||||
|
|
||||||
# Register startup event to create tables
|
def configure_logging() -> None:
|
||||||
@app.on_event("startup")
|
"""Set up backend logging: always to stderr, plus a rotating file when
|
||||||
def on_startup():
|
PIRATS_LOG_FILE is set (the NixOS service points it at /var/log/pirats/).
|
||||||
create_db_and_tables()
|
Level comes from PIRATS_LOG_LEVEL (default INFO). Idempotent so it's safe to
|
||||||
|
call from both the CLI entrypoint and the app's startup lifespan."""
|
||||||
|
global _logging_configured
|
||||||
|
if _logging_configured:
|
||||||
|
return
|
||||||
|
_logging_configured = True
|
||||||
|
|
||||||
# --- HTTP Route Handlers ---
|
level_name = os.environ.get("PIRATS_LOG_LEVEL", "INFO").upper()
|
||||||
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
root = logging.getLogger()
|
||||||
def home(request: Request):
|
root.setLevel(level)
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
fmt = logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
@app.post("/game/create")
|
stderr_handler = logging.StreamHandler(sys.stderr)
|
||||||
def create_game_route(db: Session = Depends(get_session)):
|
stderr_handler.setFormatter(fmt)
|
||||||
game = crud.create_game(db)
|
root.addHandler(stderr_handler)
|
||||||
# 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)
|
log_file = os.environ.get("PIRATS_LOG_FILE")
|
||||||
def join_game_form(request: Request, game_id: str, creator: bool = False, db: Session = Depends(get_session)):
|
if log_file:
|
||||||
game = crud.get_game(db, game_id)
|
try:
|
||||||
if not game:
|
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
file_handler = RotatingFileHandler(
|
||||||
return templates.TemplateResponse("join_form.html", {"request": request, "game": game, "creator": creator})
|
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(fmt)
|
||||||
|
root.addHandler(file_handler)
|
||||||
|
logger.info("Logging to %s", log_file)
|
||||||
|
except OSError:
|
||||||
|
# A bad/unwritable path mustn't take the server down — keep stderr.
|
||||||
|
logger.exception("Could not open log file %s; logging to stderr only", log_file)
|
||||||
|
|
||||||
@app.post("/game/{game_id}/join")
|
DEFAULT_MAX_BODY_BYTES = 1024 * 1024 # 1 MiB — far above any legitimate form post
|
||||||
|
|
||||||
|
def max_body_bytes() -> int:
|
||||||
|
"""Largest request body to accept, from PIRATS_MAX_BODY_BYTES (default 1 MiB)."""
|
||||||
|
val = os.environ.get("PIRATS_MAX_BODY_BYTES")
|
||||||
|
if val is None:
|
||||||
|
return DEFAULT_MAX_BODY_BYTES
|
||||||
|
try:
|
||||||
|
return max(int(val), 0)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid PIRATS_MAX_BODY_BYTES=%r; using default", val)
|
||||||
|
return DEFAULT_MAX_BODY_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
class _BodyTooLarge(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LimitRequestBodyMiddleware:
|
||||||
|
"""Reject request bodies larger than `max_bytes` with HTTP 413 before they are
|
||||||
|
buffered into memory. The app is on the open internet, so this caps the damage
|
||||||
|
a single oversized POST can do. The declared Content-Length is checked up front
|
||||||
|
(the common case); the bytes actually streamed are then counted too, so a
|
||||||
|
chunked or length-omitting client can't slip past the header check."""
|
||||||
|
|
||||||
|
def __init__(self, app, max_bytes: int):
|
||||||
|
self.app = app
|
||||||
|
self.max_bytes = max_bytes
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
for name, value in scope.get("headers", []):
|
||||||
|
if name == b"content-length":
|
||||||
|
try:
|
||||||
|
declared = int(value)
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
if declared > self.max_bytes:
|
||||||
|
await self._send_413(send)
|
||||||
|
return
|
||||||
|
break
|
||||||
|
|
||||||
|
received = 0
|
||||||
|
response_started = False
|
||||||
|
|
||||||
|
async def capped_receive():
|
||||||
|
nonlocal received
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.request":
|
||||||
|
received += len(message.get("body", b""))
|
||||||
|
if received > self.max_bytes:
|
||||||
|
raise _BodyTooLarge()
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def watched_send(message):
|
||||||
|
nonlocal response_started
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
response_started = True
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.app(scope, capped_receive, watched_send)
|
||||||
|
except _BodyTooLarge:
|
||||||
|
# The handler reads the whole body before responding, so nothing has
|
||||||
|
# been sent yet; if a response somehow already started, we can only stop.
|
||||||
|
if not response_started:
|
||||||
|
await self._send_413(send)
|
||||||
|
|
||||||
|
async def _send_413(self, send):
|
||||||
|
body = b'{"error":"Request body too large."}'
|
||||||
|
await send({
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 413,
|
||||||
|
"headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(body)).encode()),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await send({"type": "http.response.body", "body": body})
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
configure_logging()
|
||||||
|
logger.info(
|
||||||
|
"Starting Rats with Gats backend (db=%s, dev_mode_default=%s)",
|
||||||
|
engine.url.render_as_string(hide_password=True),
|
||||||
|
crud.dev_mode_default(),
|
||||||
|
)
|
||||||
|
run_migrations()
|
||||||
|
logger.info("Database migrations applied")
|
||||||
|
|
||||||
|
purge_cfg = maintenance.purge_config()
|
||||||
|
purge_task = None
|
||||||
|
if purge_cfg.enabled:
|
||||||
|
purge_task = asyncio.create_task(maintenance.purge_loop(purge_cfg))
|
||||||
|
else:
|
||||||
|
logger.info("Game purge task disabled (PIRATS_PURGE_ENABLED)")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
if purge_task is not None:
|
||||||
|
purge_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await purge_task
|
||||||
|
logger.info("Shutting down")
|
||||||
|
|
||||||
|
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)
|
||||||
|
api = APIRouter()
|
||||||
|
|
||||||
|
# Every state mutation is a POST under /api/game/{game_id}/..., so one
|
||||||
|
# middleware can notify a game's websocket clients after any successful write.
|
||||||
|
GAME_PATH_RE = re.compile(r"^/api/game/([^/]+)/")
|
||||||
|
# The full-state rollback endpoint manages its own history, so it's the one mutation
|
||||||
|
# we don't checkpoint. Matched precisely (not a bare "/rollback" suffix) so it stays
|
||||||
|
# unambiguous if other "*/rollback" endpoints are ever added.
|
||||||
|
STATE_ROLLBACK_PATH_RE = re.compile(r"/player/[^/]+/rollback$")
|
||||||
|
|
||||||
|
def _checkpoint_after_mutation(game_id: str):
|
||||||
|
"""After a successful mutation, maintain rollback history: snapshot the state
|
||||||
|
while a scene is in progress, otherwise clear any scene's history. Runs in its own
|
||||||
|
session (the request's has already committed and closed by the time this fires)."""
|
||||||
|
with Session(engine) as db:
|
||||||
|
game = crud.get_game(db, game_id)
|
||||||
|
if game:
|
||||||
|
crud.maintain_rollback_history(db, game)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def broadcast_state_changes(request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
if request.method == "POST" and response.status_code < 400:
|
||||||
|
match = GAME_PATH_RE.match(request.url.path)
|
||||||
|
if match:
|
||||||
|
game_id = match.group(1)
|
||||||
|
# The full-state rollback endpoint manages its own state; don't checkpoint it.
|
||||||
|
if not STATE_ROLLBACK_PATH_RE.search(request.url.path):
|
||||||
|
try:
|
||||||
|
await run_in_threadpool(_checkpoint_after_mutation, game_id)
|
||||||
|
except Exception:
|
||||||
|
# A checkpointing failure must not fail the user's action, which
|
||||||
|
# has already committed; the next mutation will checkpoint again.
|
||||||
|
logger.exception("Checkpoint capture failed for game %s", game_id)
|
||||||
|
await manager.broadcast(game_id, {"type": "state_changed"})
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Added last so it wraps outermost: oversized bodies are rejected before any
|
||||||
|
# other middleware or the route handler buffers them.
|
||||||
|
app.add_middleware(LimitRequestBodyMiddleware, max_bytes=max_body_bytes())
|
||||||
|
|
||||||
|
@app.websocket("/api/game/{game_id}/ws")
|
||||||
|
async def game_websocket(websocket: WebSocket, game_id: str):
|
||||||
|
await manager.connect(game_id, websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Clients don't send anything meaningful; this just detects disconnects.
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
manager.disconnect(game_id, websocket)
|
||||||
|
|
||||||
|
# --- API Core Route Handlers ---
|
||||||
|
|
||||||
|
@api.post("/game")
|
||||||
|
def create_game_route(
|
||||||
|
crew_name: str = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
game = crud.create_game(db, crew_name=sanitize_name(crew_name))
|
||||||
|
# admin_key is only revealed here, to the creator; the state endpoint hides it
|
||||||
|
return {"id": game.id, "admin_key": game.admin_key}
|
||||||
|
|
||||||
|
@api.get("/game/code/{join_code}")
|
||||||
|
def find_game_by_join_code(join_code: str, db: Session = Depends(get_session)):
|
||||||
|
normalized = join_code.strip().upper()
|
||||||
|
game = crud.get_game_by_join_code(db, normalized)
|
||||||
|
if not game or game.phase == "ended":
|
||||||
|
raise HTTPException(status_code=404, detail="No active game found for that join code")
|
||||||
|
return {"id": game.id}
|
||||||
|
|
||||||
|
@api.post("/game/{game_id}/join")
|
||||||
def join_game_submit(
|
def join_game_submit(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
name: str = Form(...),
|
name: str = Form(...),
|
||||||
@@ -60,635 +262,97 @@ def join_game_submit(
|
|||||||
|
|
||||||
# First player is automatically the creator
|
# First player is automatically the creator
|
||||||
is_creator = len(game.players) == 0
|
is_creator = len(game.players) == 0
|
||||||
player = crud.add_player(db, game_id, name.strip(), is_creator=is_creator)
|
player = crud.add_player(db, game_id, sanitize_name(name), is_creator=is_creator)
|
||||||
|
return {"id": player.id}
|
||||||
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)
|
@api.post("/game/{game_id}/player/{player_id}/leave")
|
||||||
def player_dashboard(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)):
|
def leave_game_route(game_id: str, player_id: str, db: Session = Depends(get_session)):
|
||||||
|
"""A player voluntarily removes themselves from the game (counterpart to join)."""
|
||||||
|
game = crud.get_game(db, game_id)
|
||||||
|
player = crud.get_player(db, player_id)
|
||||||
|
if not game or not player or player.game_id != game.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||||
|
ok, msg = crud.leave_game(db, game, player)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@api.get("/game/{game_id}/player/{player_id}/state")
|
||||||
|
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
player = crud.get_player(db, player_id)
|
player = crud.get_player(db, player_id)
|
||||||
if not game or not player:
|
if not game or not player:
|
||||||
raise HTTPException(status_code=404, detail="Game or Player not found")
|
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||||
|
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player
|
|
||||||
})
|
|
||||||
|
|
||||||
# Creator Admin Panel
|
return {
|
||||||
@app.get("/game/{game_id}/admin", response_class=HTMLResponse)
|
# admin_key stays hidden except from Admins, who need it to open the admin panel
|
||||||
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
|
"game": game.model_dump(exclude=(set() if player.is_admin else {"admin_key"})),
|
||||||
game = crud.get_game(db, game_id)
|
"player": player.model_dump(),
|
||||||
if not game:
|
# A swap offer must reveal nothing but its existence: only the offerer
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
# gets to see which technique they put on the table.
|
||||||
if game.admin_key != key:
|
"players": [p.model_dump(exclude=(set() if p.id == player.id else {"swap_offer_technique"})) for p in game.players],
|
||||||
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
|
"obstacles": [o.model_dump() for o in game.obstacles],
|
||||||
|
"challenges": [c.model_dump() for c in game.challenges],
|
||||||
base_url = str(request.base_url).rstrip("/")
|
"votes": [v.model_dump() for v in game.votes],
|
||||||
return templates.TemplateResponse("admin.html", {
|
"events": [e.model_dump() for e in events],
|
||||||
"request": request,
|
"events_have_more": events_have_more,
|
||||||
"game": game,
|
# The newest checkpoint id, so the log can tell which event is "you are here".
|
||||||
"base_url": base_url
|
"latest_checkpoint_id": crud.latest_checkpoint_id(db, game_id),
|
||||||
})
|
|
||||||
|
|
||||||
# --- HTMX Snippet and Phase Polling Routes ---
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/player/{player_id}/phase-check")
|
|
||||||
def phase_check_route(game_id: str, player_id: str, current: str, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
|
||||||
if game.phase != current:
|
|
||||||
# Phase changed! Trigger page reload on client via HTMX header
|
|
||||||
return HTMLResponse(status_code=200, headers={"HX-Trigger": "phase-changed"})
|
|
||||||
return HTMLResponse(status_code=204) # No content, no reload
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/lobby/players", response_class=HTMLResponse)
|
|
||||||
def lobby_players_snippet(request: Request, game_id: str, player_id: Optional[str] = None, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
return HTMLResponse("")
|
|
||||||
return templates.TemplateResponse("lobby_players_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player_id": player_id
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/scene/roster", response_class=HTMLResponse)
|
|
||||||
def scene_roster_snippet(request: Request, game_id: str, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
return HTMLResponse("")
|
|
||||||
return templates.TemplateResponse("scene_roster_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"is_captain": crud.is_player_captain
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/scene/obstacles", response_class=HTMLResponse)
|
|
||||||
def scene_obstacles_snippet(request: Request, game_id: str, player_rank: int, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
return HTMLResponse("")
|
|
||||||
return templates.TemplateResponse("scene_obstacles_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player_rank": player_rank,
|
|
||||||
"parse_card": cards.parse_card,
|
|
||||||
"json_loads": json.loads
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/scene/challenges", response_class=HTMLResponse)
|
|
||||||
def scene_challenges_snippet(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
return HTMLResponse("")
|
|
||||||
|
|
||||||
def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]:
|
|
||||||
return db.get(Obstacle, obs_id)
|
|
||||||
|
|
||||||
hand = crud.get_player_hand(player)
|
|
||||||
return templates.TemplateResponse("scene_challenges_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"hand": hand,
|
|
||||||
"parse_card": cards.parse_card,
|
|
||||||
"get_obstacle_by_id": get_obstacle_by_id,
|
|
||||||
"json_loads": json.loads
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/between/roster", response_class=HTMLResponse)
|
|
||||||
def between_scenes_roster_snippet(request: Request, game_id: str, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
return HTMLResponse("")
|
|
||||||
return templates.TemplateResponse("voting_roster_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game
|
|
||||||
})
|
|
||||||
|
|
||||||
# --- HTMX View Polling Render Route ---
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/player/{player_id}/view", response_class=HTMLResponse)
|
|
||||||
def get_game_phase_view(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
return HTMLResponse("<p class='alert alert-danger'>Session disconnected or expired.</p>")
|
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
|
||||||
|
|
||||||
# Context helper functions to run inside Jinja templates
|
|
||||||
def get_player_name_by_id(*args) -> str:
|
|
||||||
pid = args[1] if len(args) == 2 else (args[0] if len(args) == 1 else None)
|
|
||||||
if not pid:
|
|
||||||
return "None"
|
|
||||||
p = db.get(Player, pid)
|
|
||||||
return p.name if p else "Unknown"
|
|
||||||
|
|
||||||
def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]:
|
|
||||||
return db.get(Obstacle, obs_id)
|
|
||||||
|
|
||||||
# Core variables
|
|
||||||
hand = crud.get_player_hand(player)
|
|
||||||
max_hand_size = crud.calculate_max_hand_size(player, game.players)
|
|
||||||
|
|
||||||
# Compute inbox tasks for current player
|
|
||||||
inbox_tasks = []
|
|
||||||
for p in game.players:
|
|
||||||
if p.id != player.id:
|
|
||||||
if p.other_like_from_player_id == player.id and not p.other_like:
|
|
||||||
inbox_tasks.append({"player": p, "type": "like"})
|
|
||||||
if p.other_hate_from_player_id == player.id and not p.other_hate:
|
|
||||||
inbox_tasks.append({"player": p, "type": "hate"})
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"hand": hand,
|
|
||||||
"max_hand_size": max_hand_size,
|
|
||||||
"base_url": base_url,
|
|
||||||
"db": db,
|
|
||||||
"get_player_name": get_player_name_by_id,
|
|
||||||
"get_obstacle_by_id": get_obstacle_by_id,
|
|
||||||
"parse_card": cards.parse_card,
|
|
||||||
"is_captain": crud.is_player_captain,
|
|
||||||
"json_loads": json.loads,
|
|
||||||
"deck_count": len(crud.get_game_deck(game)),
|
|
||||||
"inbox_tasks": inbox_tasks
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Select template based on current game phase
|
|
||||||
if game.phase == "lobby":
|
|
||||||
return templates.TemplateResponse("lobby_partial.html", context)
|
|
||||||
elif game.phase in ["character_creation", "swap_techniques"]:
|
|
||||||
return templates.TemplateResponse("character_creation_partial.html", context)
|
|
||||||
elif game.phase == "scene_setup":
|
|
||||||
return templates.TemplateResponse("scene_setup_partial.html", context)
|
|
||||||
elif game.phase == "scene":
|
|
||||||
return templates.TemplateResponse("scene_partial.html", context)
|
|
||||||
elif game.phase == "between_scenes":
|
|
||||||
return templates.TemplateResponse("between_scenes_partial.html", context)
|
|
||||||
|
|
||||||
return HTMLResponse("<p>Unknown game state.</p>")
|
|
||||||
|
|
||||||
# --- HTMX Action Handlers ---
|
@api.get("/game/{game_id}/events")
|
||||||
|
def get_game_events(
|
||||||
# 1. Start character creation from lobby
|
game_id: str,
|
||||||
@app.post("/game/{game_id}/lobby/start")
|
before: Optional[float] = None,
|
||||||
def lobby_start_character_creation(game_id: str, db: Session = Depends(get_session)):
|
limit: int = EVENT_PAGE_SIZE,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Pages back through the event log: returns the newest `limit` events older than `before`."""
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
if not game:
|
if not game:
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
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
|
limit = max(1, min(limit, 200))
|
||||||
@app.post("/game/{game_id}/player/{player_id}/save-basic")
|
events, have_more = crud.get_events_page(db, game_id, before=before, limit=limit)
|
||||||
def player_save_basic_details(
|
return {
|
||||||
game_id: str,
|
"events": [e.model_dump() for e in events],
|
||||||
player_id: str,
|
"have_more": have_more,
|
||||||
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)
|
# --- Register Modular Phase Routers ---
|
||||||
@app.post("/game/{game_id}/player/{player_id}/edit-basic")
|
from .routes_lobby import router as lobby_router
|
||||||
def player_edit_basic_details(
|
from .routes_character import router as character_router
|
||||||
game_id: str,
|
from .routes_scene import router as scene_router
|
||||||
player_id: str,
|
from .routes_challenge import router as challenge_router
|
||||||
db: Session = Depends(get_session)
|
from .routes_upkeep import router as upkeep_router
|
||||||
):
|
from .routes_admin import router as admin_router
|
||||||
player = crud.get_player(db, player_id)
|
from .routes_rollback import router as rollback_router
|
||||||
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
|
api.include_router(lobby_router)
|
||||||
@app.post("/game/{game_id}/player/{player_id}/delegate/auto")
|
api.include_router(character_router)
|
||||||
def player_auto_delegate(
|
api.include_router(scene_router)
|
||||||
game_id: str,
|
api.include_router(challenge_router)
|
||||||
player_id: str,
|
api.include_router(upkeep_router)
|
||||||
db: Session = Depends(get_session)
|
api.include_router(admin_router)
|
||||||
):
|
api.include_router(rollback_router)
|
||||||
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)
|
# Mount API at /api
|
||||||
@app.get("/game/{game_id}/player/{player_id}/crew-status", response_class=HTMLResponse)
|
app.include_router(api, prefix="/api")
|
||||||
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
|
# Static rulebook page (registered before the SPA mount so it takes precedence)
|
||||||
@app.post("/game/{game_id}/player/{player_id}/delegate/{question_type}", response_class=HTMLResponse)
|
@app.get("/rules", include_in_schema=False)
|
||||||
def player_delegate_question(
|
def rules_page():
|
||||||
request: Request,
|
return FileResponse(BASE_DIR / "rules.html", media_type="text/html")
|
||||||
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
|
# Mount SPA
|
||||||
@app.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}", response_class=HTMLResponse)
|
static_dir = BASE_DIR / "static"
|
||||||
def player_revoke_delegation(
|
if os.path.exists(static_dir):
|
||||||
request: Request,
|
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
question_type: str, # "like" or "hate"
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
|
||||||
if question_type == "like":
|
|
||||||
player.other_like_from_player_id = None
|
|
||||||
player.other_like = ""
|
|
||||||
elif question_type == "hate":
|
|
||||||
player.other_hate_from_player_id = None
|
|
||||||
player.other_hate = ""
|
|
||||||
db.add(player)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
|
||||||
|
|
||||||
def get_player_name_by_id(pid: Optional[str]) -> str:
|
|
||||||
if not pid:
|
|
||||||
return "None"
|
|
||||||
p = db.get(Player, pid)
|
|
||||||
return p.name if p else "Unknown"
|
|
||||||
|
|
||||||
return templates.TemplateResponse("delegation_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"question_type": question_type,
|
|
||||||
"get_player_name": get_player_name_by_id
|
|
||||||
})
|
|
||||||
|
|
||||||
# Fetch the delegation card status (for background polling when waiting)
|
|
||||||
@app.get("/game/{game_id}/player/{player_id}/delegation/{question_type}", response_class=HTMLResponse)
|
|
||||||
def player_get_delegation_status(
|
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
question_type: str, # "like" or "hate"
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
|
|
||||||
def get_player_name_by_id(pid: Optional[str]) -> str:
|
|
||||||
if not pid:
|
|
||||||
return "None"
|
|
||||||
p = db.get(Player, pid)
|
|
||||||
return p.name if p else "Unknown"
|
|
||||||
|
|
||||||
return templates.TemplateResponse("delegation_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"question_type": question_type,
|
|
||||||
"get_player_name": get_player_name_by_id
|
|
||||||
})
|
|
||||||
|
|
||||||
# 5. Answer a delegated question for another player
|
|
||||||
@app.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
|
||||||
def player_submit_delegated_answer(
|
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
|
||||||
player_id: str, # The answering player
|
|
||||||
target_player_id: str, # The player whose sheet we are filling out
|
|
||||||
question_type: str, # "like" or "hate"
|
|
||||||
answer: str = Form(...),
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id)
|
|
||||||
|
|
||||||
# Re-fetch data to render the updated inbox snippet
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
|
|
||||||
# Compute inbox tasks
|
|
||||||
inbox_tasks = []
|
|
||||||
for p in game.players:
|
|
||||||
if p.id != player.id:
|
|
||||||
if p.other_like_from_player_id == player.id and not p.other_like:
|
|
||||||
inbox_tasks.append({"player": p, "type": "like"})
|
|
||||||
if p.other_hate_from_player_id == player.id and not p.other_hate:
|
|
||||||
inbox_tasks.append({"player": p, "type": "hate"})
|
|
||||||
|
|
||||||
return templates.TemplateResponse("inbox_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"inbox_tasks": inbox_tasks
|
|
||||||
})
|
|
||||||
|
|
||||||
# 6. Submit 3 Secret Pirate Techniques
|
|
||||||
@app.post("/game/{game_id}/player/{player_id}/submit-techniques")
|
|
||||||
def player_submit_techniques(
|
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
tech1: str = Form(...),
|
|
||||||
tech2: str = Form(...),
|
|
||||||
tech3: str = Form(...),
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip())
|
|
||||||
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
|
|
||||||
return templates.TemplateResponse("techniques_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"json_loads": json.loads
|
|
||||||
})
|
|
||||||
|
|
||||||
# 7. Assign Swapped Techniques to Face Cards (Jack, Queen, King)
|
|
||||||
@app.post("/game/{game_id}/player/{player_id}/assign-face-techniques")
|
|
||||||
def player_assign_face_techniques(
|
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
jack: str = Form(...),
|
|
||||||
queen: str = Form(...),
|
|
||||||
king: str = Form(...),
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
if len({jack, queen, king}) < 3:
|
|
||||||
# Prevent assigning the same technique to multiple face cards
|
|
||||||
return HTMLResponse("<p class='alert alert-danger'>Error: Each card must be assigned a unique technique.</p>", status_code=400)
|
|
||||||
|
|
||||||
crud.assign_face_card_techniques(db, player_id, jack, queen, king)
|
|
||||||
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
|
|
||||||
return templates.TemplateResponse("techniques_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"json_loads": json.loads
|
|
||||||
})
|
|
||||||
|
|
||||||
# 8. Set Role during Scene Setup
|
|
||||||
@app.post("/game/{game_id}/player/{player_id}/set-role")
|
|
||||||
def player_set_role(
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
role: str,
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
crud.update_scene_role(db, player_id, role)
|
|
||||||
return HTMLResponse(status_code=204)
|
|
||||||
|
|
||||||
# 9. Start Scene (Verify Setup & Shuffle & Draw Obstacles)
|
|
||||||
@app.post("/game/{game_id}/scene/start")
|
|
||||||
def start_scene_route(request: Request, game_id: str, db: Session = Depends(get_session)):
|
|
||||||
success, msg = crud.confirm_scene_setup(db, game_id)
|
|
||||||
if not success:
|
|
||||||
# Render the setup partial again with the error message
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
# We need a player to render the partial, let's grab any player or the one who requested it.
|
|
||||||
# But we don't have the player_id in this path. Let's make sure we pass the error message,
|
|
||||||
# or we just return an HTTP 400 with the error text so HTMX displays it!
|
|
||||||
return HTMLResponse(f"<div class='alert alert-danger'>{msg}</div>", status_code=400)
|
|
||||||
|
|
||||||
return HTMLResponse(status_code=204)
|
|
||||||
|
|
||||||
# 10. Deep creates a new challenge
|
|
||||||
@app.post("/game/{game_id}/challenge/create")
|
|
||||||
def create_challenge_route(
|
|
||||||
game_id: str,
|
|
||||||
title: str = Form(...),
|
|
||||||
description: str = Form(""),
|
|
||||||
obstacle_ids: List[str] = Form(...),
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
crud.create_challenge(db, game_id, title.strip(), description.strip(), obstacle_ids)
|
|
||||||
return HTMLResponse(status_code=204)
|
|
||||||
|
|
||||||
# 11. Pi-Rat plays card against an obstacle in a challenge
|
|
||||||
@app.post("/game/{game_id}/player/{player_id}/play-card")
|
|
||||||
def play_card_route(
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
challenge_id: str = Form(...),
|
|
||||||
obstacle_id: str = Form(...),
|
|
||||||
card_code: str = Form(...),
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code, challenge_id)
|
|
||||||
if not ok:
|
|
||||||
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
|
|
||||||
|
|
||||||
# Return a success notification box that HTMX will show, or just return 204 to let polling pick it up.
|
|
||||||
# Returning a message is nice! But returning 204 is clean. Let's return a brief HTML snippet.
|
|
||||||
color_prefix = "🟩" if res["success"] else "🟥"
|
|
||||||
drew_text = f" (Drew card: {res['drew_card']})" if res["drew_card"] else ""
|
|
||||||
return HTMLResponse(
|
|
||||||
f"<div class='play-notification' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
|
|
||||||
f"<p>{color_prefix} {res['details']}{drew_text}</p>"
|
|
||||||
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
|
|
||||||
f"</div>"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 12. Pi-Rat plays a Joker to replace an obstacle
|
|
||||||
@app.post("/game/{game_id}/player/{player_id}/play-joker")
|
|
||||||
def play_joker_route(
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
card_code: str = Form(...),
|
|
||||||
obstacle_id: str = Form(...),
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
# Retrieve any active challenge that contains this obstacle to use as a dummy,
|
|
||||||
# or let play_card_on_obstacle handle challenge_id=None.
|
|
||||||
# Wait, we can pass challenge_id = "" and check in play_card if it exists.
|
|
||||||
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code, "")
|
|
||||||
if not ok:
|
|
||||||
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
|
|
||||||
|
|
||||||
return HTMLResponse(
|
|
||||||
f"<div class='play-notification' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
|
|
||||||
f"<p>🃏 Play Joker: Discarded obstacle and drew a new replacement!</p>"
|
|
||||||
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
|
|
||||||
f"</div>"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 13. Toggle Objective Completion Checklist
|
|
||||||
@app.post("/game/{game_id}/player/{player_id}/objective/toggle")
|
|
||||||
def toggle_objective_route(
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
type: str, # "personal_1", "personal_2", "personal_3"
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
|
||||||
|
|
||||||
# Toggle logic
|
|
||||||
current_status = False
|
|
||||||
if type == "personal_1":
|
|
||||||
current_status = player.completed_personal_1
|
|
||||||
elif type == "personal_2":
|
|
||||||
current_status = player.completed_personal_2
|
|
||||||
elif type == "personal_3":
|
|
||||||
current_status = player.completed_personal_3
|
|
||||||
|
|
||||||
crud.toggle_objective(db, player_id, type, not current_status)
|
|
||||||
return HTMLResponse(status_code=204)
|
|
||||||
|
|
||||||
# 14. Deep ends the scene
|
|
||||||
@app.post("/game/{game_id}/scene/end")
|
|
||||||
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
|
|
||||||
crud.end_scene_and_transition(db, game_id)
|
|
||||||
return HTMLResponse(status_code=204)
|
|
||||||
|
|
||||||
# 15. Cast vote to Rank Up a player
|
|
||||||
@app.post("/game/{game_id}/player/{player_id}/submit-vote")
|
|
||||||
def submit_vote_route(
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
nominated_id: str = Form(...),
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
crud.submit_rank_vote(db, game_id, player_id, nominated_id)
|
|
||||||
return HTMLResponse(status_code=204)
|
|
||||||
|
|
||||||
# 16. Player Ready for Next Scene
|
|
||||||
@app.post("/game/{game_id}/player/{player_id}/ready-next")
|
|
||||||
def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_session)):
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
|
||||||
|
|
||||||
player.is_ready = True
|
|
||||||
db.add(player)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Check if all players in the game are ready. If so, transition to next scene!
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if game and all(p.is_ready for p in game.players):
|
|
||||||
crud.proceed_to_next_scene_setup(db, game_id)
|
|
||||||
|
|
||||||
return HTMLResponse(status_code=204)
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
configure_logging()
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description="Run the Rats with Gats web app")
|
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("--host", default="0.0.0.0", help="Host address to bind to")
|
||||||
|
|||||||
204
src/pirats/maintenance.py
Normal file
204
src/pirats/maintenance.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Periodic database maintenance: purge old finished/inactive games.
|
||||||
|
|
||||||
|
A game is purged when it either finished more than `finished_days` ago or has
|
||||||
|
had no activity for `inactive_days`. "Activity" is the timestamp of a game's
|
||||||
|
most recent GameEvent (every meaningful move records one); for an ended game
|
||||||
|
that last event is its finish, so the same signal serves both windows. Games
|
||||||
|
with no events at all are undatable and left alone.
|
||||||
|
|
||||||
|
The purge runs in-process on a background asyncio loop (started from the app
|
||||||
|
lifespan); all knobs are configurable via environment variables / the NixOS
|
||||||
|
service. Every purge is logged with the crew name, uuid and an estimate of the
|
||||||
|
bytes it held, and each run that removes anything VACUUMs the SQLite file and
|
||||||
|
logs the disk space actually reclaimed.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from .database import engine
|
||||||
|
from .models import Game, GameEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DAY_SECONDS = 86400.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PurgeConfig:
|
||||||
|
enabled: bool
|
||||||
|
interval_hours: float
|
||||||
|
finished_days: float
|
||||||
|
inactive_days: float
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
|
val = os.environ.get(name)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
return val.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _env_float(name: str, default: float) -> float:
|
||||||
|
val = os.environ.get(name)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid %s=%r; using default %s", name, val, default)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def purge_config() -> PurgeConfig:
|
||||||
|
"""Read purge settings from the environment (see README for the knobs)."""
|
||||||
|
return PurgeConfig(
|
||||||
|
enabled=_env_bool("PIRATS_PURGE_ENABLED", True),
|
||||||
|
interval_hours=_env_float("PIRATS_PURGE_INTERVAL_HOURS", 24.0),
|
||||||
|
finished_days=_env_float("PIRATS_PURGE_FINISHED_DAYS", 14.0),
|
||||||
|
inactive_days=_env_float("PIRATS_PURGE_INACTIVE_DAYS", 30.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_file_path() -> Optional[Path]:
|
||||||
|
"""The on-disk SQLite file, or None for in-memory / non-file databases."""
|
||||||
|
name = engine.url.database
|
||||||
|
if not name or name == ":memory:":
|
||||||
|
return None
|
||||||
|
return Path(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_game_bytes(game: Game) -> int:
|
||||||
|
"""A rough (uncompressed) byte footprint of a game and all its rows, summed
|
||||||
|
from each row's serialized columns. Reported per purge; the dominant term is
|
||||||
|
any leftover rollback checkpoints' state_json."""
|
||||||
|
total = len(json.dumps(game.model_dump(), default=str))
|
||||||
|
for rows in (game.players, game.obstacles, game.votes, game.events,
|
||||||
|
game.challenges, game.checkpoints):
|
||||||
|
for row in rows:
|
||||||
|
total += len(json.dumps(row.model_dump(), default=str))
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def purge_old_games(db: Session, *, finished_days: float, inactive_days: float,
|
||||||
|
now: Optional[float] = None) -> List[dict]:
|
||||||
|
"""Delete games that finished more than `finished_days` ago or have been
|
||||||
|
inactive for `inactive_days`. Returns a summary dict per purged game and
|
||||||
|
logs each one. Does not VACUUM (the caller does, once per run)."""
|
||||||
|
import time
|
||||||
|
if now is None:
|
||||||
|
now = time.time()
|
||||||
|
finished_cutoff = now - finished_days * DAY_SECONDS
|
||||||
|
inactive_cutoff = now - inactive_days * DAY_SECONDS
|
||||||
|
|
||||||
|
# Most-recent event timestamp per game, in one grouped query.
|
||||||
|
last_activity = {
|
||||||
|
gid: ts
|
||||||
|
for gid, ts in db.exec(
|
||||||
|
select(GameEvent.game_id, func.max(GameEvent.timestamp)).group_by(GameEvent.game_id)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
purged: List[dict] = []
|
||||||
|
for game in db.exec(select(Game)).all():
|
||||||
|
last = last_activity.get(game.id)
|
||||||
|
if last is None:
|
||||||
|
# No events ever recorded: we can't date it, so leave it be.
|
||||||
|
continue
|
||||||
|
finished = game.phase == "ended" and last < finished_cutoff
|
||||||
|
inactive = last < inactive_cutoff
|
||||||
|
if not (finished or inactive):
|
||||||
|
continue
|
||||||
|
|
||||||
|
est_bytes = _estimate_game_bytes(game)
|
||||||
|
age_days = (now - last) / DAY_SECONDS
|
||||||
|
reason = "finished" if finished else "inactive"
|
||||||
|
logger.info(
|
||||||
|
"Purging game %s (crew=%r, phase=%s, reason=%s, idle %.1f days, ~%d bytes)",
|
||||||
|
game.id, game.crew_name, game.phase, reason, age_days, est_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# _estimate_game_bytes loaded every relationship, so the ORM's
|
||||||
|
# cascade_delete (set on each Game relationship) deletes all the
|
||||||
|
# children along with the game — no DB-level FK pragma needed.
|
||||||
|
db.delete(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
purged.append({
|
||||||
|
"id": game.id,
|
||||||
|
"crew_name": game.crew_name,
|
||||||
|
"phase": game.phase,
|
||||||
|
"reason": reason,
|
||||||
|
"idle_days": age_days,
|
||||||
|
"estimated_bytes": est_bytes,
|
||||||
|
})
|
||||||
|
|
||||||
|
return purged
|
||||||
|
|
||||||
|
|
||||||
|
def _vacuum() -> None:
|
||||||
|
"""Reclaim the pages freed by the deletes. VACUUM cannot run inside a
|
||||||
|
transaction, hence the autocommit connection."""
|
||||||
|
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
|
||||||
|
conn.exec_driver_sql("VACUUM")
|
||||||
|
|
||||||
|
|
||||||
|
def run_purge(*, finished_days: float, inactive_days: float,
|
||||||
|
now: Optional[float] = None) -> List[dict]:
|
||||||
|
"""One full purge cycle: delete stale games, then VACUUM and report the
|
||||||
|
disk space actually reclaimed from the SQLite file."""
|
||||||
|
db_path = _db_file_path()
|
||||||
|
size_before = db_path.stat().st_size if db_path and db_path.exists() else None
|
||||||
|
|
||||||
|
with Session(engine) as db:
|
||||||
|
purged = purge_old_games(
|
||||||
|
db, finished_days=finished_days, inactive_days=inactive_days, now=now
|
||||||
|
)
|
||||||
|
|
||||||
|
if not purged:
|
||||||
|
logger.debug("Game purge: nothing to remove")
|
||||||
|
return purged
|
||||||
|
|
||||||
|
if db_path and db_path.exists():
|
||||||
|
_vacuum()
|
||||||
|
size_after = db_path.stat().st_size
|
||||||
|
reclaimed = (size_before - size_after) if size_before is not None else None
|
||||||
|
logger.info(
|
||||||
|
"Game purge removed %d game(s); reclaimed %s bytes from %s",
|
||||||
|
len(purged),
|
||||||
|
reclaimed if reclaimed is not None else "?",
|
||||||
|
db_path,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Game purge removed %d game(s)", len(purged))
|
||||||
|
return purged
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_loop(config: Optional[PurgeConfig] = None) -> None:
|
||||||
|
"""Background task: run a purge at startup, then every `interval_hours`."""
|
||||||
|
cfg = config or purge_config()
|
||||||
|
logger.info(
|
||||||
|
"Game purge task started: every %.1f h, finished > %.0f days, inactive > %.0f days",
|
||||||
|
cfg.interval_hours, cfg.finished_days, cfg.inactive_days,
|
||||||
|
)
|
||||||
|
interval_seconds = max(cfg.interval_hours, 0.0) * 3600.0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
await run_in_threadpool(
|
||||||
|
run_purge,
|
||||||
|
finished_days=cfg.finished_days,
|
||||||
|
inactive_days=cfg.inactive_days,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Game purge run failed")
|
||||||
|
await asyncio.sleep(interval_seconds)
|
||||||
65
src/pirats/migrations/env.py
Normal file
65
src/pirats/migrations/env.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
# Importing the models registers every table on SQLModel.metadata,
|
||||||
|
# which is what `alembic revision --autogenerate` diffs against.
|
||||||
|
from pirats import models # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||||
|
|
||||||
|
target_metadata = SQLModel.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _get_engine():
|
||||||
|
# The app (pirats.database.run_migrations) passes its engine in via
|
||||||
|
# attributes; the alembic CLI falls back to the same engine the app
|
||||||
|
# would build (DATABASE_URL or ./rats_with_gats.db), unless an URL is
|
||||||
|
# set explicitly with `alembic -x url=...` or in alembic.ini.
|
||||||
|
engine = config.attributes.get("engine")
|
||||||
|
if engine is not None:
|
||||||
|
return engine
|
||||||
|
url = context.get_x_argument(as_dictionary=True).get("url") or config.get_main_option(
|
||||||
|
"sqlalchemy.url"
|
||||||
|
)
|
||||||
|
if url:
|
||||||
|
return create_engine(url)
|
||||||
|
from pirats.database import engine as app_engine
|
||||||
|
|
||||||
|
return app_engine
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=str(_get_engine().url),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
render_as_batch=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
with _get_engine().connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
# SQLite can't ALTER most things in place; batch mode rebuilds
|
||||||
|
# tables so autogenerated migrations actually run.
|
||||||
|
render_as_batch=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
src/pirats/migrations/script.py.mako
Normal file
24
src/pirats/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
137
src/pirats/migrations/versions/0001_initial_schema.py
Normal file
137
src/pirats/migrations/versions/0001_initial_schema.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Initial schema: the six tables as of 2026-06-12.
|
||||||
|
|
||||||
|
Pre-Alembic databases are brought to this exact shape by
|
||||||
|
pirats.database._upgrade_legacy_db() and then stamped at this revision,
|
||||||
|
so this migration only ever runs against an empty database.
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-06-12
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"game",
|
||||||
|
sa.Column("id", sa.String(), nullable=False),
|
||||||
|
sa.Column("admin_key", sa.String(), nullable=False),
|
||||||
|
sa.Column("crew_name", sa.String(), nullable=True),
|
||||||
|
sa.Column("phase", sa.String(), nullable=False),
|
||||||
|
sa.Column("current_scene_number", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("extra_obstacles", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("deck_cards", sa.String(), nullable=False),
|
||||||
|
sa.Column("completed_crew_1", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("completed_crew_2", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("completed_crew_3", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("captain_player_id", sa.String(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"player",
|
||||||
|
sa.Column("id", sa.String(), nullable=False),
|
||||||
|
sa.Column("game_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("player_name", sa.String(), nullable=False),
|
||||||
|
sa.Column("is_ready", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("rank", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("hand_cards", sa.String(), nullable=False),
|
||||||
|
sa.Column("role", sa.String(), nullable=True),
|
||||||
|
sa.Column("previous_role", sa.String(), nullable=True),
|
||||||
|
sa.Column("is_creator", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("avatar_look", sa.String(), nullable=False),
|
||||||
|
sa.Column("avatar_smell", sa.String(), nullable=False),
|
||||||
|
sa.Column("first_words", sa.String(), nullable=False),
|
||||||
|
sa.Column("good_at_math", sa.String(), nullable=False),
|
||||||
|
sa.Column("other_like", sa.String(), nullable=False),
|
||||||
|
sa.Column("other_like_from_player_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("other_hate", sa.String(), nullable=False),
|
||||||
|
sa.Column("other_hate_from_player_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("created_techniques", sa.String(), nullable=False),
|
||||||
|
sa.Column("swapped_techniques", sa.String(), nullable=False),
|
||||||
|
sa.Column("tech_jack", sa.String(), nullable=False),
|
||||||
|
sa.Column("tech_queen", sa.String(), nullable=False),
|
||||||
|
sa.Column("tech_king", sa.String(), nullable=False),
|
||||||
|
sa.Column("completed_personal_1", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("completed_personal_2", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("completed_personal_3", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("needs_name", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("needs_rank_3_bonus", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_dead", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_ghost", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("tax_banned", sa.Boolean(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_player_game_id"), "player", ["game_id"])
|
||||||
|
op.create_table(
|
||||||
|
"obstacle",
|
||||||
|
sa.Column("id", sa.String(), nullable=False),
|
||||||
|
sa.Column("game_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("original_card", sa.String(), nullable=False),
|
||||||
|
sa.Column("suit", sa.String(), nullable=False),
|
||||||
|
sa.Column("title", sa.String(), nullable=False),
|
||||||
|
sa.Column("description", sa.String(), nullable=False),
|
||||||
|
sa.Column("current_value", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("played_cards", sa.String(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_obstacle_game_id"), "obstacle", ["game_id"])
|
||||||
|
op.create_table(
|
||||||
|
"challenge",
|
||||||
|
sa.Column("id", sa.String(), nullable=False),
|
||||||
|
sa.Column("game_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("challenge_type", sa.String(), nullable=False),
|
||||||
|
sa.Column("target_player_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("acting_player_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("challenger_player_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("obstacle_ids", sa.String(), nullable=False),
|
||||||
|
sa.Column("temp_card", sa.String(), nullable=True),
|
||||||
|
sa.Column("stakes", sa.String(), nullable=False),
|
||||||
|
sa.Column("plays", sa.String(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(), nullable=False),
|
||||||
|
sa.Column("tax_type", sa.String(), nullable=True),
|
||||||
|
sa.Column("tax_target_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("tax_state", sa.String(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_challenge_game_id"), "challenge", ["game_id"])
|
||||||
|
op.create_table(
|
||||||
|
"vote",
|
||||||
|
sa.Column("id", sa.String(), nullable=False),
|
||||||
|
sa.Column("game_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("voter_player_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("nominated_player_id", sa.String(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_vote_game_id"), "vote", ["game_id"])
|
||||||
|
op.create_table(
|
||||||
|
"gameevent",
|
||||||
|
sa.Column("id", sa.String(), nullable=False),
|
||||||
|
sa.Column("game_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("timestamp", sa.Float(), nullable=False),
|
||||||
|
sa.Column("message", sa.String(), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_gameevent_game_id"), "gameevent", ["game_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("gameevent")
|
||||||
|
op.drop_table("vote")
|
||||||
|
op.drop_table("challenge")
|
||||||
|
op.drop_table("obstacle")
|
||||||
|
op.drop_table("player")
|
||||||
|
op.drop_table("game")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add gat description to player
|
||||||
|
|
||||||
|
Revision ID: 153132c8e583
|
||||||
|
Revises: 5a6e73d2bc4f
|
||||||
|
Create Date: 2026-06-14 09:42:58.953909
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = '153132c8e583'
|
||||||
|
down_revision = '5a6e73d2bc4f'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('gat_description', sqlmodel.sql.sqltypes.AutoString(), server_default="", nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('needs_gat_description', sa.Boolean(), server_default=sa.false(), nullable=False))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('needs_gat_description')
|
||||||
|
batch_op.drop_column('gat_description')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add teaching_deep_player_id to game
|
||||||
|
|
||||||
|
Revision ID: 19ee9ae5ca0e
|
||||||
|
Revises: e6294f6f8a81
|
||||||
|
Create Date: 2026-06-14 09:11:17.810765
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = '19ee9ae5ca0e'
|
||||||
|
down_revision = 'e6294f6f8a81'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('teaching_deep_player_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('teaching_deep_player_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add last_rank_up_player_id to game
|
||||||
|
|
||||||
|
Revision ID: 3bbf6812c261
|
||||||
|
Revises: 19ee9ae5ca0e
|
||||||
|
Create Date: 2026-06-14 09:21:11.037751
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = '3bbf6812c261'
|
||||||
|
down_revision = '19ee9ae5ca0e'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('last_rank_up_player_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('last_rank_up_player_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""add rollback checkpoints
|
||||||
|
|
||||||
|
Revision ID: 480e6d83109e
|
||||||
|
Revises: b306c59e9cc2
|
||||||
|
Create Date: 2026-06-13 05:44:11.123047
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = '480e6d83109e'
|
||||||
|
down_revision = 'b306c59e9cc2'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('checkpoint',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('game_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.Float(), nullable=False),
|
||||||
|
sa.Column('state_json', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['game_id'], ['game.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('checkpoint', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_checkpoint_game_id'), ['game_id'], unique=False)
|
||||||
|
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('rollback_timeline_version', sa.Integer(), nullable=False, server_default=sa.text('0')))
|
||||||
|
|
||||||
|
with op.batch_alter_table('gameevent', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('checkpoint_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_index(batch_op.f('ix_gameevent_checkpoint_id'), ['checkpoint_id'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('gameevent', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_gameevent_checkpoint_id'))
|
||||||
|
batch_op.drop_column('checkpoint_id')
|
||||||
|
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('rollback_timeline_version')
|
||||||
|
|
||||||
|
with op.batch_alter_table('checkpoint', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_checkpoint_game_id'))
|
||||||
|
|
||||||
|
op.drop_table('checkpoint')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add success/failure stakes to challenge
|
||||||
|
|
||||||
|
Revision ID: 5a6e73d2bc4f
|
||||||
|
Revises: 3bbf6812c261
|
||||||
|
Create Date: 2026-06-14 09:25:46.637295
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = '5a6e73d2bc4f'
|
||||||
|
down_revision = '3bbf6812c261'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('challenge', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('stakes_success', sqlmodel.sql.sqltypes.AutoString(), server_default="", nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('stakes_failure', sqlmodel.sql.sqltypes.AutoString(), server_default="", nullable=False))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('challenge', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('stakes_failure')
|
||||||
|
batch_op.drop_column('stakes_success')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add Game.dev_mode
|
||||||
|
|
||||||
|
Revision ID: 6071db83ba81
|
||||||
|
Revises: 71c6d3e50ba3
|
||||||
|
Create Date: 2026-06-12 11:37:36.965417
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = '6071db83ba81'
|
||||||
|
down_revision = '71c6d3e50ba3'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('dev_mode', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('dev_mode')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""add game join codes
|
||||||
|
|
||||||
|
Revision ID: 6ea41638edfc
|
||||||
|
Revises: 153132c8e583
|
||||||
|
Create Date: 2026-07-10 12:28:45.476200
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
revision = '6ea41638edfc'
|
||||||
|
down_revision = '153132c8e583'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def _new_join_code(used: set[str]) -> str:
|
||||||
|
while True:
|
||||||
|
code = "".join(secrets.choice(JOIN_CODE_ALPHABET) for _ in range(5))
|
||||||
|
if code not in used:
|
||||||
|
used.add(code)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# SQLite cannot add a required column to a populated table. Add it as
|
||||||
|
# nullable, backfill every existing game with a distinct code, then tighten
|
||||||
|
# the constraint and create the unique lookup index.
|
||||||
|
op.add_column('game', sa.Column('join_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
connection = op.get_bind()
|
||||||
|
game_ids = connection.execute(sa.text("SELECT id FROM game")).scalars().all()
|
||||||
|
used: set[str] = set()
|
||||||
|
for game_id in game_ids:
|
||||||
|
connection.execute(
|
||||||
|
sa.text("UPDATE game SET join_code = :join_code WHERE id = :game_id"),
|
||||||
|
{"join_code": _new_join_code(used), "game_id": game_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
'join_code',
|
||||||
|
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
batch_op.create_index(batch_op.f('ix_game_join_code'), ['join_code'], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_game_join_code'))
|
||||||
|
batch_op.drop_column('join_code')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""recruit creation columns
|
||||||
|
|
||||||
|
Revision ID: 71c6d3e50ba3
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2026-06-12 11:12:06.389057
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = '71c6d3e50ba3'
|
||||||
|
down_revision = '0001'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('needs_reroll', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
batch_op.add_column(sa.Column('incoming_techniques', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default='[]'))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('incoming_techniques')
|
||||||
|
batch_op.drop_column('needs_reroll')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add Player.is_admin
|
||||||
|
|
||||||
|
Revision ID: 8b2f4c9d1e07
|
||||||
|
Revises: 6071db83ba81
|
||||||
|
Create Date: 2026-06-12 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = '8b2f4c9d1e07'
|
||||||
|
down_revision = '6071db83ba81'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
# Existing creators keep their privileges under the new flag
|
||||||
|
op.execute("UPDATE player SET is_admin = is_creator")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_admin')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""technique swapping columns
|
||||||
|
|
||||||
|
Revision ID: b306c59e9cc2
|
||||||
|
Revises: 8b2f4c9d1e07
|
||||||
|
Create Date: 2026-06-12 16:35:11.867104
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'b306c59e9cc2'
|
||||||
|
down_revision = '8b2f4c9d1e07'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('held_techniques', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default='[]'))
|
||||||
|
batch_op.add_column(sa.Column('swap_offer_to_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('swap_offer_technique', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default=''))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('swap_offer_technique')
|
||||||
|
batch_op.drop_column('swap_offer_to_id')
|
||||||
|
batch_op.drop_column('held_techniques')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add rollback head pointer
|
||||||
|
|
||||||
|
Revision ID: e6294f6f8a81
|
||||||
|
Revises: 480e6d83109e
|
||||||
|
Create Date: 2026-06-13 06:08:17.170588
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'e6294f6f8a81'
|
||||||
|
down_revision = '480e6d83109e'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('rollback_head_checkpoint_id', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('game', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('rollback_head_checkpoint_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,30 +1,53 @@
|
|||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import secrets
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from sqlmodel import Field, SQLModel, Relationship
|
from sqlmodel import Field, SQLModel, Relationship
|
||||||
|
|
||||||
|
JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def new_join_code() -> str:
|
||||||
|
return "".join(secrets.choice(JOIN_CODE_ALPHABET) for _ in range(5))
|
||||||
|
|
||||||
class Game(SQLModel, table=True):
|
class Game(SQLModel, table=True):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||||
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
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"
|
join_code: str = Field(default_factory=new_join_code, index=True, unique=True)
|
||||||
|
crew_name: Optional[str] = Field(default=None)
|
||||||
|
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "assign_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "recruit_creation", "ended"
|
||||||
|
dev_mode: bool = Field(default=False) # Creator-toggled; reveals testing shortcuts (Suggest buttons, skip character creation) to the whole table
|
||||||
current_scene_number: int = Field(default=1)
|
current_scene_number: int = Field(default=1)
|
||||||
extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers)
|
extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles)
|
||||||
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
|
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
|
||||||
|
rollback_timeline_version: int = Field(default=0) # Bumped whenever a rollback truncates events; lets the frontend reset its accumulated log. NEVER snapshotted (see crud_rollback.SNAPSHOT_EXCLUDE)
|
||||||
|
rollback_head_checkpoint_id: Optional[int] = Field(default=None) # When set, the game is viewing a rolled-back state at this checkpoint; events after it are the greyed, still-undoable future. None = live at the latest. NEVER snapshotted.
|
||||||
|
completed_crew_1: bool = Field(default=False) # Steal a Ship
|
||||||
|
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
||||||
|
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
||||||
|
captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship
|
||||||
|
teaching_deep_player_id: Optional[str] = Field(default=None) # Teaching mode: an admin who seeds themselves as the forced Rank-3 Deep for the first scene (set in the lobby, honored when starting ranks are rolled)
|
||||||
|
last_rank_up_player_id: Optional[str] = Field(default=None) # Winner of the most recent rank-up vote, shown on the post-voting upkeep screen; cleared when the next scene begins
|
||||||
|
|
||||||
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
|
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
obstacles: List["Obstacle"] = 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)
|
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
|
events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
|
challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
|
checkpoints: List["Checkpoint"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
|
|
||||||
class Player(SQLModel, table=True):
|
class Player(SQLModel, table=True):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||||
game_id: str = Field(foreign_key="game.id", index=True)
|
game_id: str = Field(foreign_key="game.id", index=True)
|
||||||
name: str = Field(default="")
|
name: str = Field(default="") # The Pi-Rat's current identity: "Recruit {smell}" until they earn a Name
|
||||||
|
player_name: str = Field(default="") # The human player's lobby name, shown alongside the Pi-Rat
|
||||||
is_ready: bool = Field(default=False)
|
is_ready: bool = Field(default=False)
|
||||||
rank: int = Field(default=1)
|
rank: int = Field(default=1)
|
||||||
hand_cards: str = Field(default="[]") # JSON string of list of card codes
|
hand_cards: str = Field(default="[]") # JSON string of list of card codes
|
||||||
role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
||||||
previous_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)
|
is_creator: bool = Field(default=False)
|
||||||
|
is_admin: bool = Field(default=False) # Admin privileges; the creator starts with them and can grant them to others
|
||||||
|
|
||||||
# Character sheet questions
|
# Character sheet questions
|
||||||
avatar_look: str = Field(default="")
|
avatar_look: str = Field(default="")
|
||||||
@@ -40,7 +63,10 @@ class Player(SQLModel, table=True):
|
|||||||
|
|
||||||
# Techniques
|
# Techniques
|
||||||
created_techniques: str = Field(default="[]") # JSON string of list of 3 strings
|
created_techniques: str = Field(default="[]") # JSON string of list of 3 strings
|
||||||
|
held_techniques: str = Field(default="[]") # JSON list of {"text", "creator_id"}: what the player currently holds during/after the swap phase
|
||||||
swapped_techniques: str = Field(default="[]") # JSON string of list of 3 strings
|
swapped_techniques: str = Field(default="[]") # JSON string of list of 3 strings
|
||||||
|
swap_offer_to_id: Optional[str] = Field(default=None) # Pending outgoing swap offer's recipient
|
||||||
|
swap_offer_technique: str = Field(default="") # The held technique being offered; hidden from everyone but the offerer
|
||||||
tech_jack: str = Field(default="")
|
tech_jack: str = Field(default="")
|
||||||
tech_queen: str = Field(default="")
|
tech_queen: str = Field(default="")
|
||||||
tech_king: str = Field(default="")
|
tech_king: str = Field(default="")
|
||||||
@@ -50,6 +76,19 @@ class Player(SQLModel, table=True):
|
|||||||
completed_personal_2: bool = Field(default=False) # Earn a Name
|
completed_personal_2: bool = Field(default=False) # Earn a Name
|
||||||
completed_personal_3: bool = Field(default=False) # Die like a Pirate
|
completed_personal_3: bool = Field(default=False) # Die like a Pirate
|
||||||
|
|
||||||
|
gat_description: str = Field(default="") # Free-text description of the Pi-Rat's Gat, entered via a modal when they earn one
|
||||||
|
# States for Milestones & Death
|
||||||
|
needs_name: bool = Field(default=False)
|
||||||
|
needs_gat_description: bool = Field(default=False) # Prompts the gat-description modal after earning a Gat objective
|
||||||
|
needs_rank_3_bonus: bool = Field(default=False)
|
||||||
|
is_dead: bool = Field(default=False)
|
||||||
|
is_ghost: bool = Field(default=False)
|
||||||
|
tax_banned: bool = Field(default=False) # Failed a refused Gat/Name Tax; cannot initiate one again this scene
|
||||||
|
|
||||||
|
# Recruit creation (death re-roll, or joining after initial character creation)
|
||||||
|
needs_reroll: bool = Field(default=False) # Waiting on the between-scenes recruit_creation phase
|
||||||
|
incoming_techniques: str = Field(default="[]") # JSON list of {"from_id", "text"}: techniques crewmates write for this recruit
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
game: Optional[Game] = Relationship(back_populates="players")
|
game: Optional[Game] = Relationship(back_populates="players")
|
||||||
|
|
||||||
@@ -65,15 +104,35 @@ class Obstacle(SQLModel, table=True):
|
|||||||
|
|
||||||
game: Optional[Game] = Relationship(back_populates="obstacles")
|
game: Optional[Game] = Relationship(back_populates="obstacles")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_count(self) -> int:
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
cards_list = json.loads(self.played_cards)
|
||||||
|
return sum(1 for c in cards_list if c.get("success") is True)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
class Challenge(SQLModel, table=True):
|
class Challenge(SQLModel, table=True):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||||
game_id: str = Field(foreign_key="game.id", index=True)
|
game_id: str = Field(foreign_key="game.id", index=True)
|
||||||
title: str = Field(...)
|
challenge_type: str = Field(default="deep") # "deep" (called by Deep Players) or "pvp" (Pi-Rat vs Pi-Rat)
|
||||||
description: str = Field(default="")
|
target_player_id: str = Field(...) # The originally challenged Pi-Rat
|
||||||
applied_obstacle_ids: str = Field(default="[]") # JSON string of list of obstacle IDs
|
acting_player_id: str = Field(...) # Who attempts it (changes when a Gat/Name Tax is accepted)
|
||||||
resolved_by_player_id: Optional[str] = Field(default=None) # Player ID if resolved (for logging/history)
|
challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
|
||||||
is_active: bool = Field(default=True)
|
obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges)
|
||||||
|
temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
|
||||||
|
stakes: str = Field(default="") # Deprecated single-field stakes; kept for old rollback-snapshot compatibility. New challenges use stakes_success/stakes_failure.
|
||||||
|
stakes_success: str = Field(default="") # What the Deep promises happens if the Pi-Rat beats the Challenge
|
||||||
|
stakes_failure: str = Field(default="") # What the Deep threatens happens if the Pi-Rat fails
|
||||||
|
plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
|
||||||
|
status: str = Field(default="open") # "open", "succeeded", "failed"
|
||||||
|
|
||||||
|
# Gat/Name Tax state
|
||||||
|
tax_type: Optional[str] = Field(default=None) # "gat" or "name"
|
||||||
|
tax_target_id: Optional[str] = Field(default=None) # The player asked to take over the challenge
|
||||||
|
tax_state: Optional[str] = Field(default=None) # "requested", "accepted", "refused"
|
||||||
|
|
||||||
game: Optional[Game] = Relationship(back_populates="challenges")
|
game: Optional[Game] = Relationship(back_populates="challenges")
|
||||||
|
|
||||||
class Vote(SQLModel, table=True):
|
class Vote(SQLModel, table=True):
|
||||||
@@ -83,3 +142,28 @@ class Vote(SQLModel, table=True):
|
|||||||
nominated_player_id: str = Field(...)
|
nominated_player_id: str = Field(...)
|
||||||
|
|
||||||
game: Optional[Game] = Relationship(back_populates="votes")
|
game: Optional[Game] = Relationship(back_populates="votes")
|
||||||
|
|
||||||
|
class GameEvent(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)
|
||||||
|
timestamp: float = Field(default_factory=time.time)
|
||||||
|
message: str = Field(...)
|
||||||
|
kind: str = Field(default="info") # category for frontend icons/styling: "join", "phase", "scene", "captain", "challenge", "card", "tax", "objective", "vote", "obstacle", "joker", "victory", "warning", "rank", "info"
|
||||||
|
# Links an event to the Checkpoint captured by its action (see crud_rollback):
|
||||||
|
# None = in-flight (created since the last capture; next capture tags it)
|
||||||
|
# > 0 = a live rollback target within the current scene
|
||||||
|
# 0 = sealed/historical (a past scene or non-scene event; never rollback-able)
|
||||||
|
checkpoint_id: Optional[int] = Field(default=None, index=True)
|
||||||
|
|
||||||
|
game: Optional[Game] = Relationship(back_populates="events")
|
||||||
|
|
||||||
|
class Checkpoint(SQLModel, table=True):
|
||||||
|
"""A full snapshot of a game's gameplay state, captured after each action during
|
||||||
|
a scene so it can be rolled back to. The event log itself is NOT snapshotted (it
|
||||||
|
is an append-only spine); rollback truncates it separately. See crud_rollback."""
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True) # autoincrement; the rollback target the frontend sends
|
||||||
|
game_id: str = Field(foreign_key="game.id", index=True)
|
||||||
|
created_at: float = Field(default_factory=time.time)
|
||||||
|
state_json: str = Field(...) # serialized gameplay snapshot (Game minus control cols + Players/Obstacles/Challenges/Votes)
|
||||||
|
|
||||||
|
game: Optional[Game] = Relationship(back_populates="checkpoints")
|
||||||
|
|||||||
192
src/pirats/routes_admin.py
Normal file
192
src/pirats/routes_admin.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Request, Form, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
from .database import get_session
|
||||||
|
from . import crud
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def _get_admin(db: Session, game_id: str, player_id: str):
|
||||||
|
"""Resolves (game, player), requiring the player to be an Admin."""
|
||||||
|
game = crud.get_game(db, game_id)
|
||||||
|
player = crud.get_player(db, player_id)
|
||||||
|
if not game or not player or player.game_id != game.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if not player.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Only an Admin can do that.")
|
||||||
|
return game, player
|
||||||
|
|
||||||
|
# Admin toggles Dev Mode for the whole table (reveals testing shortcuts)
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/dev-mode")
|
||||||
|
def set_dev_mode(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
enabled: bool = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
game, player = _get_admin(db, game_id, player_id)
|
||||||
|
if game.dev_mode != enabled:
|
||||||
|
game.dev_mode = enabled
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
crud.add_game_event(db, game.id, f"🛠 Dev Mode turned {'ON' if enabled else 'OFF'} by {player.name}.", kind="info")
|
||||||
|
return {"status": "ok", "dev_mode": game.dev_mode}
|
||||||
|
|
||||||
|
# Teaching mode: an admin volunteers to be the forced Rank-3 Deep for scene 1.
|
||||||
|
# Only meaningful before starting ranks are rolled (i.e. before scene setup).
|
||||||
|
_PRE_RANK_PHASES = {"lobby", "character_creation", "swap_techniques", "assign_techniques"}
|
||||||
|
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/teaching-mode")
|
||||||
|
def set_teaching_mode(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
enabled: bool = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
game, player = _get_admin(db, game_id, player_id)
|
||||||
|
if game.phase not in _PRE_RANK_PHASES:
|
||||||
|
raise HTTPException(status_code=400, detail="Starting ranks have already been rolled.")
|
||||||
|
if enabled:
|
||||||
|
game.teaching_deep_player_id = player.id
|
||||||
|
elif game.teaching_deep_player_id == player.id:
|
||||||
|
game.teaching_deep_player_id = None
|
||||||
|
else:
|
||||||
|
# Disabling someone else's teaching mode shouldn't silently clear it.
|
||||||
|
return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id}
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
crud.add_game_event(
|
||||||
|
db, game.id,
|
||||||
|
f"📚 {player.name} {'will teach as the Deep' if enabled else 'cancelled teaching mode'} for the first scene.",
|
||||||
|
kind="info",
|
||||||
|
)
|
||||||
|
return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id}
|
||||||
|
|
||||||
|
# Dev Mode shortcut: auto-complete character creation for everyone and move on
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/skip-character-creation")
|
||||||
|
def skip_character_creation_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
fills: str = Form("{}"), # JSON: player_id -> suggested values for free-text fields
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
game, player = _get_admin(db, game_id, player_id)
|
||||||
|
if not game.dev_mode:
|
||||||
|
raise HTTPException(status_code=403, detail="Dev Mode must be on to skip character creation.")
|
||||||
|
try:
|
||||||
|
fills_dict = json.loads(fills) if fills else {}
|
||||||
|
if not isinstance(fills_dict, dict):
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="fills must be a JSON object.")
|
||||||
|
crud.add_game_event(db, game.id, f"🛠 {player.name} is skipping the rest of character creation (Dev Mode).", kind="info")
|
||||||
|
ok, msg = crud.skip_character_creation(db, game, fills_dict)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Admin escape hatch during technique distribution: randomly distribute every
|
||||||
|
# technique to an eligible player and auto-assign J/Q/K (no Dev Mode required)
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/auto-assign-techniques")
|
||||||
|
def auto_assign_techniques_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
game, player = _get_admin(db, game_id, player_id)
|
||||||
|
if game.phase not in ("swap_techniques", "assign_techniques"):
|
||||||
|
return JSONResponse({"error": "Technique distribution isn't in progress."}, status_code=400)
|
||||||
|
crud.add_game_event(db, game.id, f"🗝 {player.name} is auto-assigning everyone's techniques.", kind="info")
|
||||||
|
ok, msg = crud.auto_assign_techniques(db, game)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Creator Admin Panel
|
||||||
|
@router.get("/game/{game_id}/admin")
|
||||||
|
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("/")
|
||||||
|
|
||||||
|
players_data = []
|
||||||
|
for p in game.players:
|
||||||
|
players_data.append({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"player_name": p.player_name,
|
||||||
|
"rank": p.rank,
|
||||||
|
"is_dead": p.is_dead,
|
||||||
|
"is_ghost": p.is_ghost,
|
||||||
|
"role": p.role,
|
||||||
|
"is_creator": p.is_creator,
|
||||||
|
"is_admin": p.is_admin,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"game": {
|
||||||
|
"id": game.id,
|
||||||
|
"crew_name": game.crew_name,
|
||||||
|
"join_code": game.join_code,
|
||||||
|
"phase": game.phase,
|
||||||
|
"admin_key": game.admin_key
|
||||||
|
},
|
||||||
|
"players": players_data,
|
||||||
|
"base_url": base_url
|
||||||
|
}
|
||||||
|
|
||||||
|
# Grant or revoke another player's Admin privileges (admin-key authenticated, like the panel itself)
|
||||||
|
@router.post("/game/{game_id}/admin/set-admin")
|
||||||
|
def set_player_admin(
|
||||||
|
game_id: str,
|
||||||
|
key: str = Form(...),
|
||||||
|
target_player_id: str = Form(...),
|
||||||
|
is_admin: bool = 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")
|
||||||
|
if game.admin_key != key:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
|
||||||
|
target = crud.get_player(db, target_player_id)
|
||||||
|
if not target or target.game_id != game.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
if target.is_creator and not is_admin:
|
||||||
|
return JSONResponse({"error": "The game creator's Admin privileges can't be revoked."}, status_code=400)
|
||||||
|
if target.is_admin != is_admin:
|
||||||
|
target.is_admin = is_admin
|
||||||
|
db.add(target)
|
||||||
|
db.commit()
|
||||||
|
verb = "granted" if is_admin else "revoked"
|
||||||
|
crud.add_game_event(db, game.id, f"🗝 Admin privileges {verb} for {target.name}.", kind="info")
|
||||||
|
return {"status": "ok", "is_admin": target.is_admin}
|
||||||
|
|
||||||
|
# Kick a player out of the game (admin-key authenticated). Admins can kick
|
||||||
|
# anyone — including other admins — except the last remaining Admin, so the
|
||||||
|
# table is never locked out. The kicked player's cards return to the discard.
|
||||||
|
@router.post("/game/{game_id}/admin/kick")
|
||||||
|
def kick_player_route(
|
||||||
|
game_id: str,
|
||||||
|
key: str = Form(...),
|
||||||
|
target_player_id: 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")
|
||||||
|
if game.admin_key != key:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
|
||||||
|
target = crud.get_player(db, target_player_id)
|
||||||
|
if not target or target.game_id != game.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
ok, msg = crud.kick_player(db, game, target)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
113
src/pirats/routes_challenge.py
Normal file
113
src/pirats/routes_challenge.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Form, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
from .database import get_session
|
||||||
|
from . import crud
|
||||||
|
from .validation import sanitize_text
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Deep calls a Challenge: applies one or more Obstacles to a Pi-Rat
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/challenge/create")
|
||||||
|
def create_challenge_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
target_player_id: str = Form(...),
|
||||||
|
obstacle_ids: str = Form(...), # JSON list of obstacle ids
|
||||||
|
stakes_success: str = Form(""),
|
||||||
|
stakes_failure: str = Form(""),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
ids = json.loads(obstacle_ids)
|
||||||
|
assert isinstance(ids, list)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
|
||||||
|
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids,
|
||||||
|
sanitize_text(stakes_success), sanitize_text(stakes_failure))
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Deep resolves an open Challenge
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/resolve")
|
||||||
|
def resolve_challenge_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
challenge_id: str,
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.resolve_challenge(db, challenge_id, player_id)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok", "message": msg}
|
||||||
|
|
||||||
|
# Deep withdraws an open Challenge
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/cancel")
|
||||||
|
def cancel_challenge_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
challenge_id: str,
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.cancel_challenge(db, challenge_id, player_id)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Challenged Pi-Rat calls a Gat/Name Tax on another Pi-Rat
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/tax/request")
|
||||||
|
def request_tax_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
challenge_id: str,
|
||||||
|
target_player_id: str = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.request_tax(db, challenge_id, player_id, target_player_id)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok", "message": msg}
|
||||||
|
|
||||||
|
# Taxed Pi-Rat accepts or refuses
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/tax/respond")
|
||||||
|
def respond_tax_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
challenge_id: str,
|
||||||
|
accept: str = Form(...), # "true" or "false"
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.respond_tax(db, challenge_id, player_id, accept.lower() == "true")
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok", "message": msg}
|
||||||
|
|
||||||
|
# Pi-Rat challenges another Pi-Rat, playing a card as a temporary Obstacle
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/challenge/pvp/create")
|
||||||
|
def create_pvp_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
defender_id: str = Form(...),
|
||||||
|
card_code: str = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.create_pvp_challenge(db, game_id, player_id, defender_id, card_code)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Defender plays a card against the temporary Obstacle
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/pvp/defend")
|
||||||
|
def pvp_defend_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
challenge_id: str,
|
||||||
|
card_code: str = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg, res = crud.play_pvp_defense(db, challenge_id, player_id, card_code)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok", "details": res["details"], "success": res["success"], "drew_card": res["drew_card"]}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user