Compare commits
79 Commits
3a00774b50
...
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 |
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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,3 +5,7 @@ result
|
|||||||
*.pdf
|
*.pdf
|
||||||
# Built Svelte frontend (copied in from frontend/dist by the Nix build)
|
# Built Svelte frontend (copied in from frontend/dist by the Nix build)
|
||||||
src/pirats/static/
|
src/pirats/static/
|
||||||
|
# Machine-local Claude Code permission approvals
|
||||||
|
.claude/settings.local.json
|
||||||
|
# Emacs autosaves
|
||||||
|
\#*
|
||||||
|
|||||||
34
AGENTS.md
34
AGENTS.md
@@ -1 +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.
|
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.
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -61,6 +61,20 @@ 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).
|
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
|
### Option 2: Using Nix Flakes
|
||||||
@@ -99,10 +113,22 @@ services.pirats = {
|
|||||||
host = "127.0.0.1";
|
host = "127.0.0.1";
|
||||||
port = 8000;
|
port = 8000;
|
||||||
databasePath = "/var/lib/pirats/rats_with_gats.db";
|
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
|
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
|
## Running Tests
|
||||||
@@ -147,8 +173,16 @@ pirats/
|
|||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
|
|
||||||
Run the backend (`pirats --reload`) and the Vite dev server side by side; Vite proxies `/api` to port 8000:
|
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
|
```bash
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Vite proxies `/api` to the backend on port 8000.
|
||||||
|
|||||||
32
TODO.md
32
TODO.md
@@ -1,23 +1,15 @@
|
|||||||
## Major Improvements
|
## Polish
|
||||||
|
|
||||||
- [ ] **Replace polling in the frontend with a websocket connection** I believe the 2s polling interval is an artifact of the previous HTMX-based frontend implementation, and the app might feel more responsive if it were websocket-based or otherwise event driven.
|
- [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
|
||||||
|
|
||||||
- [ ] **Allow Deep players to roll back game events.** This may require some updates to how game state is handled. Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current architecture at all.
|
## Words Words Words
|
||||||
|
|
||||||
## Missing Features
|
- [ ] **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.
|
||||||
- [ ] **Smell-based temporary nicknames at game start.** When joining, players type an arbitrary lobby name; only re-rolled recruits get "Recruit {smell}". We should keep the lobby names, but be clear that these refer to the players, not the Pi-Rats, who should be identified by their smell until they earn a Name.
|
|
||||||
- [ ] **Tooltip reminders of card suit themes**. When you mouse over a card, there should be reminders that e.g. hearts are thematically social skills. Check the manual for the other suits.
|
|
||||||
- [ ] **Name string transfer on a refused Name Tax.** Mechanically the Objective (and Rank) transfer and the winner gets a "claim your name" prompt; this should be updated so that the stealer literally takes the other Pi-Rat's name, leaving them demoted to "Recruit {smell}".
|
|
||||||
- [ ] **First-scene framing requirement** ("must establish the Pi-Rats are on a ship they can steal; don't start far from water") is not surfaced anywhere in the scene-setup UI. We could add an interstitial phase that prompts the Deep to explain the scene before we move to the scene UI with the objectives.
|
|
||||||
- [ ] **Dead Pi-Rat re-roll flow.** I suspect this is badly broken, as it was implemented in a rush by an older model. In particular, other players will need to contribute likes/hates and techniques, which I believe are not currently implemented. This flow should also be used for new players who join after initial character creation, and should take place as a between-scenes phase after Deep Upkeep.
|
|
||||||
- [ ] **Event log visibility**. The event log should be visible in all phases, not just during a scene.
|
|
||||||
- [ ] **Admin page link**. The admin/creator player should always have a link to the admin page available to them.
|
|
||||||
|
|
||||||
## Worth Double-Checking / Minor
|
|
||||||
|
|
||||||
- [ ] **Dev/prod server flows.** Investigate where assets are served from in both development and production workflows. Make sure we're not committing any compiled svelte code. In production, the app should be used through the Nix flake, which should handle the compile step when the flake is built.
|
|
||||||
- [ ] **Refactor the CSS.** Make it easy to re-theme by keeping the definitions of primary/background/highlight/etc colors confined to a few specific styles. If this requires adopting some kind of CSS framework/preprocessing, so be it.
|
|
||||||
- [ ] **More formal database migration procedures.**
|
|
||||||
- [ ] **Rules endpoint** Make a nicely formatted HTML version of the rulebook. It should probably also be linked from a help menu somewhere that's always available, rather than only being surfaced on the splash page of the site (which joining players don't even see)
|
|
||||||
- [ ] **Re-styling**. The entire app could use a face lift, but in particular the splash page is ugly.
|
|
||||||
|
|||||||
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
|
||||||
68
flake.nix
68
flake.nix
@@ -24,7 +24,7 @@
|
|||||||
pname = "pirats-frontend";
|
pname = "pirats-frontend";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./frontend;
|
src = ./frontend;
|
||||||
npmDepsHash = "sha256-ekYBi0oUjtnsfdig2B0yrmT7KbSc9IB1DVbTCRwYI24=";
|
npmDepsHash = "sha256-S2CXmFgovGD40wS4bTsiVF84J3zEQK6qrg7EPy+ojPY=";
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
mkdir -p $out
|
mkdir -p $out
|
||||||
cp -r dist $out/
|
cp -r dist $out/
|
||||||
@@ -45,9 +45,11 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
propagatedBuildInputs = with python.pkgs; [
|
propagatedBuildInputs = with python.pkgs; [
|
||||||
|
alembic
|
||||||
fastapi
|
fastapi
|
||||||
sqlmodel
|
sqlmodel
|
||||||
uvicorn
|
uvicorn
|
||||||
|
websockets
|
||||||
python-multipart
|
python-multipart
|
||||||
httpx
|
httpx
|
||||||
];
|
];
|
||||||
@@ -110,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 {
|
||||||
@@ -127,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";
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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"
|
"svelte-spa-router": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -50,6 +53,33 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/alegreya-sans": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/alegreya-sans/-/alegreya-sans-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-kXUTR1S6dsWa7rBxaDJ3P05HodmYmxfY20aQtQ4SYVmrEYrWtaw5A1n+h+wcCF0nAMwf+CQ4M435dNuWD1YfMg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/alegreya-sc": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/alegreya-sc/-/alegreya-sc-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-28ZMhxJ/bx7f9S2oTTPeXvKchAu5VxPbEG95RI+EI9l3/1YyomEXzUqX0lCdCQb5fDXhdmVwHSD07P0BL4ueQA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/pirata-one": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/pirata-one/-/pirata-one-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-hl3Zqt6mgP+jstHnv2qYc2xQdYM1DSYrpSU9bpS1fYExoGFgJJNbXTPBQ9XivNk9RCKkibhou3iqoOZQOXM48w==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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"
|
"svelte-spa-router": "^5.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 240 B |
@@ -4,6 +4,7 @@
|
|||||||
import Join from './pages/Join.svelte';
|
import Join from './pages/Join.svelte';
|
||||||
import Dashboard from './pages/Dashboard.svelte';
|
import Dashboard from './pages/Dashboard.svelte';
|
||||||
import Admin from './pages/Admin.svelte';
|
import Admin from './pages/Admin.svelte';
|
||||||
|
import CornerMenu from './components/CornerMenu.svelte';
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
'/': Home,
|
'/': Home,
|
||||||
@@ -15,4 +16,5 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Router {routes} />
|
<Router {routes} />
|
||||||
|
<CornerMenu />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
@import './assets/css/style.css';
|
/* ==========================================================================
|
||||||
@import './assets/css/core.css';
|
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/components.css';
|
||||||
|
@import './assets/css/card.css';
|
||||||
@import './assets/css/welcome.css';
|
@import './assets/css/welcome.css';
|
||||||
@import './assets/css/lobby.css';
|
@import './assets/css/lobby.css';
|
||||||
@import './assets/css/character.css';
|
@import './assets/css/character.css';
|
||||||
@import './assets/css/scene-setup.css';
|
@import './assets/css/scene-setup.css';
|
||||||
@import './assets/css/scene-play.css';
|
@import './assets/css/scene-play.css';
|
||||||
@import './assets/css/card.css';
|
|
||||||
@import './assets/css/upkeep.css';
|
@import './assets/css/upkeep.css';
|
||||||
@import './assets/css/admin.css';
|
@import './assets/css/admin.css';
|
||||||
|
|||||||
@@ -1,22 +1,123 @@
|
|||||||
/* --- Admin Panel --- */
|
/* --- Admin panel --- */
|
||||||
.admin-players-list {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
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;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-player-row {
|
.admin-table th, .admin-table td {
|
||||||
display: flex;
|
padding: 0.75rem;
|
||||||
justify-content: space-between;
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-info-basic {
|
.admin-table th {
|
||||||
font-size: 1.05rem;
|
font-family: var(--font-heading);
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-copy-action {
|
.link-copy-action {
|
||||||
@@ -26,3 +127,38 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 500px;
|
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;
|
||||||
|
}
|
||||||
@@ -1,22 +1,44 @@
|
|||||||
/* Card Widget (Mini) */
|
/* ============================================================
|
||||||
|
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 {
|
.card-mini {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--text-muted);
|
|
||||||
background: var(--bg-dark);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-mini.base-card {
|
.card-mini.base-card {
|
||||||
border-color: var(--gold);
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
color: var(--gold);
|
|
||||||
background: rgba(212,175,55,0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-mini.rotated {
|
.card-mini.rotated {
|
||||||
@@ -25,13 +47,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-mini.success {
|
.card-mini.success {
|
||||||
border-color: var(--neon-emerald);
|
box-shadow: 0 0 0 2px var(--success);
|
||||||
color: var(--neon-emerald);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-mini.failure {
|
.card-mini.failure {
|
||||||
border-color: var(--red-suit);
|
box-shadow: 0 0 0 2px var(--danger);
|
||||||
color: var(--red-suit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-mini .val {
|
.card-mini .val {
|
||||||
@@ -48,17 +68,15 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
left: 1px;
|
left: 1px;
|
||||||
color: var(--text-muted);
|
color: color-mix(in srgb, var(--card-ink-black) 60%, var(--card-face));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card Widget (Medium) */
|
/* --- Medium (obstacle display) --- */
|
||||||
.card-medium {
|
.card-medium {
|
||||||
width: 75px;
|
width: 75px;
|
||||||
height: 112px;
|
height: 112px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1.5px solid var(--glass-border);
|
box-shadow: var(--shadow-card);
|
||||||
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
|
|
||||||
box-shadow: 0 3px 10px rgba(0,0,0,0.4);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -83,29 +101,6 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-medium.suit-c, .card-medium.suit-s {
|
|
||||||
border-color: rgba(226, 232, 240, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-medium.suit-h, .card-medium.suit-d {
|
|
||||||
border-color: rgba(255, 42, 95, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-medium.joker-card {
|
|
||||||
border-color: var(--gold);
|
|
||||||
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-medium.suit-c .val, .card-medium.suit-c .suit,
|
|
||||||
.card-medium.suit-s .val, .card-medium.suit-s .suit {
|
|
||||||
color: var(--black-suit);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-medium.suit-h .val, .card-medium.suit-h .suit,
|
|
||||||
.card-medium.suit-d .val, .card-medium.suit-d .suit {
|
|
||||||
color: var(--red-suit);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-medium .card-center {
|
.card-medium .card-center {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -113,17 +108,13 @@
|
|||||||
margin: auto 0;
|
margin: auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-medium.suit-c .card-center, .card-medium.suit-s .card-center { color: var(--black-suit); }
|
/* --- Large (the player's hand) --- */
|
||||||
.card-medium.suit-h .card-center, .card-medium.suit-d .card-center { color: var(--red-suit); }
|
|
||||||
|
|
||||||
/* Card Widget (Large) */
|
|
||||||
.card-large {
|
.card-large {
|
||||||
width: 110px;
|
width: 110px;
|
||||||
height: 165px;
|
height: 165px;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
border: 2px solid var(--glass-border);
|
border-width: 2px;
|
||||||
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
|
box-shadow: var(--shadow-card-raised);
|
||||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -134,29 +125,7 @@
|
|||||||
|
|
||||||
.card-large:hover {
|
.card-large:hover {
|
||||||
transform: translateY(-8px) scale(1.05);
|
transform: translateY(-8px) scale(1.05);
|
||||||
border-color: var(--gold);
|
box-shadow: var(--shadow-card-lifted), 0 0 14px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
box-shadow: 0 10px 25px rgba(212, 175, 55, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-large.suit-c, .card-large.suit-s {
|
|
||||||
border-color: rgba(226, 232, 240, 0.15);
|
|
||||||
}
|
|
||||||
.card-large.suit-c:hover, .card-large.suit-s:hover {
|
|
||||||
border-color: var(--black-suit);
|
|
||||||
box-shadow: 0 10px 25px var(--black-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-large.suit-h, .card-large.suit-d {
|
|
||||||
border-color: rgba(255, 42, 95, 0.15);
|
|
||||||
}
|
|
||||||
.card-large.suit-h:hover, .card-large.suit-d:hover {
|
|
||||||
border-color: var(--red-suit);
|
|
||||||
box-shadow: 0 10px 25px var(--red-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-large.joker-card {
|
|
||||||
border-color: var(--gold);
|
|
||||||
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-large .card-corner {
|
.card-large .card-corner {
|
||||||
@@ -175,16 +144,6 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-large.suit-c .val, .card-large.suit-c .suit,
|
|
||||||
.card-large.suit-s .val, .card-large.suit-s .suit {
|
|
||||||
color: var(--black-suit);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-large.suit-h .val, .card-large.suit-h .suit,
|
|
||||||
.card-large.suit-d .val, .card-large.suit-d .suit {
|
|
||||||
color: var(--red-suit);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-large .card-center {
|
.card-large .card-center {
|
||||||
font-size: 2.8rem;
|
font-size: 2.8rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -192,9 +151,6 @@
|
|||||||
margin: auto 0;
|
margin: auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-large.suit-c .card-center, .card-large.suit-s .card-center { color: var(--black-suit); }
|
|
||||||
.card-large.suit-h .card-center, .card-large.suit-d .card-center { color: var(--red-suit); }
|
|
||||||
|
|
||||||
.card-large .card-tech-overlay {
|
.card-large .card-tech-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0.5rem;
|
bottom: 0.5rem;
|
||||||
@@ -203,10 +159,10 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tech-tag {
|
.card-large .tech-tag {
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||||
border: 1px solid var(--gold);
|
border: 1px solid var(--accent);
|
||||||
color: var(--gold);
|
color: var(--accent-bright);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
padding: 0.15rem 0.3rem;
|
padding: 0.15rem 0.3rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -220,7 +176,7 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drag and Drop Interface Styles */
|
/* --- Drag & drop --- */
|
||||||
.card-large[draggable="true"] {
|
.card-large[draggable="true"] {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
@@ -236,9 +192,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-item.drag-over {
|
.obstacle-item.drag-over {
|
||||||
border-color: var(--gold) !important;
|
border-color: var(--accent) !important;
|
||||||
background: rgba(212, 175, 55, 0.1) !important;
|
background: color-mix(in srgb, var(--accent) 9%, transparent) !important;
|
||||||
box-shadow: 0 0 20px rgba(212, 175, 55, 0.2) !important;
|
box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 20%, transparent) !important;
|
||||||
transform: translateY(-2px) scale(1.01);
|
transform: translateY(-2px) scale(1.01);
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* --- Character Creation & Sheet Grid --- */
|
/* --- Character creation & sheet --- */
|
||||||
.creation-grid {
|
.creation-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -10,20 +10,18 @@
|
|||||||
.creation-grid {
|
.creation-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.section-desc {
|
.creation-grid > .card {
|
||||||
font-size: 0.9rem;
|
grid-column: span 1 !important;
|
||||||
color: var(--text-muted);
|
}
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-line {
|
.record-line {
|
||||||
background: rgba(0,0,0,0.2);
|
background: var(--well);
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
border-left: 3px solid var(--gold);
|
border-left: 3px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.delegation-box {
|
.delegation-box {
|
||||||
@@ -33,14 +31,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.delegation-box h4 {
|
.delegation-box h4 {
|
||||||
|
font-family: var(--font-body);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-box {
|
.answer-box {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--neon-cyan);
|
color: var(--accent-bright);
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,14 +49,6 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waiting-box {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--gold);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inbox-list:has(.inbox-item) .inbox-empty-message {
|
.inbox-list:has(.inbox-item) .inbox-empty-message {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -65,8 +56,8 @@
|
|||||||
.inbox-item {
|
.inbox-item {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
border-left: 3px solid var(--neon-cyan);
|
border-left: 3px solid var(--deep);
|
||||||
background: rgba(0, 242, 254, 0.02);
|
background: color-mix(in srgb, var(--deep) 4%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inbox-item label {
|
.inbox-item label {
|
||||||
@@ -76,26 +67,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.submitted-techniques .tech-chip {
|
.submitted-techniques .tech-chip {
|
||||||
background: rgba(212,175,55,0.05);
|
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||||
border: 1px solid var(--gold);
|
border: 1px solid var(--accent);
|
||||||
color: var(--gold);
|
color: var(--accent);
|
||||||
padding: 0.5rem;
|
padding: 0.6rem 1rem;
|
||||||
border-radius: 6px;
|
font-size: 0.95rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tech-chip {
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tech-assignment-pill {
|
.tech-assignment-pill {
|
||||||
background: rgba(0, 242, 254, 0.05);
|
background: color-mix(in srgb, var(--deep) 6%, transparent);
|
||||||
border: 1px solid var(--neon-cyan);
|
border: 1px solid var(--deep);
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -104,8 +91,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-rank {
|
.card-rank {
|
||||||
background: var(--neon-cyan);
|
background: var(--accent);
|
||||||
color: var(--bg-dark);
|
color: var(--text-inverse);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -114,31 +101,30 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Character Sheet layout --- */
|
/* --- Character sheet layout --- */
|
||||||
.character-sheet-details {
|
.character-sheet-details {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-group {
|
@media (min-width: 768px) {
|
||||||
background: rgba(0,0,0,0.15);
|
.character-sheet-details {
|
||||||
padding: 1rem;
|
flex-direction: row;
|
||||||
border-radius: 8px;
|
align-items: flex-start;
|
||||||
border-left: 2px solid var(--gold-dark);
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
.sheet-main-column {
|
||||||
.sheet-group h4 {
|
flex: 2;
|
||||||
color: var(--gold);
|
min-width: 0;
|
||||||
font-size: 0.95rem;
|
}
|
||||||
margin-bottom: 0.5rem;
|
.sheet-side-column {
|
||||||
text-transform: uppercase;
|
flex: 1;
|
||||||
letter-spacing: 0.5px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footnote {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.techniques-list-sheet {
|
.techniques-list-sheet {
|
||||||
@@ -150,37 +136,23 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footnote-desc {
|
/* --- Face-card technique assignment (drag & drop) --- */
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
margin-left: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.margin-top-small {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Visual & Tactile Technique Assignment --- */
|
|
||||||
.face-tech-drag-form {
|
.face-tech-drag-form {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.available-techniques-container {
|
.available-techniques-container {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--well);
|
||||||
border: 1px dashed var(--glass-border);
|
border: 1px dashed var(--edge);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
box-shadow: inset 0 0 15px rgba(0,0,0,0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.available-techniques-container h4 {
|
.available-techniques-container h4 {
|
||||||
font-family: var(--font-heading);
|
color: var(--accent);
|
||||||
color: var(--gold);
|
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,18 +172,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.draggable-tech-chip {
|
.draggable-tech-chip {
|
||||||
background: linear-gradient(135deg, rgba(22, 36, 59, 0.9) 0%, rgba(13, 23, 38, 0.9) 100%);
|
background: var(--surface-raised);
|
||||||
border: 1px solid var(--gold);
|
border: 1px solid var(--accent);
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3), 0 0 5px rgba(212, 175, 55, 0.1);
|
box-shadow: var(--shadow-soft);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -219,8 +190,7 @@
|
|||||||
|
|
||||||
.draggable-tech-chip:hover {
|
.draggable-tech-chip:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: var(--neon-cyan);
|
border-color: var(--accent-bright);
|
||||||
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4), 0 0 10px rgba(0, 242, 254, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.draggable-tech-chip:active {
|
.draggable-tech-chip:active {
|
||||||
@@ -232,26 +202,20 @@
|
|||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.draggable-tech-chip.selected {
|
|
||||||
border-color: var(--neon-cyan);
|
|
||||||
box-shadow: 0 0 15px rgba(0, 242, 254, 0.5);
|
|
||||||
background: rgba(0, 242, 254, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.draggable-tech-chip.assigned-hidden {
|
.draggable-tech-chip.assigned-hidden {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
border-color: var(--text-muted);
|
border-color: var(--edge);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-icon {
|
.drag-icon {
|
||||||
color: var(--gold);
|
color: var(--accent);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slots Grid */
|
/* Face-card slots */
|
||||||
.face-cards-slots-grid {
|
.face-cards-slots-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
@@ -276,9 +240,9 @@
|
|||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
aspect-ratio: 2 / 3;
|
aspect-ratio: 2 / 3;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
background: linear-gradient(135deg, rgba(13, 23, 38, 0.8) 0%, rgba(7, 11, 18, 0.9) 100%);
|
background: var(--well);
|
||||||
border: 2px dashed rgba(212, 175, 55, 0.3);
|
border: 2px dashed var(--edge-accent);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -286,27 +250,25 @@
|
|||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.face-card-slot:hover {
|
.face-card-slot:hover {
|
||||||
border-color: rgba(212, 175, 55, 0.7);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 12px 30px rgba(212, 175, 55, 0.15);
|
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.face-card-slot.drag-over {
|
.face-card-slot.drag-over {
|
||||||
border-color: var(--neon-cyan);
|
border-color: var(--accent-bright);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
background: rgba(0, 242, 254, 0.05);
|
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||||
box-shadow: 0 0 20px rgba(0, 242, 254, 0.2);
|
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.face-card-slot.has-assignment {
|
.face-card-slot.has-assignment {
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: var(--gold);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 15px rgba(212, 175, 55, 0.15);
|
background: color-mix(in srgb, var(--accent) 5%, var(--well));
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-bg-letter {
|
.card-bg-letter {
|
||||||
@@ -314,10 +276,9 @@
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-display);
|
||||||
font-size: 8rem;
|
font-size: 9rem;
|
||||||
font-weight: 900;
|
color: color-mix(in srgb, var(--text) 4%, transparent);
|
||||||
color: rgba(255, 255, 255, 0.025);
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -325,7 +286,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.face-card-slot.has-assignment .card-bg-letter {
|
.face-card-slot.has-assignment .card-bg-letter {
|
||||||
color: rgba(212, 175, 55, 0.04);
|
color: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-slot-header, .card-slot-footer {
|
.card-slot-header, .card-slot-footer {
|
||||||
@@ -342,7 +303,7 @@
|
|||||||
|
|
||||||
.face-card-slot.has-assignment .card-slot-header,
|
.face-card-slot.has-assignment .card-slot-header,
|
||||||
.face-card-slot.has-assignment .card-slot-footer {
|
.face-card-slot.has-assignment .card-slot-footer {
|
||||||
color: var(--gold);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-slot-body {
|
.card-slot-body {
|
||||||
@@ -360,29 +321,29 @@
|
|||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.face-card-slot.has-assignment .card-title {
|
.face-card-slot.has-assignment .card-title {
|
||||||
color: var(--gold);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone-placeholder {
|
.drop-zone-placeholder {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
border: 1px dashed var(--edge);
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: var(--well);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.face-card-slot:hover .drop-zone-placeholder {
|
.face-card-slot:hover .drop-zone-placeholder {
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: var(--edge-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assigned-tech-content {
|
.assigned-tech-content {
|
||||||
@@ -399,14 +360,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assigned-tech-chip {
|
.assigned-tech-chip {
|
||||||
background: rgba(212, 175, 55, 0.08);
|
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||||
border: 1px solid var(--gold);
|
border: 1px solid var(--accent);
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--shadow-soft);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
animation: fadeIn 0.3s ease;
|
animation: fadeIn 0.3s ease;
|
||||||
}
|
}
|
||||||
@@ -418,22 +379,21 @@
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #c0392b;
|
background: var(--danger);
|
||||||
border: 1px solid #e74c3c;
|
border: 1px solid color-mix(in srgb, var(--danger) 70%, var(--text));
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 5px rgba(0,0,0,0.5);
|
box-shadow: var(--shadow-soft);
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-tech-btn:hover {
|
.remove-tech-btn:hover {
|
||||||
background: #e74c3c;
|
transform: scale(1.15);
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
|
|||||||
@@ -1,39 +1,108 @@
|
|||||||
/* --- UI Panels & Glassmorphism --- */
|
/* --- Panels --- */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: var(--glass-bg);
|
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||||
backdrop-filter: blur(12px);
|
border: 1px solid var(--edge);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: var(--shadow-soft);
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-panel:hover {
|
.glass-panel:hover {
|
||||||
border-color: rgba(212, 175, 55, 0.35);
|
border-color: var(--edge-accent);
|
||||||
box-shadow: 0 8px 32px rgba(212, 175, 55, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-ocean-light);
|
background: var(--surface);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--edge);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
box-shadow: var(--shadow-soft);
|
||||||
transition: var(--transition-smooth);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h3 {
|
.card h3 {
|
||||||
font-family: var(--font-heading);
|
color: var(--accent);
|
||||||
color: var(--gold);
|
font-size: 1.35rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
border-bottom: 1px solid var(--edge-accent);
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Forms & Controls --- */
|
/* 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 {
|
.form-group {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -47,40 +116,35 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field {
|
.input-field, .form-control, .select-field, .copy-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.7rem 1rem;
|
||||||
background: rgba(7, 11, 18, 0.8);
|
background: var(--well);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--edge);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field:focus {
|
.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;
|
outline: none;
|
||||||
border-color: var(--neon-cyan);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 10px rgba(0, 242, 254, 0.2);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-field {
|
.select-field {
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: rgba(7, 11, 18, 0.8);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: var(--transition-smooth);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-field:focus {
|
.input-large {
|
||||||
outline: none;
|
padding: 0.9rem 1.1rem;
|
||||||
border-color: var(--neon-cyan);
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-row {
|
.input-row {
|
||||||
@@ -92,96 +156,116 @@
|
|||||||
flex: 1;
|
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 --- */
|
/* --- Buttons --- */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.7rem 1.5rem;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-heading);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-dark) 100%);
|
background: linear-gradient(160deg, var(--accent-bright) 0%, var(--accent) 45%, var(--accent-dark) 100%);
|
||||||
color: var(--text-dark);
|
border-color: var(--accent-dark);
|
||||||
box-shadow: 0 4px 15px var(--gold-glow);
|
color: var(--text-inverse);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 20px rgba(212, 175, 55, 0.6);
|
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: rgba(255,255,255,0.08);
|
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
border: 1px solid rgba(255,255,255,0.15);
|
border-color: var(--edge);
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: rgba(255,255,255,0.15);
|
background: color-mix(in srgb, var(--text) 15%, transparent);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-deep {
|
.btn-deep {
|
||||||
background: linear-gradient(135deg, #152238 0%, #0d1726 100%);
|
background: color-mix(in srgb, var(--deep) 10%, var(--surface));
|
||||||
border: 1px solid var(--neon-cyan);
|
border-color: var(--deep);
|
||||||
color: var(--neon-cyan);
|
color: var(--deep);
|
||||||
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-deep:hover:not(:disabled) {
|
.btn-deep:hover:not(:disabled) {
|
||||||
background: var(--neon-cyan);
|
background: var(--deep);
|
||||||
color: var(--text-dark);
|
color: var(--text-inverse);
|
||||||
box-shadow: 0 4px 20px rgba(0, 242, 254, 0.4);
|
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: linear-gradient(135deg, #7a1c1c 0%, #c0392b 100%);
|
background: color-mix(in srgb, var(--danger) 18%, var(--surface));
|
||||||
color: var(--text-primary);
|
border-color: var(--danger);
|
||||||
box-shadow: 0 4px 15px rgba(192, 57, 43, 0.2);
|
color: color-mix(in srgb, var(--danger) 70%, var(--text));
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--text-inverse);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 20px rgba(192, 57, 43, 0.5);
|
}
|
||||||
|
|
||||||
|
.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 {
|
.btn-gold {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 2px solid var(--gold);
|
border: 2px solid var(--accent);
|
||||||
color: var(--gold);
|
color: var(--accent);
|
||||||
box-shadow: 0 0 10px rgba(212,175,55,0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-gold:hover:not(:disabled) {
|
.btn-gold:hover:not(:disabled) {
|
||||||
background: var(--gold);
|
background: var(--accent);
|
||||||
color: var(--bg-dark);
|
color: var(--text-inverse);
|
||||||
box-shadow: 0 0 20px var(--gold-glow);
|
box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-full {
|
.btn-full, .btn-block { width: 100%; }
|
||||||
width: 100%;
|
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
|
||||||
}
|
.btn-large { padding: 0.9rem 2rem; font-size: 1.15rem; }
|
||||||
|
|
||||||
.btn-small {
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-large {
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -191,111 +275,215 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glow-effect:hover {
|
.glow-effect:hover {
|
||||||
filter: brightness(1.2);
|
filter: brightness(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Event Log --- */
|
/* --- Alerts --- */
|
||||||
.event-log-fab {
|
.alert {
|
||||||
position: fixed;
|
padding: 0.75rem 1.25rem;
|
||||||
bottom: 20px;
|
margin-bottom: 1.5rem;
|
||||||
right: 20px;
|
border-radius: var(--radius-sm);
|
||||||
width: 60px;
|
text-align: center;
|
||||||
height: 60px;
|
font-weight: 500;
|
||||||
border-radius: 50%;
|
}
|
||||||
background: var(--glass-bg);
|
|
||||||
backdrop-filter: blur(8px);
|
.alert-danger {
|
||||||
-webkit-backdrop-filter: blur(8px);
|
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||||
border: 1px solid var(--gold);
|
border: 1px solid var(--danger);
|
||||||
color: var(--gold);
|
color: color-mix(in srgb, var(--danger) 75%, var(--text));
|
||||||
font-size: 1.5rem;
|
}
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5), 0 0 10px rgba(212, 175, 55, 0.2);
|
|
||||||
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
gap: 0.75rem;
|
||||||
z-index: 1000;
|
|
||||||
transition: var(--transition-smooth);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-log-fab:hover {
|
/* --- Modal overlay (shared by popups) --- */
|
||||||
transform: scale(1.1);
|
.modal-backdrop {
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6), 0 0 15px rgba(212, 175, 55, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-log-panel {
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 90px;
|
inset: 0;
|
||||||
right: 20px;
|
z-index: 2000;
|
||||||
width: 350px;
|
|
||||||
max-width: calc(100vw - 40px);
|
|
||||||
height: 400px;
|
|
||||||
max-height: calc(100vh - 120px);
|
|
||||||
background: var(--glass-bg);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
z-index: 999;
|
|
||||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-log-panel.hidden {
|
|
||||||
transform: translateY(20px) scale(0.95);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-log-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
justify-content: center;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
padding: 20px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-log-header h3 {
|
.modal-box {
|
||||||
margin: 0;
|
position: relative;
|
||||||
font-family: var(--font-heading);
|
max-width: 440px;
|
||||||
font-size: 1.1rem;
|
width: 100%;
|
||||||
color: var(--gold);
|
max-height: 88vh;
|
||||||
}
|
|
||||||
|
|
||||||
.event-log-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1rem;
|
padding: 1.75rem;
|
||||||
display: flex;
|
border: 1px solid var(--edge);
|
||||||
flex-direction: column;
|
border-radius: var(--radius-md);
|
||||||
gap: 0.5rem;
|
box-shadow: var(--shadow-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-list {
|
.modal-box h3 {
|
||||||
list-style: none;
|
margin-top: 0;
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-item {
|
.modal-box.sheet-modal {
|
||||||
font-size: 0.9rem;
|
max-width: 520px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
text-align: left;
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 3px solid var(--neon-cyan);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-time {
|
.modal-box.character-sheet-modal {
|
||||||
display: block;
|
max-width: 1260px;
|
||||||
font-size: 0.75rem;
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 0.25rem;
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-msg {
|
.modal-close:hover {
|
||||||
color: var(--text-primary);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
/* --- Variables & Tokens --- */
|
|
||||||
:root {
|
|
||||||
--bg-dark: #070b12;
|
|
||||||
--bg-ocean: #0d1726;
|
|
||||||
--bg-ocean-light: #16243b;
|
|
||||||
|
|
||||||
--gold: #d4af37;
|
|
||||||
--gold-glow: rgba(212, 175, 55, 0.4);
|
|
||||||
--gold-dark: #aa841c;
|
|
||||||
|
|
||||||
--neon-cyan: #00f2fe;
|
|
||||||
--neon-emerald: #00ff87;
|
|
||||||
|
|
||||||
--red-suit: #ff2a5f;
|
|
||||||
--red-glow: rgba(255, 42, 95, 0.3);
|
|
||||||
--black-suit: #e2e8f0;
|
|
||||||
--black-glow: rgba(226, 232, 240, 0.2);
|
|
||||||
|
|
||||||
--glass-bg: rgba(13, 23, 38, 0.7);
|
|
||||||
--glass-bg-hover: rgba(22, 36, 59, 0.85);
|
|
||||||
--glass-border: rgba(212, 175, 55, 0.2);
|
|
||||||
--glass-border-focus: rgba(0, 242, 254, 0.4);
|
|
||||||
|
|
||||||
--text-primary: #f1f5f9;
|
|
||||||
--text-muted: #94a3b8;
|
|
||||||
--text-dark: #0f172a;
|
|
||||||
|
|
||||||
--font-heading: 'Cinzel', serif;
|
|
||||||
--font-body: 'Outfit', sans-serif;
|
|
||||||
|
|
||||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Base Reset & Setup --- */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.6;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: var(--bg-dark);
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--gold-dark);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Layout Components --- */
|
|
||||||
.app-header {
|
|
||||||
background: rgba(7, 11, 18, 0.95);
|
|
||||||
border-bottom: 2px solid var(--gold);
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-container h1 {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
background: linear-gradient(135deg, var(--gold) 30%, #fff 70%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
text-shadow: 0 0 10px var(--gold-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.math-magic {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: 2.2rem;
|
|
||||||
color: var(--neon-cyan);
|
|
||||||
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
|
|
||||||
animation: pulse-glow 3s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-pill {
|
|
||||||
background: rgba(0, 242, 254, 0.1);
|
|
||||||
border: 1px solid var(--neon-cyan);
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-pill .bullet {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
background-color: var(--neon-emerald);
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 8px var(--neon-emerald);
|
|
||||||
}
|
|
||||||
|
|
||||||
.phase-pill {
|
|
||||||
background: rgba(212, 175, 55, 0.1);
|
|
||||||
border: 1px solid var(--gold);
|
|
||||||
color: var(--gold);
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
flex: 1;
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 1400px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer {
|
|
||||||
background: var(--bg-dark);
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.05);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Loader / Spinner widgets --- */
|
|
||||||
.loader-container {
|
|
||||||
padding: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border: 3px solid rgba(0, 242, 254, 0.1);
|
|
||||||
border-radius: 50%;
|
|
||||||
border-top-color: var(--neon-cyan);
|
|
||||||
animation: spin 1s ease-in-out infinite;
|
|
||||||
margin: 0 auto 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-small {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 2px solid rgba(255,255,255,0.1);
|
|
||||||
border-radius: 50%;
|
|
||||||
border-top-color: var(--gold);
|
|
||||||
animation: spin 1s ease-in-out infinite;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Alert styles --- */
|
|
||||||
.alert {
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-danger {
|
|
||||||
background: rgba(192, 57, 43, 0.2);
|
|
||||||
border: 1px solid #c0392b;
|
|
||||||
color: #e74c3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Animation keyframes --- */
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
|
||||||
0% {
|
|
||||||
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
text-shadow: 0 0 15px var(--neon-cyan), 0 0 30px var(--neon-cyan), 0 0 40px var(--neon-cyan);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Utilities --- */
|
|
||||||
.text-center { text-align: center; }
|
|
||||||
.text-left { text-align: left; }
|
|
||||||
.margin-top { margin-top: 1.5rem; }
|
|
||||||
.gold-text { color: var(--gold); }
|
|
||||||
.center-block { margin: 1rem auto; max-width: 500px; }
|
|
||||||
.inline-form { display: flex; gap: 0.5rem; width: 100%; }
|
|
||||||
.inline-group { display: flex; gap: 0.5rem; width: 100%; }
|
|
||||||
.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; }
|
|
||||||
.select-xsmall { padding: 0.4rem; font-size: 0.85rem; }
|
|
||||||
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
|
|
||||||
|
|
||||||
/* --- Ghost World Styles --- */
|
|
||||||
.ghost-world {
|
|
||||||
filter: grayscale(95%) brightness(0.85) sepia(15%) contrast(1.05);
|
|
||||||
transition: filter 1.2s ease-in-out;
|
|
||||||
}
|
|
||||||
@@ -1,78 +1,44 @@
|
|||||||
/* --- Lobby / Hold View --- */
|
/* --- Lobby / Ship's Hold --- */
|
||||||
.lobby-view {
|
.lobby-view {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lobby-view h2 {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: 2.2rem;
|
|
||||||
color: var(--gold);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lobby-status-box {
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip {
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
border-radius: 30px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
transition: var(--transition-smooth);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.current-user {
|
|
||||||
background: rgba(0, 242, 254, 0.15);
|
|
||||||
border-color: var(--neon-cyan);
|
|
||||||
box-shadow: 0 0 10px rgba(0,242,254,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.voted-chip {
|
|
||||||
border-color: var(--neon-emerald);
|
|
||||||
background-color: rgba(0,255,135,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.not-voted-chip {
|
|
||||||
border-color: rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.creator-badge {
|
|
||||||
background: var(--gold);
|
|
||||||
color: var(--text-dark);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-box {
|
.links-box {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
margin-bottom: 2rem;
|
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 {
|
.link-item {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-item h4 {
|
.link-item h4 {
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lobby-join-code-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.copy-container {
|
.copy-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -80,27 +46,17 @@
|
|||||||
|
|
||||||
.copy-input {
|
.copy-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #05080e;
|
background: var(--bg-deep);
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-input.admin-input {
|
.copy-input.admin-input {
|
||||||
border-color: var(--gold-dark);
|
border-color: var(--edge-accent);
|
||||||
color: var(--gold);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-link-item {
|
.admin-link-item {
|
||||||
border-top: 1px dashed rgba(212, 175, 55, 0.3);
|
border-top: 1px dashed var(--edge-accent);
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-text {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,48 @@
|
|||||||
/* --- Scene Play Board Layout --- */
|
/* --- Scene board layout --- */
|
||||||
.scene-view-layout {
|
.scene-view-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.4fr 1fr;
|
grid-template-columns: 180px minmax(0, 1.7fr) minmax(300px, 1fr);
|
||||||
gap: 2rem;
|
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) {
|
@media (max-width: 1100px) {
|
||||||
@@ -11,11 +51,162 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- 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 {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
border-bottom: 1px solid var(--edge-accent);
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -28,11 +219,11 @@
|
|||||||
|
|
||||||
.deck-counter {
|
.deck-counter {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--gold);
|
color: var(--accent);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Active Obstacle Item Display --- */
|
/* --- Obstacles --- */
|
||||||
.obstacles-container {
|
.obstacles-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -40,10 +231,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-item {
|
.obstacle-item {
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--edge);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-md);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
background: rgba(7, 11, 18, 0.4);
|
background: var(--well);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 0.8fr 2fr 1fr 1fr;
|
grid-template-columns: 0.8fr 2fr 1fr 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -51,6 +242,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.obstacle-item.in-challenge {
|
||||||
|
box-shadow: 0 0 0 2px var(--deep);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.obstacle-item {
|
.obstacle-item {
|
||||||
grid-template-columns: 0.8fr 2fr 1.5fr;
|
grid-template-columns: 0.8fr 2fr 1.5fr;
|
||||||
@@ -63,20 +258,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Color theme mappings for suits */
|
/* Suit accent stripes */
|
||||||
.suit-c { border-left: 4px solid var(--black-suit); }
|
.suit-c, .suit-s { border-left: 4px solid var(--suit-black); }
|
||||||
.suit-s { border-left: 4px solid var(--black-suit); }
|
.suit-h, .suit-d { border-left: 4px solid var(--suit-red); }
|
||||||
.suit-h { border-left: 4px solid var(--red-suit); }
|
|
||||||
.suit-d { border-left: 4px solid var(--red-suit); }
|
|
||||||
|
|
||||||
.obstacle-meta {
|
/* 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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(0,0,0,0.3);
|
padding: 2px; /* room for the rings, which overflow:hidden would clip */
|
||||||
border-radius: 6px;
|
}
|
||||||
padding: 0.5rem;
|
|
||||||
|
.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 {
|
.suit-badge {
|
||||||
@@ -84,8 +303,8 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suit-c .suit-badge, .suit-s .suit-badge { color: var(--black-suit); }
|
.suit-c .suit-badge, .suit-s .suit-badge { color: var(--suit-black); }
|
||||||
.suit-h .suit-badge, .suit-d .suit-badge { color: var(--red-suit); }
|
.suit-h .suit-badge, .suit-d .suit-badge { color: var(--suit-red); }
|
||||||
|
|
||||||
.card-code {
|
.card-code {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -94,10 +313,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-details h4 {
|
.obstacle-details h4 {
|
||||||
font-family: var(--font-heading);
|
color: var(--text);
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
font-size: 1.1rem;
|
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 {
|
.obstacle-details .desc {
|
||||||
@@ -106,29 +336,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-value-display {
|
.obstacle-value-display {
|
||||||
|
align-self: start;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(212, 175, 55, 0.04);
|
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||||
border: 1px dashed var(--glass-border);
|
border: 1px dashed var(--edge-accent);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-successes-display {
|
.obstacle-successes-display {
|
||||||
|
align-self: start;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(0, 255, 135, 0.04);
|
background: color-mix(in srgb, var(--success) 5%, transparent);
|
||||||
border: 1px dashed rgba(0, 255, 135, 0.25);
|
border: 1px dashed color-mix(in srgb, var(--success) 30%, transparent);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-successes-display .val-number {
|
.obstacle-successes-display .val-number {
|
||||||
color: var(--neon-emerald);
|
color: var(--success);
|
||||||
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.val-label {
|
.val-label {
|
||||||
@@ -141,132 +374,104 @@
|
|||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: var(--gold);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.played-column {
|
.clear-obstacle-row {
|
||||||
grid-column: span 4;
|
grid-column: 1 / -1;
|
||||||
border-top: 1px solid rgba(255,255,255,0.05);
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
/* --- Challenges --- (framed in the Deep's teal so red/black always means suit color) */
|
||||||
.played-column {
|
/* The challenge-area card IS the box; the challenge content sits flush inside it. */
|
||||||
grid-column: span 1;
|
.challenge-area-card {
|
||||||
}
|
border-color: color-mix(in srgb, var(--deep) 45%, transparent);
|
||||||
}
|
|
||||||
|
|
||||||
.played-column h5 {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-cards-flex {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Challenges Section --- */
|
|
||||||
.challenges-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-item {
|
.challenge-item {
|
||||||
background: rgba(255,255,255,0.02);
|
margin-bottom: 1rem;
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.challenge-item + .challenge-item {
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.challenge-item h4 {
|
.challenge-item h4 {
|
||||||
font-family: var(--font-heading);
|
color: var(--deep);
|
||||||
color: var(--neon-cyan);
|
font-size: 1.25rem;
|
||||||
font-size: 1.2rem;
|
margin: 0;
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-item .desc {
|
.challenge-head {
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.applied-obstacles h5 {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.applied-obs-row {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.obs-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-info .symbol {
|
.challenge-actions {
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-card-form {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-xsmall {
|
.tax-callout {
|
||||||
padding: 0.25rem 0.5rem;
|
margin-top: 0.75rem;
|
||||||
font-size: 0.8rem;
|
padding: 0.75rem;
|
||||||
width: 140px;
|
border: 1px dashed var(--accent);
|
||||||
background: var(--bg-dark);
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Deep Control panel --- */
|
.tax-callout p {
|
||||||
.deep-text {
|
margin: 0 0 0.5rem 0;
|
||||||
color: var(--neon-cyan) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.deep-divider {
|
/* --- Challenge area (middle column) --- */
|
||||||
border: 0;
|
/* Obstacles applied to an active Challenge, pulled up into the Challenge panel */
|
||||||
height: 1px;
|
.challenge-obstacles {
|
||||||
background: linear-gradient(to right, rgba(0, 242, 254, 0), rgba(0, 242, 254, 0.4), rgba(0, 242, 254, 0));
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.obstacles-checkboxes {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 1rem;
|
||||||
background: rgba(0,0,0,0.2);
|
margin-top: 0.75rem;
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-height: 150px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label {
|
/* The Deep's call-a-challenge + end-scene controls live below any open Challenges */
|
||||||
display: flex;
|
.deep-challenge-controls {
|
||||||
align-items: center;
|
margin-top: 0.5rem;
|
||||||
gap: 0.5rem;
|
}
|
||||||
|
|
||||||
|
/* When a Challenge is active the rest of the Obstacle list collapses out of the way */
|
||||||
|
.obstacle-collapse > summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95rem;
|
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 {
|
.hand-flex {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* --- Scene Setup / Role Choose --- */
|
/* --- Scene setup / role choice --- */
|
||||||
.setup-grid {
|
.setup-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.2fr 1fr;
|
grid-template-columns: 1.2fr 1fr;
|
||||||
@@ -6,6 +6,11 @@
|
|||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setup-grid.single {
|
||||||
|
grid-template-columns: minmax(0, 700px);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.setup-grid {
|
.setup-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -22,62 +27,44 @@
|
|||||||
.role-btn {
|
.role-btn {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
background: rgba(255,255,255,0.03);
|
background: var(--well);
|
||||||
border: 2px solid rgba(255,255,255,0.1);
|
border: 2px solid var(--edge);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-btn.pirat-btn:hover, .role-btn.pirat-btn.active {
|
.role-btn.pirat-btn:hover:not(:disabled), .role-btn.pirat-btn.active {
|
||||||
border-color: var(--neon-emerald);
|
border-color: var(--pirat);
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
background-color: rgba(0,255,135,0.06);
|
background-color: color-mix(in srgb, var(--pirat) 8%, transparent);
|
||||||
box-shadow: 0 0 15px rgba(0,255,135,0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-btn.deep-btn:hover, .role-btn.deep-btn.active {
|
.role-btn.deep-btn:hover:not(:disabled), .role-btn.deep-btn.active {
|
||||||
border-color: var(--neon-cyan);
|
border-color: var(--deep);
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
background-color: rgba(0,242,254,0.06);
|
background-color: color-mix(in srgb, var(--deep) 8%, transparent);
|
||||||
box-shadow: 0 0 15px rgba(0,242,254,0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.large-badge {
|
.role-badge {
|
||||||
padding: 0.5rem 2rem;
|
padding: 0.15rem 0.75rem;
|
||||||
font-size: 1.5rem;
|
border-radius: var(--radius-sm);
|
||||||
border-radius: 8px;
|
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-badge.deep {
|
|
||||||
background: rgba(0, 242, 254, 0.15);
|
|
||||||
border: 2px solid var(--neon-cyan);
|
|
||||||
color: var(--neon-cyan);
|
|
||||||
box-shadow: 0 0 15px rgba(0,242,254,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-badge.pirat {
|
|
||||||
background: rgba(0, 255, 135, 0.15);
|
|
||||||
border: 2px solid var(--neon-emerald);
|
|
||||||
color: var(--neon-emerald);
|
|
||||||
box-shadow: 0 0 15px rgba(0,255,135,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-chips {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-chips .player-chip {
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deep-chip {
|
.role-badge.deep {
|
||||||
border-color: var(--neon-cyan);
|
background: color-mix(in srgb, var(--deep) 14%, transparent);
|
||||||
background: rgba(0, 242, 254, 0.03);
|
border: 1px solid var(--deep);
|
||||||
|
color: var(--deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pirat-chip {
|
.role-badge.pirat {
|
||||||
border-color: var(--neon-emerald);
|
background: color-mix(in srgb, var(--pirat) 14%, transparent);
|
||||||
background: rgba(0, 255, 135, 0.03);
|
border: 1px solid var(--pirat);
|
||||||
|
color: var(--pirat);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.unassigned {
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px dashed var(--edge);
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
/* ==========================================================================
|
|
||||||
RATS WITH GATS DESIGN SYSTEM & STYLESHEET
|
|
||||||
A weathered math-magic pirate aesthetic: deep-ocean dark mode, glassmorphism,
|
|
||||||
neon math runes, gold accents, and animated card widgets.
|
|
||||||
|
|
||||||
This file imports modular stylesheets divided by page phase and components.
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
@import url("core.css");
|
|
||||||
@import url("components.css");
|
|
||||||
@import url("card.css");
|
|
||||||
@import url("welcome.css");
|
|
||||||
@import url("lobby.css");
|
|
||||||
@import url("character.css");
|
|
||||||
@import url("scene-setup.css");
|
|
||||||
@import url("scene-play.css");
|
|
||||||
@import url("upkeep.css");
|
|
||||||
@import url("admin.css");
|
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* --- Between Scenes Upkeep --- */
|
/* --- Between-scenes upkeep --- */
|
||||||
.between-grid {
|
.between-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -6,6 +6,14 @@
|
|||||||
margin: 2rem 0;
|
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) {
|
@media (max-width: 800px) {
|
||||||
.between-grid {
|
.between-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -19,12 +27,7 @@
|
|||||||
.voted-confirmation-box {
|
.voted-confirmation-box {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--neon-emerald);
|
border: 1px solid var(--success);
|
||||||
}
|
|
||||||
|
|
||||||
.success-text {
|
|
||||||
color: var(--neon-emerald);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranks-ledger {
|
.ranks-ledger {
|
||||||
@@ -33,30 +36,66 @@
|
|||||||
|
|
||||||
.ranks-ledger li {
|
.ranks-ledger li {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deep-badge {
|
/* --- Death fate panel --- */
|
||||||
background: rgba(0, 242, 254, 0.15);
|
.death-fate-card {
|
||||||
border: 1px solid var(--neon-cyan);
|
border: 2px solid var(--danger);
|
||||||
color: var(--neon-cyan);
|
background: color-mix(in srgb, var(--danger) 5%, var(--surface));
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 2.5rem;
|
||||||
font-size: 0.75rem;
|
margin: 2rem auto;
|
||||||
border-radius: 4px;
|
max-width: 650px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pirat-badge {
|
.death-fate-card > h2 {
|
||||||
background: rgba(0, 255, 135, 0.15);
|
color: var(--danger);
|
||||||
border: 1px solid var(--neon-emerald);
|
font-size: 2rem;
|
||||||
color: var(--neon-emerald);
|
margin-bottom: 1rem;
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Deep Upkeep Hand Refresh Drag & Drop --- */
|
.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 {
|
.upkeep-drag-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -76,9 +115,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upkeep-box h3 {
|
.upkeep-box h4 {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-family: var(--font-heading);
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upkeep-flex {
|
.upkeep-flex {
|
||||||
@@ -87,14 +126,16 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--well);
|
||||||
border: 1px dashed var(--glass-border);
|
border: 1px dashed var(--edge);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
align-content: flex-start;
|
align-content: flex-start;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upkeep-box[ondragover]:hover .upkeep-flex {
|
.end-story-box {
|
||||||
border-color: var(--neon-cyan);
|
margin-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,89 @@
|
|||||||
/* --- Welcome / Main Menu Screen --- */
|
/* --- Welcome / splash & join screens --- */
|
||||||
.welcome-container {
|
.welcome-container {
|
||||||
max-width: 900px;
|
max-width: 560px;
|
||||||
margin: 4rem auto;
|
margin: 0 auto;
|
||||||
|
padding: 4rem 1.5rem 3rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-hero h2 {
|
.welcome-hero {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-overline {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: 2.5rem;
|
font-size: 0.95rem;
|
||||||
color: var(--text-primary);
|
letter-spacing: 4px;
|
||||||
margin-bottom: 1rem;
|
text-transform: uppercase;
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-hero .subtitle {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-actions {
|
.wordmark {
|
||||||
display: grid;
|
font-family: var(--font-display);
|
||||||
grid-template-columns: 1fr 1fr;
|
font-weight: 400;
|
||||||
gap: 2rem;
|
font-size: clamp(3.2rem, 10vw, 5rem);
|
||||||
}
|
line-height: 1;
|
||||||
|
letter-spacing: 2px;
|
||||||
@media (max-width: 768px) {
|
background: linear-gradient(175deg, var(--accent-bright) 20%, var(--accent) 55%, var(--accent-dark) 100%);
|
||||||
.welcome-actions {
|
-webkit-background-clip: text;
|
||||||
grid-template-columns: 1fr;
|
background-clip: text;
|
||||||
}
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
filter: drop-shadow(0 3px 12px color-mix(in srgb, var(--accent) 30%, transparent));
|
||||||
|
|
||||||
.action-card {
|
|
||||||
padding: 2.5rem 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-card h3 {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
color: var(--gold);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card p {
|
.wordmark .amp {
|
||||||
color: var(--text-muted);
|
font-size: 0.45em;
|
||||||
margin-bottom: 2rem;
|
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>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip } from '../lib/cards';
|
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 card; // card code, e.g. "10D" or "Joker1"
|
||||||
export let size = 'large'; // 'large' | 'medium' | 'mini'
|
export let size = 'large'; // 'large' | 'medium' | 'mini'
|
||||||
@@ -7,7 +8,7 @@
|
|||||||
export let rotated = false; // mini: rendered as a played (rotated) column card
|
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 success = null; // mini: true/false adds success/failure styling
|
||||||
export let owner = ''; // mini: short label of who played the card
|
export let owner = ''; // mini: short label of who played the card
|
||||||
export let title = '';
|
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 techs = null; // large: {J, Q, K} technique names for the face-card overlay
|
||||||
export let style = '';
|
export let style = '';
|
||||||
|
|
||||||
@@ -16,19 +17,23 @@
|
|||||||
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
|
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
|
||||||
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
|
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
|
||||||
$: techName = techs && !joker ? techs[cardValue(card)] : null;
|
$: techName = techs && !joker ? techs[cardValue(card)] : null;
|
||||||
// Tooltip: explicit title wins; otherwise remind the player of the suit's theme
|
// Tooltip: an explicit title wins (plain text); otherwise a rich graphical
|
||||||
$: tooltip = title || cardTooltip(card);
|
// 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>
|
</script>
|
||||||
|
|
||||||
{#if size === 'mini'}
|
{#if size === 'mini'}
|
||||||
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}" title={tooltip}>
|
<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="val">{val}</span>
|
||||||
<span class="suit">{suit}</span>
|
<span class="suit">{suit}</span>
|
||||||
{#if owner}<span class="owner">{owner}</span>{/if}
|
{#if owner}<span class="owner">{owner}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
|
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
|
||||||
title={tooltip} {draggable} {style}
|
use:tooltip={tipContent} aria-label={ariaLabel} {draggable} {style}
|
||||||
on:dragstart on:dragend on:click>
|
on:dragstart on:dragend on:click>
|
||||||
<div class="card-corner top-left">
|
<div class="card-corner top-left">
|
||||||
<span class="val">{val}</span>
|
<span class="val">{val}</span>
|
||||||
@@ -43,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if techName}
|
{#if techName}
|
||||||
<div class="card-tech-overlay">
|
<div class="card-tech-overlay">
|
||||||
<div class="tech-tag" title="Automatic success when played">{cardValue(card)}: "{techName}"</div>
|
<div class="tech-tag" use:tooltip={`Automatic success when played\n\n"${techName}"`}>{cardValue(card)}: "{techName}"</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="card-corner bottom-right">
|
<div class="card-corner bottom-right">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions';
|
import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
|
||||||
import { displayName } from '../lib/cards';
|
import { displayName } from '../lib/cards';
|
||||||
|
import FaceCardAssigner from './FaceCardAssigner.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
@@ -15,11 +16,13 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let savingBasic = false;
|
let savingBasic = false;
|
||||||
|
let editingBasic = false;
|
||||||
|
|
||||||
async function saveBasicDetails() {
|
async function saveBasicDetails() {
|
||||||
savingBasic = true;
|
savingBasic = true;
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails);
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails);
|
||||||
|
editingBasic = false;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -27,6 +30,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Like/Hate tasks are assigned automatically when character creation starts.
|
||||||
// This is a fallback for games that entered the phase before assignments existed.
|
// This is a fallback for games that entered the phase before assignments existed.
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -50,6 +68,10 @@
|
|||||||
let tech1 = '', tech2 = '', tech3 = '';
|
let tech1 = '', tech2 = '', tech3 = '';
|
||||||
let savingTechs = false;
|
let savingTechs = false;
|
||||||
let techError = '';
|
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() {
|
async function submitTechniques() {
|
||||||
savingTechs = true;
|
savingTechs = true;
|
||||||
@@ -58,6 +80,7 @@
|
|||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||||
tech1, tech2, tech3
|
tech1, tech2, tech3
|
||||||
});
|
});
|
||||||
|
revisingTechs = false;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
techError = e.message;
|
techError = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -65,10 +88,79 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Face Techniques
|
||||||
let jackTech = '', queenTech = '', kingTech = '';
|
let jackTech = '', queenTech = '', kingTech = '';
|
||||||
let assigningFace = false;
|
let assigningFace = false;
|
||||||
let faceError = '';
|
let faceError = '';
|
||||||
|
// True while revising a previously-assigned J/Q/K layout, so we offer Cancel.
|
||||||
|
let revisingFace = false;
|
||||||
|
let faceSnapshot = {};
|
||||||
|
|
||||||
async function assignFaceTechniques() {
|
async function assignFaceTechniques() {
|
||||||
assigningFace = true;
|
assigningFace = true;
|
||||||
@@ -79,6 +171,44 @@
|
|||||||
queen: queenTech,
|
queen: queenTech,
|
||||||
king: kingTech
|
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) {
|
} catch(e) {
|
||||||
faceError = e.message;
|
faceError = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -102,68 +232,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check completion status
|
// Check completion status
|
||||||
|
$: phase = state.game.phase;
|
||||||
$: createdTechniques = state.player.created_techniques ? JSON.parse(state.player.created_techniques) : [];
|
$: 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) : [];
|
$: 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);
|
$: 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);
|
$: 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);
|
$: techniquesComplete = !!(state.player.tech_jack && state.player.tech_queen && state.player.tech_king);
|
||||||
|
|
||||||
// Dev Mode
|
// Dev Mode (toggled by the creator from the corner menu) reveals the
|
||||||
let devMode = false;
|
// Suggest buttons; in real games players write their own material.
|
||||||
let autoFilling = false;
|
$: devMode = !!state.game.dev_mode;
|
||||||
|
|
||||||
async function autoFillAll() {
|
|
||||||
autoFilling = true;
|
|
||||||
try {
|
|
||||||
// 1. Basic details — drawn from the same pools as the Suggest buttons
|
|
||||||
if (!basicComplete) {
|
|
||||||
basicDetails = {
|
|
||||||
avatar_look: getSuggestion('look'),
|
|
||||||
avatar_smell: getSuggestion('smell'),
|
|
||||||
first_words: getSuggestion('first_words'),
|
|
||||||
good_at_math: getSuggestion('good_at_math')
|
|
||||||
};
|
|
||||||
await saveBasicDetails();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Answer pending inbox questions with pool suggestions
|
|
||||||
for (const p of state.players) {
|
|
||||||
if (p.other_like_from_player_id === state.player.id && !p.other_like) {
|
|
||||||
inboxAnswers[`${p.id}:like`] = getSuggestion('like');
|
|
||||||
await submitDelegatedAnswer('like', p.id);
|
|
||||||
}
|
|
||||||
if (p.other_hate_from_player_id === state.player.id && !p.other_hate) {
|
|
||||||
inboxAnswers[`${p.id}:hate`] = getSuggestion('hate');
|
|
||||||
await submitDelegatedAnswer('hate', p.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Techniques — 3 distinct pool picks; retry if another player
|
|
||||||
// grabbed the same name first (the backend rejects collisions)
|
|
||||||
if (createdTechniques.length === 0) {
|
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
|
||||||
[tech1, tech2, tech3] = await getTechniqueSuggestions(3);
|
|
||||||
await submitTechniques();
|
|
||||||
if (!techError) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Face Techniques - we need the swapped ones. We can't do this synchronously without waiting for the server
|
|
||||||
// to process the state, because swapped_techniques comes from the server once everyone is done.
|
|
||||||
if (!techniquesComplete && swappedTechniques.length === 3) {
|
|
||||||
jackTech = swappedTechniques[0];
|
|
||||||
queenTech = swappedTechniques[1];
|
|
||||||
kingTech = swappedTechniques[2];
|
|
||||||
await assignFaceTechniques();
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
autoFilling = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -171,24 +256,8 @@
|
|||||||
<div class="view-header text-center">
|
<div class="view-header text-center">
|
||||||
<h2>Character Sheet Creation</h2>
|
<h2>Character Sheet Creation</h2>
|
||||||
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
|
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
|
||||||
<div class="flex justify-center items-center gap-2 text-sm text-gray-500 mt-2">
|
|
||||||
<input type="checkbox" id="devModeToggle" bind:checked={devMode} class="accent-gold"/>
|
|
||||||
<label for="devModeToggle">Dev Mode</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if devMode}
|
|
||||||
<div class="bg-dark-900 border border-gold p-4 rounded-lg flex justify-between items-center mb-6 max-w-4xl mx-auto">
|
|
||||||
<span class="text-gold font-bold">🛠 Dev Mode Tools</span>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
on:click={autoFillAll}
|
|
||||||
disabled={autoFilling}>
|
|
||||||
{autoFilling ? 'Auto-Filling...' : 'Auto-Fill Available Fields'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- MAIN PANEL: Grid for Layout -->
|
<!-- MAIN PANEL: Grid for Layout -->
|
||||||
<div class="creation-grid max-w-5xl mx-auto">
|
<div class="creation-grid max-w-5xl mx-auto">
|
||||||
|
|
||||||
@@ -196,7 +265,7 @@
|
|||||||
<div class="card glass-panel section-basic">
|
<div class="card glass-panel section-basic">
|
||||||
<h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3>
|
<h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3>
|
||||||
|
|
||||||
{#if basicComplete}
|
{#if basicComplete && !editingBasic}
|
||||||
<!-- Saved View -->
|
<!-- Saved View -->
|
||||||
<div class="read-only-sheet">
|
<div class="read-only-sheet">
|
||||||
<div class="record-line"><strong>Look:</strong> {state.player.avatar_look}</div>
|
<div class="record-line"><strong>Look:</strong> {state.player.avatar_look}</div>
|
||||||
@@ -204,6 +273,7 @@
|
|||||||
<div class="record-line"><strong>First Words:</strong> "{state.player.first_words}"</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 class="record-line"><strong>Good at Math?</strong> {state.player.good_at_math}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Form View -->
|
<!-- Form View -->
|
||||||
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
|
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
|
||||||
@@ -211,31 +281,36 @@
|
|||||||
<label for="avatar_look">What does your Pi-Rat look like?</label>
|
<label for="avatar_look">What does your Pi-Rat look like?</label>
|
||||||
<div class="input-row">
|
<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">
|
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
|
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
|
||||||
<div class="input-row">
|
<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">
|
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="first_words">What were your first words after transforming?</label>
|
<label for="first_words">What were your first words after transforming?</label>
|
||||||
<div class="input-row">
|
<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">
|
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>
|
{#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>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
|
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
|
||||||
<div class="input-row">
|
<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">
|
<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">
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>
|
{#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>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
<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>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +337,7 @@
|
|||||||
<div class="author-label">— {getPlayerName(state.player.other_hate_from_player_id)}</div>
|
<div class="author-label">— {getPlayerName(state.player.other_hate_from_player_id)}</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mb-4 text-sm text-gray-400">Assigning crewmates to write your relationships...</p>
|
<p class="mb-4 text-sm text-muted">Assigning crewmates to write your relationships...</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,29 +348,35 @@
|
|||||||
<p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p>
|
<p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p>
|
||||||
<div class="inbox-list">
|
<div class="inbox-list">
|
||||||
{#each state.players as p}
|
{#each state.players as p}
|
||||||
{#if p.other_like_from_player_id === state.player.id && !p.other_like}
|
{#if p.other_like_from_player_id === state.player.id}
|
||||||
<div class="inbox-item">
|
<div class="inbox-item">
|
||||||
<label>What do you LIKE about {displayName(p)}?</label>
|
<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">
|
<div class="input-row">
|
||||||
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
|
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>
|
{#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>
|
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if p.other_hate_from_player_id === state.player.id && !p.other_hate}
|
{#if p.other_hate_from_player_id === state.player.id}
|
||||||
<div class="inbox-item">
|
<div class="inbox-item">
|
||||||
<label>What do you HATE about {displayName(p)}?</label>
|
<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">
|
<div class="input-row">
|
||||||
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
|
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>
|
{#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>
|
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if state.players.filter(p => (p.other_like_from_player_id === state.player.id && !p.other_like) || (p.other_hate_from_player_id === state.player.id && !p.other_hate)).length === 0}
|
{#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-gray-400 italic">No pending tasks! Wait for other players to assign you tasks.</p>
|
<p class="inbox-empty-message text-muted italic">No pending tasks! Wait for other players to assign you tasks.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,7 +385,9 @@
|
|||||||
<div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;">
|
<div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;">
|
||||||
<h3>IV. Secret Techniques {techniquesComplete ? '✅' : '❌'}</h3>
|
<h3>IV. Secret Techniques {techniquesComplete ? '✅' : '❌'}</h3>
|
||||||
|
|
||||||
{#if createdTechniques.length === 0}
|
{#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>
|
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
|
||||||
{#if techError}
|
{#if techError}
|
||||||
<div class="alert alert-danger">{techError}</div>
|
<div class="alert alert-danger">{techError}</div>
|
||||||
@@ -314,186 +397,161 @@
|
|||||||
<label for="tech1">Technique #1:</label>
|
<label for="tech1">Technique #1:</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
|
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech1 = await getTechniqueSuggestion([tech2, tech3])}>Suggest</button>
|
{#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>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="tech2">Technique #2:</label>
|
<label for="tech2">Technique #2:</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
|
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech2 = await getTechniqueSuggestion([tech1, tech3])}>Suggest</button>
|
{#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>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="tech3">Technique #3:</label>
|
<label for="tech3">Technique #3:</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
|
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button>
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
<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>
|
</form>
|
||||||
{:else if createdTechniques.length === 3 && swappedTechniques.length < 3}
|
{:else if phase === 'character_creation'}
|
||||||
<div class="submitted-techniques text-center mt-4">
|
<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">#1: {createdTechniques[0]}</div>
|
||||||
<div class="tech-chip">#2: {createdTechniques[1]}</div>
|
<div class="tech-chip">#2: {createdTechniques[1]}</div>
|
||||||
<div class="tech-chip">#3: {createdTechniques[2]}</div>
|
<div class="tech-chip">#3: {createdTechniques[2]}</div>
|
||||||
<p class="waiting-box margin-top flex justify-center mt-4">
|
<p class="waiting-box margin-top flex justify-center mt-4">
|
||||||
<span class="spinner-small"></span>
|
<span class="spinner-small"></span>
|
||||||
Techniques submitted! Waiting for other players to submit so we can shuffle and swap them.
|
Techniques submitted! Once everyone submits, you'll trade them all away in blind swaps.
|
||||||
</p>
|
</p>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseTechniques}>✏️ Revise Techniques</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if !techniquesComplete}
|
{:else if phase === 'swap_techniques'}
|
||||||
{#if swappedTechniques.length === 3}
|
<p class="section-desc">
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
Trade away all 3 techniques you created by offering blind swaps to your crewmates.
|
||||||
<p class="text-gray-300">Drag and drop your assigned techniques onto your Face Cards:</p>
|
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.
|
||||||
{#if faceError}
|
</p>
|
||||||
<div class="alert alert-danger">{faceError}</div>
|
{#if swapError}
|
||||||
{/if}
|
<div class="alert alert-danger">{swapError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="available-techniques-container">
|
<h4>Your techniques in hand</h4>
|
||||||
<h4>Available Swapped Techniques</h4>
|
<div class="submitted-techniques mt-4">
|
||||||
<p class="instruction-help">Drag a technique onto a card slot below to assign it.</p>
|
{#each heldTechniques as t}
|
||||||
<div class="techniques-drag-pool">
|
<div class="tech-chip">
|
||||||
{#each swappedTechniques as tech}
|
{t.text}
|
||||||
<div
|
{#if t.creator_id === state.player.id}
|
||||||
draggable={tech !== jackTech && tech !== queenTech && tech !== kingTech}
|
<span class="text-warning"> — yours, swap it away!</span>
|
||||||
on:dragstart={(e) => e.dataTransfer.setData('text/plain', tech)}
|
{:else}
|
||||||
class="draggable-tech-chip {tech === jackTech || tech === queenTech || tech === kingTech ? 'assigned-hidden' : ''}"
|
<span class="text-success"> — from {getPlayerName(t.creator_id)}</span>
|
||||||
>
|
{/if}
|
||||||
<span class="drag-icon">⋮⋮</span> {tech}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Visual Card Slots Grid -->
|
{#if state.player.swap_offer_to_id}
|
||||||
<div class="face-cards-slots-grid">
|
<div class="delegation-box glass-panel mt-4">
|
||||||
|
<p>
|
||||||
<!-- JACK SLOT -->
|
You offered <strong>"{state.player.swap_offer_technique}"</strong> to
|
||||||
<div class="face-card-slot-wrapper">
|
{getPlayerName(state.player.swap_offer_to_id)}. Waiting for their answer...
|
||||||
<div class="face-card-slot {jackTech ? 'has-assignment' : ''}"
|
</p>
|
||||||
on:dragover|preventDefault
|
<button class="btn btn-secondary mt-4" disabled={swapBusy} on:click={cancelOffer}>Withdraw Offer</button>
|
||||||
on:drop={(e) => {
|
|
||||||
const tech = e.dataTransfer.getData('text/plain');
|
|
||||||
if (tech) {
|
|
||||||
if (queenTech === tech) queenTech = '';
|
|
||||||
if (kingTech === tech) kingTech = '';
|
|
||||||
jackTech = tech;
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<div class="card-bg-letter">J</div>
|
|
||||||
<div class="card-slot-header">
|
|
||||||
<span class="rank">J</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-body">
|
|
||||||
<div class="card-title">Jack</div>
|
|
||||||
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
|
||||||
<div class="assigned-tech-content">
|
|
||||||
{#if jackTech}
|
|
||||||
<div class="assigned-tech-chip">
|
|
||||||
{jackTech}
|
|
||||||
<span class="remove-tech-btn" on:click={() => jackTech = ''}>×</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-footer">
|
|
||||||
<span></span>
|
|
||||||
<span class="rank">J</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QUEEN SLOT -->
|
|
||||||
<div class="face-card-slot-wrapper">
|
|
||||||
<div class="face-card-slot {queenTech ? 'has-assignment' : ''}"
|
|
||||||
on:dragover|preventDefault
|
|
||||||
on:drop={(e) => {
|
|
||||||
const tech = e.dataTransfer.getData('text/plain');
|
|
||||||
if (tech) {
|
|
||||||
if (jackTech === tech) jackTech = '';
|
|
||||||
if (kingTech === tech) kingTech = '';
|
|
||||||
queenTech = tech;
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<div class="card-bg-letter">Q</div>
|
|
||||||
<div class="card-slot-header">
|
|
||||||
<span class="rank">Q</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-body">
|
|
||||||
<div class="card-title">Queen</div>
|
|
||||||
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
|
||||||
<div class="assigned-tech-content">
|
|
||||||
{#if queenTech}
|
|
||||||
<div class="assigned-tech-chip">
|
|
||||||
{queenTech}
|
|
||||||
<span class="remove-tech-btn" on:click={() => queenTech = ''}>×</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-footer">
|
|
||||||
<span></span>
|
|
||||||
<span class="rank">Q</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- KING SLOT -->
|
|
||||||
<div class="face-card-slot-wrapper">
|
|
||||||
<div class="face-card-slot {kingTech ? 'has-assignment' : ''}"
|
|
||||||
on:dragover|preventDefault
|
|
||||||
on:drop={(e) => {
|
|
||||||
const tech = e.dataTransfer.getData('text/plain');
|
|
||||||
if (tech) {
|
|
||||||
if (jackTech === tech) jackTech = '';
|
|
||||||
if (queenTech === tech) queenTech = '';
|
|
||||||
kingTech = tech;
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<div class="card-bg-letter">K</div>
|
|
||||||
<div class="card-slot-header">
|
|
||||||
<span class="rank">K</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-body">
|
|
||||||
<div class="card-title">King</div>
|
|
||||||
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
|
||||||
<div class="assigned-tech-content">
|
|
||||||
{#if kingTech}
|
|
||||||
<div class="assigned-tech-chip">
|
|
||||||
{kingTech}
|
|
||||||
<span class="remove-tech-btn" on:click={() => kingTech = ''}>×</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-footer">
|
|
||||||
<span></span>
|
|
||||||
<span class="rank">K</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if !state.player.is_ready}
|
||||||
<div class="text-center italic text-gray-400">
|
<div class="delegation-box glass-panel mt-4">
|
||||||
Waiting for all players to submit techniques so they can be shuffled...
|
<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>
|
</div>
|
||||||
{/if}
|
{/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}
|
{:else}
|
||||||
<div class="assigned-techs text-center mt-4">
|
<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">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">Q</span> {state.player.tech_queen}</div>
|
||||||
<div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div>
|
<div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div>
|
||||||
@@ -501,32 +559,12 @@
|
|||||||
<span class="spinner-small"></span>
|
<span class="spinner-small"></span>
|
||||||
Ready! Waiting for other players to finish J/Q/K assignment...
|
Ready! Waiting for other players to finish J/Q/K assignment...
|
||||||
</p>
|
</p>
|
||||||
|
{#if phase === 'assign_techniques'}
|
||||||
|
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseFaceTechniques}>✏️ Revise Assignment</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SECTION 5: Crew Creation Progress -->
|
|
||||||
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
|
|
||||||
<h3>V. Crew Creation Status</h3>
|
|
||||||
<p class="section-desc">See the creation progress of the entire crew in real time.</p>
|
|
||||||
<div class="crew-status-list">
|
|
||||||
<ul class="space-y-2 mt-4">
|
|
||||||
{#each state.players as p}
|
|
||||||
<li class="p-3 bg-dark-800 rounded flex justify-between items-center border-l-4 {p.tech_jack ? 'border-green-500' : 'border-gray-500'}">
|
|
||||||
<span>{displayName(p)}</span>
|
|
||||||
<span class="text-sm">
|
|
||||||
{#if p.tech_jack}
|
|
||||||
<span class="text-green-400">Ready</span>
|
|
||||||
{:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3}
|
|
||||||
<span class="text-yellow-400">Assigning Face Cards</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-gray-400">Writing Sheet</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { tick } from 'svelte';
|
import { tick, onMount, createEventDispatcher } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
export let state;
|
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 PAGE_SIZE = 50;
|
||||||
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
||||||
@@ -27,12 +32,32 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let open = false;
|
let open = false;
|
||||||
|
// The content is visible whenever the floating log is open OR it's pinned inline.
|
||||||
|
$: shown = open || inline;
|
||||||
let logEl;
|
let logEl;
|
||||||
let events = []; // ascending by timestamp; accumulated across polls + history loads
|
let events = []; // ascending by timestamp; accumulated across polls + history loads
|
||||||
let seenIds = new Set();
|
let seenIds = new Set();
|
||||||
let haveMore = true;
|
let haveMore = true;
|
||||||
let loadingOlder = false;
|
let loadingOlder = false;
|
||||||
let atBottom = true;
|
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);
|
$: mergePolled(state.events);
|
||||||
|
|
||||||
@@ -40,24 +65,54 @@
|
|||||||
// otherwise events the player paged back to would vanish as the window slides.
|
// otherwise events the player paged back to would vanish as the window slides.
|
||||||
async function mergePolled(polled) {
|
async function mergePolled(polled) {
|
||||||
if (!polled) return;
|
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) {
|
if (events.length === 0) {
|
||||||
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
||||||
}
|
}
|
||||||
let added = false;
|
let added = false;
|
||||||
|
let newest = null;
|
||||||
for (const e of polled) {
|
for (const e of polled) {
|
||||||
if (!seenIds.has(e.id)) {
|
if (!seenIds.has(e.id)) {
|
||||||
seenIds.add(e.id);
|
seenIds.add(e.id);
|
||||||
events.push(e);
|
events.push(e);
|
||||||
added = true;
|
added = true;
|
||||||
|
newest = e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (added) {
|
if (added) {
|
||||||
events = events;
|
events = events;
|
||||||
if (open && atBottom) {
|
if (shown && atBottom) {
|
||||||
await tick();
|
await tick();
|
||||||
scrollToBottom();
|
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() {
|
function scrollToBottom() {
|
||||||
@@ -107,14 +162,59 @@
|
|||||||
function formatTime(ts) {
|
function formatTime(ts) {
|
||||||
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="floating-event-log {open ? 'open' : ''}">
|
<div class="floating-event-log {shown ? 'open' : ''} {inline ? 'inline' : ''}">
|
||||||
<button class="toggle-log-btn" on:click={toggleOpen}>
|
{#if toast && !shown}
|
||||||
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
{#key toast.id}
|
||||||
</button>
|
<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 open}
|
{#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}>
|
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
|
||||||
{#if events.length}
|
{#if events.length}
|
||||||
<div class="log-top">
|
<div class="log-top">
|
||||||
@@ -127,10 +227,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#each events as event (event.id)}
|
{#each events as event (event.id)}
|
||||||
<div class="log-entry log-kind-{event.kind || 'info'}">
|
{@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-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
|
||||||
<span class="log-message">{event.message}</span>
|
<span class="log-message">{event.message}</span>
|
||||||
<span class="log-time">{formatTime(event.timestamp)}</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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -150,10 +258,10 @@
|
|||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 380px;
|
width: 380px;
|
||||||
background: rgba(10, 15, 25, 0.95);
|
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
||||||
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
|
border: 1px solid var(--edge);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
box-shadow: var(--shadow-deep);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -163,20 +271,66 @@
|
|||||||
.floating-event-log:not(.open) {
|
.floating-event-log:not(.open) {
|
||||||
width: auto;
|
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 {
|
.toggle-log-btn {
|
||||||
background: var(--dark-bg, #1a1a2e);
|
background: transparent;
|
||||||
color: white;
|
color: var(--text);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
}
|
}
|
||||||
.toggle-log-btn:hover {
|
.toggle-log-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
}
|
}
|
||||||
.log-content {
|
.log-content {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -193,34 +347,73 @@
|
|||||||
}
|
}
|
||||||
.log-top-note {
|
.log-top-note {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted, #94a3b8);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.load-older-btn {
|
.load-older-btn {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid var(--edge);
|
||||||
color: var(--text-muted, #94a3b8);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.load-older-btn:hover {
|
.load-older-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: color-mix(in srgb, var(--text) 12%, transparent);
|
||||||
color: var(--text-primary, #f1f5f9);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.log-entry {
|
.log-entry {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr auto auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: color-mix(in srgb, var(--text) 3%, transparent);
|
||||||
border-left: 3px solid rgba(255, 255, 255, 0.15);
|
border-left: 3px solid var(--edge);
|
||||||
border-radius: 0 6px 6px 0;
|
border-radius: 0 6px 6px 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
color: #c3cede;
|
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 {
|
.log-icon {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -230,13 +423,13 @@
|
|||||||
}
|
}
|
||||||
.log-time {
|
.log-time {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-muted, #94a3b8);
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
.log-empty {
|
.log-empty {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
color: var(--text-muted, #94a3b8);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.jump-to-bottom {
|
.jump-to-bottom {
|
||||||
@@ -244,37 +437,79 @@
|
|||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--gold, #d4af37);
|
background: var(--accent);
|
||||||
color: var(--text-dark, #0f172a);
|
color: var(--text-inverse);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 5px 14px;
|
padding: 5px 14px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
.jump-to-bottom:hover {
|
.jump-to-bottom:hover {
|
||||||
filter: brightness(1.1);
|
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 */
|
/* Accent colors by event kind */
|
||||||
.log-kind-challenge { border-left-color: var(--red-suit, #ff2a5f); }
|
.log-kind-challenge { border-left-color: var(--danger); }
|
||||||
.log-kind-card { border-left-color: var(--neon-cyan, #00f2fe); }
|
.log-kind-card { border-left-color: var(--deep); }
|
||||||
.log-kind-captain,
|
.log-kind-captain,
|
||||||
.log-kind-tax,
|
.log-kind-tax,
|
||||||
.log-kind-rank,
|
.log-kind-rank,
|
||||||
.log-kind-victory { border-left-color: var(--gold, #d4af37); }
|
.log-kind-victory { border-left-color: var(--accent); }
|
||||||
.log-kind-scene,
|
.log-kind-scene,
|
||||||
.log-kind-phase { border-left-color: var(--neon-emerald, #00ff87); }
|
.log-kind-phase { border-left-color: var(--pirat); }
|
||||||
.log-kind-joker,
|
.log-kind-joker,
|
||||||
.log-kind-warning { border-left-color: #ff9f43; }
|
.log-kind-warning { border-left-color: var(--warning); }
|
||||||
.log-kind-obstacle { border-left-color: #4aa3df; }
|
.log-kind-obstacle { border-left-color: var(--deep); }
|
||||||
.log-kind-join,
|
.log-kind-join,
|
||||||
.log-kind-vote,
|
.log-kind-vote,
|
||||||
.log-kind-objective { border-left-color: #9b8cff; }
|
.log-kind-objective { border-left-color: var(--mystic); }
|
||||||
.log-kind-victory {
|
.log-kind-victory {
|
||||||
background: rgba(212, 175, 55, 0.08);
|
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||||
color: var(--gold, #d4af37);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
||||||
@@ -1,22 +1,19 @@
|
|||||||
<script>
|
<script>
|
||||||
import { displayName } from '../lib/cards';
|
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
$: pirats = state.players;
|
|
||||||
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
|
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dashboard-container" style="max-width: 800px; margin: 0 auto; padding: 2rem;">
|
<div class="gameover-container">
|
||||||
<div class="card glass-panel" style="text-align: center; padding: 2rem;">
|
<div class="card glass-panel gameover-card">
|
||||||
<h1 style="font-family: var(--font-heading); color: var(--gold);">🎉 The Story Has Ended!</h1>
|
<h1>🎉 The Story Has Ended!</h1>
|
||||||
<p class="section-desc" style="font-size: 1.1rem;">
|
<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.
|
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!
|
Tell one last tale of how your Pi-Rat is remembered!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="sheet-group" style="text-align: left; margin-top: 2rem;">
|
<div class="sheet-group">
|
||||||
<h3 style="color: var(--gold);">Crew Objectives</h3>
|
<h3 class="text-accent">Crew Objectives</h3>
|
||||||
<div class="objectives-checklist">
|
<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_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_2} disabled> <span>Choose a Captain</span></label>
|
||||||
@@ -29,24 +26,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sheet-group" style="text-align: left; margin-top: 1.5rem;">
|
|
||||||
<h3 style="color: var(--gold);">The Crew</h3>
|
|
||||||
{#each pirats as p}
|
|
||||||
<div style="background: rgba(0,0,0,0.2); padding: 0.75rem; border-radius: 6px; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
|
||||||
<span>
|
|
||||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if}
|
|
||||||
<strong>{displayName(p)}</strong> (Rank {p.rank})
|
|
||||||
{#if p.id === state.game.captain_player_id}<span style="color: var(--gold);"> ⭐ Captain</span>{/if}
|
|
||||||
</span>
|
|
||||||
<span style="font-size: 0.85rem; color: var(--text-muted);">
|
|
||||||
{p.completed_personal_1 ? '🔫 Gat' : '— no gat'} ·
|
|
||||||
{p.completed_personal_2 ? '📛 Named' : '— nameless'} ·
|
|
||||||
{p.completed_personal_3 ? '⚰️ Story complete' : '⛵ Still sailing'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="info-text" style="margin-top: 2rem;">Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!</p>
|
<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>
|
||||||
</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>
|
||||||
@@ -4,6 +4,15 @@
|
|||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
let starting = false;
|
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() {
|
async function startGame() {
|
||||||
starting = true;
|
starting = true;
|
||||||
@@ -15,59 +24,95 @@
|
|||||||
starting = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="lobby-view text-center">
|
<div class="lobby-view text-center">
|
||||||
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
|
<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>
|
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
||||||
|
|
||||||
<div class="lobby-status-box glass-panel">
|
|
||||||
<h3>Joined Crew members</h3>
|
|
||||||
<div class="player-chips" id="lobby-player-list">
|
|
||||||
{#each state.players as p}
|
|
||||||
<div class="player-chip {p.id === state.player.id ? 'current-user' : ''}">
|
|
||||||
<span class="avatar-icon">🐀</span>
|
|
||||||
<span class="name">{p.name}</span>
|
|
||||||
{#if p.is_creator}<span class="creator-badge">Creator</span>{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="links-box glass-panel">
|
<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">
|
<div class="link-item">
|
||||||
<h4>Share Join Link:</h4>
|
<h4>Share Join Link:</h4>
|
||||||
<div class="copy-container">
|
<div class="copy-container">
|
||||||
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/join" class="copy-input" id="join-link-copy">
|
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/join" class="copy-input" id="join-link-copy">
|
||||||
<button on:click={() => { navigator.clipboard.writeText(document.getElementById('join-link-copy').value); alert('Join link copied!'); }} class="btn btn-secondary btn-small">Copy</button>
|
<button on:click={() => copyLink('join', document.getElementById('join-link-copy').value)} class="btn btn-secondary btn-small">
|
||||||
|
{copiedLink === 'join' ? '✓ Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if state.player.is_creator}
|
{#if state.player.is_admin}
|
||||||
<div class="link-item admin-link-item">
|
<div class="link-item admin-link-item">
|
||||||
<h4 class="gold-text">⚠️ Creator Admin Magic Link:</h4>
|
<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>
|
<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">
|
<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">
|
<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={() => { navigator.clipboard.writeText(document.getElementById('admin-link-copy').value); alert('Admin link copied!'); }} class="btn btn-gold btn-small">Copy Admin Link</button>
|
<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>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<a href="/#/game/{state.game.id}/admin" class="text-sm text-silver underline hover:text-white">Access Admin Panel directly</a>
|
<a href="/#/game/{state.game.id}/admin" class="text-sm text-muted underline">Access Admin Panel directly</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<div class="lobby-action">
|
||||||
{#if state.player.is_creator}
|
{#if state.player.is_admin}
|
||||||
{#if state.players.length >= 1}
|
{#if state.players.length >= 3 || state.game.dev_mode}
|
||||||
<button on:click={startGame}
|
<button on:click={startGame}
|
||||||
disabled={starting}
|
disabled={starting}
|
||||||
class="btn btn-primary btn-large glow-effect">
|
class="btn btn-primary btn-large glow-effect">
|
||||||
{starting ? 'Starting...' : 'Start Character Creation'}
|
{starting ? 'Starting...' : 'Start Character Creation'}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn btn-primary btn-large" disabled>Waiting for players...</button>
|
<button class="btn btn-primary btn-large" disabled>Waiting for players... ({state.players.length}/3 minimum)</button>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="waiting-indicator">
|
<div class="waiting-indicator">
|
||||||
|
|||||||
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>
|
||||||
@@ -1,101 +1,79 @@
|
|||||||
<script>
|
<script>
|
||||||
import Card from './Card.svelte';
|
import Card from './Card.svelte';
|
||||||
import { displayName } from '../lib/cards';
|
|
||||||
import ChallengePanel from './scene/ChallengePanel.svelte';
|
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||||
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||||
import DeepControlPanel from './scene/DeepControlPanel.svelte';
|
import CrewColumn from './scene/CrewColumn.svelte';
|
||||||
import CharacterSheet from './scene/CharacterSheet.svelte';
|
import EventLog from './EventLog.svelte';
|
||||||
|
|
||||||
export let state;
|
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) : [];
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
|
|
||||||
$: isCaptain = state.game.captain_player_id === state.player.id;
|
|
||||||
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
$: 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>
|
</script>
|
||||||
|
|
||||||
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
<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 Shared Table -->
|
<!-- LEFT COLUMN: The Crew -->
|
||||||
<div class="table-column">
|
<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}
|
||||||
|
|
||||||
<!-- Game Deck and Obstacles Roster -->
|
|
||||||
<div class="card glass-panel obstacle-list-card">
|
<div class="card glass-panel obstacle-list-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>🌊 The Obstacle List</h3>
|
<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>
|
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ObstacleBoard {state} />
|
||||||
<div class="obstacles-container" id="scene-obstacles-container">
|
|
||||||
<!-- TOP STATUS BAR: Scene Info and Captain -->
|
|
||||||
<div class="scene-status-banner glass-panel" style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(7, 11, 18, 0.6); border: 1px solid rgba(212, 175, 55, 0.2); border-radius: 8px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
|
||||||
<!-- Captain Badge -->
|
|
||||||
<div>
|
|
||||||
{#if captain}
|
|
||||||
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;">
|
|
||||||
🏴☠️ Captain: {displayName(captain)} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
|
|
||||||
🏴☠️ Captain: None{state.game.completed_crew_1 ? '' : ' (steal a ship first!)'}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Active Scene Roster Mini-list -->
|
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
|
||||||
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-heading); margin-right: 0.25rem;">Active Roster:</span>
|
|
||||||
{#each state.players as p}
|
|
||||||
{#if p.role === 'deep'}
|
|
||||||
<span class="player-chip deep-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem;">
|
|
||||||
🌊 {displayName(p)} (Deep)
|
|
||||||
</span>
|
|
||||||
{:else if p.role === 'pirat'}
|
|
||||||
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {p.is_ghost ? 'border-color: #7890a8; color: #a0b0c0; background: rgba(120, 150, 180, 0.1);' : ''} {p.is_dead ? 'border-color: var(--red-suit); color: var(--red-suit); background: rgba(255, 42, 95, 0.1);' : ''} {p.id === state.game.captain_player_id ? 'border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);' : ''}">
|
|
||||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {displayName(p)} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChallengePanel {state} />
|
|
||||||
<ObstacleBoard {state} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
<!-- RIGHT COLUMN: The Event Log (collapsible to a corner button) -->
|
||||||
<div class="private-column">
|
{#if logOpen}
|
||||||
{#if state.player.role === "deep"}
|
<div class="log-column">
|
||||||
<DeepControlPanel {state} />
|
<EventLog {state} inline on:collapse={() => (logOpen = false)} />
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Player Hand Card -->
|
|
||||||
<div class="card glass-panel hand-card">
|
|
||||||
<h3>🃏 Your Hand</h3>
|
|
||||||
<p class="section-desc">Keep your cards secret! {#if state.player.role !== "deep"}When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.{/if}</p>
|
|
||||||
|
|
||||||
<div class="hand-flex">
|
|
||||||
{#each hand as card}
|
|
||||||
<Card {card} techs={playerTechs}
|
|
||||||
draggable={state.player.role !== "deep"}
|
|
||||||
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>
|
</div>
|
||||||
|
{/if}
|
||||||
{#if state.player.role !== "deep"}
|
|
||||||
<CharacterSheet {state} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { displayName } from '../lib/cards';
|
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
@@ -41,10 +40,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: allRolesAssigned = state.players.every(p => p.role !== null);
|
$: allRolesAssigned = state.players.every(p => p.role !== null || p.needs_reroll);
|
||||||
$: deepPlayer = state.players.find(p => p.role === 'deep');
|
$: deepPlayer = state.players.find(p => p.role === 'deep');
|
||||||
$: piratPlayer = state.players.find(p => p.role === 'pirat');
|
$: piratPlayer = state.players.find(p => p.role === 'pirat');
|
||||||
$: canStart = allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_creator || state.player.role === 'deep');
|
$: enoughPlayers = state.players.length >= 3 || state.game.dev_mode;
|
||||||
|
$: canStart = enoughPlayers && allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_admin || state.player.role === 'deep');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="scene-setup-view text-center">
|
<div class="scene-setup-view text-center">
|
||||||
@@ -52,22 +52,28 @@
|
|||||||
<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>
|
<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}
|
{#if state.game.current_scene_number === 1}
|
||||||
<div class="glass-panel" style="max-width: 700px; margin: 0 auto 1.5rem; padding: 1rem 1.5rem; border: 1px dashed var(--gold); border-radius: 8px; background: rgba(212, 175, 55, 0.08); text-align: left;">
|
<div class="notice-banner accent">
|
||||||
<h4 style="color: var(--gold); margin: 0 0 0.5rem 0; font-family: var(--font-heading);">🚢 First Scene Framing</h4>
|
<h4>🚢 First Scene Framing</h4>
|
||||||
<p class="info-text" style="margin: 0; font-size: 0.95rem;">
|
<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>
|
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'}
|
{#if state.player.role === 'deep'}
|
||||||
<br/><span style="color: var(--gold);">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>
|
<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}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="setup-grid">
|
<div class="setup-grid single">
|
||||||
<!-- Role Selection Card -->
|
<!-- Role Selection Card -->
|
||||||
<div class="card glass-panel role-selection-card">
|
<div class="card glass-panel role-selection-card">
|
||||||
<h3>Select Your Role</h3>
|
<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">
|
<div class="role-buttons">
|
||||||
<button on:click={() => setRole('pirat')}
|
<button on:click={() => setRole('pirat')}
|
||||||
disabled={settingRole || !allowedRoles.includes('pirat')}
|
disabled={settingRole || !allowedRoles.includes('pirat')}
|
||||||
@@ -83,41 +89,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="role-restrictions mt-4">
|
<div class="role-restrictions mt-4">
|
||||||
{#if !allowedRoles.includes('pirat')}
|
{#if !allowedRoles.includes('pirat') && !state.player.needs_reroll}
|
||||||
{#if state.game.current_scene_number === 1}
|
{#if state.game.current_scene_number === 1}
|
||||||
<p class="text-red-400 text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p>
|
<p class="text-danger text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-red-400 text-sm italic">You are the only eligible player for The Deep this scene!</p>
|
<p class="text-danger text-sm italic">You are the only eligible player for The Deep this scene!</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if !allowedRoles.includes('deep')}
|
{#if !allowedRoles.includes('deep') && !state.player.needs_reroll}
|
||||||
{#if state.game.current_scene_number === 1}
|
{#if state.game.current_scene_number === 1}
|
||||||
<p class="text-red-400 text-sm italic">You start at Rank 1, so you must play your Pi-Rat for the first scene!</p>
|
<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}
|
{:else}
|
||||||
<p class="text-red-400 text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
|
<p class="text-danger text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live Roster Card -->
|
|
||||||
<div class="card glass-panel roster-card">
|
|
||||||
<h3>Live Roster Selection</h3>
|
|
||||||
<div class="roster-list" id="scene-roster-list">
|
|
||||||
{#each state.players as p}
|
|
||||||
<div class="roster-item">
|
|
||||||
<span class="player-name">{displayName(p)} <span class="text-sm text-gray-400">(Rank {p.rank})</span></span>
|
|
||||||
{#if p.role === 'pirat'}
|
|
||||||
<span class="role-badge pirat">Pi-Rat</span>
|
|
||||||
{:else if p.role === 'deep'}
|
|
||||||
<span class="role-badge deep">The Deep</span>
|
|
||||||
{:else}
|
|
||||||
<span class="role-badge unassigned">Choosing...</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-box margin-top">
|
<div class="action-box margin-top">
|
||||||
@@ -131,14 +119,16 @@
|
|||||||
class="btn btn-primary btn-large glow-effect">
|
class="btn btn-primary btn-large glow-effect">
|
||||||
{starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'}
|
{starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'}
|
||||||
</button>
|
</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}
|
{:else if !allRolesAssigned}
|
||||||
<p class="text-gray-400 italic mt-4">Waiting for all players to choose a role...</p>
|
<p class="text-muted italic mt-4">Waiting for all players to choose a role...</p>
|
||||||
{:else if !deepPlayer}
|
{:else if !deepPlayer}
|
||||||
<p class="text-red-400 italic mt-4">Someone must play as The Deep!</p>
|
<p class="text-danger italic mt-4">Someone must play as The Deep!</p>
|
||||||
{:else if !piratPlayer}
|
{:else if !piratPlayer}
|
||||||
<p class="text-red-400 italic mt-4">Someone must play as a Pi-Rat!</p>
|
<p class="text-danger italic mt-4">Someone must play as a Pi-Rat!</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-gray-400 italic mt-4">Waiting for The Deep or Creator to start the scene...</p>
|
<p class="text-muted italic mt-4">Waiting for The Deep or Creator to start the scene...</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { getSuggestion } from '../lib/suggestions';
|
|
||||||
import { displayName } from '../lib/cards';
|
import { displayName } from '../lib/cards';
|
||||||
import Card from './Card.svelte';
|
import Card from './Card.svelte';
|
||||||
|
|
||||||
@@ -14,22 +13,30 @@
|
|||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: 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
|
// Drag & Drop State
|
||||||
let keepCards = [];
|
let keepCards = [];
|
||||||
let discardCards = [];
|
let discardCards = [];
|
||||||
let dragNDropInitialized = false;
|
let dragNDropInitialized = false;
|
||||||
|
|
||||||
// Ghost & Re-rolling fate states
|
// Ghost & Re-rolling fate choice
|
||||||
let isRolling = false;
|
|
||||||
let reRollDetails = {
|
|
||||||
avatar_look: '',
|
|
||||||
avatar_smell: '',
|
|
||||||
first_words: '',
|
|
||||||
good_at_math: ''
|
|
||||||
};
|
|
||||||
let rollingError = '';
|
|
||||||
|
|
||||||
async function becomeGhost() {
|
async function becomeGhost() {
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST');
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST');
|
||||||
@@ -38,6 +45,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
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;
|
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 {
|
try {
|
||||||
@@ -47,30 +62,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitReRoll() {
|
|
||||||
if (!reRollDetails.avatar_look.trim() ||
|
|
||||||
!reRollDetails.avatar_smell.trim() ||
|
|
||||||
!reRollDetails.first_words.trim() ||
|
|
||||||
!reRollDetails.good_at_math.trim()) {
|
|
||||||
rollingError = 'Please fill out all character details.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rollingError = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/roll-new-character`, 'POST', reRollDetails);
|
|
||||||
isRolling = false;
|
|
||||||
// Clear inputs
|
|
||||||
reRollDetails = {
|
|
||||||
avatar_look: '',
|
|
||||||
avatar_smell: '',
|
|
||||||
first_words: '',
|
|
||||||
good_at_math: ''
|
|
||||||
};
|
|
||||||
} catch(e) {
|
|
||||||
rollingError = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (state && state.game.phase === 'deep_upkeep') {
|
if (state && state.game.phase === 'deep_upkeep') {
|
||||||
if (!dragNDropInitialized) {
|
if (!dragNDropInitialized) {
|
||||||
@@ -182,75 +173,32 @@
|
|||||||
<h2>Between Scenes (Scene #{state.game.current_scene_number} Concluded)</h2>
|
<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>
|
<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}
|
{#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll}
|
||||||
<!-- Death Fate Panel -->
|
<!-- Death Fate Panel -->
|
||||||
<div class="card glass-panel death-fate-card" style="border: 2px solid var(--red-suit); background: rgba(255, 42, 95, 0.05); padding: 2.5rem; margin: 2rem auto; max-width: 650px; border-radius: 12px; box-shadow: 0 0 20px rgba(255,42,95,0.15); text-align: center;">
|
<div class="glass-panel death-fate-card">
|
||||||
<h2 style="color: var(--red-suit); font-family: var(--font-heading); margin-bottom: 1rem; font-size: 2rem;">💀 Your Pi-Rat Has Died!</h2>
|
<h2>💀 Your Pi-Rat Has Died!</h2>
|
||||||
<p class="description" style="font-size: 1.15rem; color: var(--text-primary); margin-bottom: 2rem; line-height: 1.6;">
|
<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:
|
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>
|
</p>
|
||||||
|
<div class="fate-choices">
|
||||||
{#if !isRolling}
|
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}>
|
||||||
<div style="display: flex; gap: 1.5rem; justify-content: center; flex-wrap: wrap;">
|
👻 Remain as a Ghost
|
||||||
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost} style="border: 1px solid var(--text-muted); font-size: 1.1rem; padding: 0.8rem 2rem;">
|
</button>
|
||||||
👻 Remain as a Ghost
|
<button class="btn btn-primary btn-large glow-effect" on:click={chooseReRoll}>
|
||||||
</button>
|
🐀 Roll a New Character
|
||||||
<button class="btn btn-primary btn-large glow-effect" on:click={() => isRolling = true} style="background: var(--gold); border: 1px solid var(--gold); color: black; font-size: 1.1rem; padding: 0.8rem 2rem;">
|
</button>
|
||||||
🐀 Roll a New Character
|
</div>
|
||||||
</button>
|
<p class="info-text italic" style="margin-top: 1.5rem;">
|
||||||
</div>
|
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 class="info-text" style="margin-top: 1.5rem; font-style: italic; color: var(--text-muted);">
|
</p>
|
||||||
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh at Rank 1 with surprise Secret Techniques and a name based on their smell.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<div class="creation-form-wrapper" style="text-align: left; background: rgba(0,0,0,0.2); padding: 1.5rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.05);">
|
|
||||||
<h3 style="color: var(--gold); font-family: var(--font-heading); margin-bottom: 1.5rem; border-bottom: 1px solid rgba(212,175,55,0.2); padding-bottom: 0.5rem;">Create Your New Recruit</h3>
|
|
||||||
|
|
||||||
{#if rollingError}
|
|
||||||
<div class="alert alert-danger" style="margin-bottom: 1.5rem;">{rollingError}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom: 1.25rem;">
|
|
||||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What does your Pi-Rat look like?</label>
|
|
||||||
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
|
||||||
<input type="text" placeholder="e.g. Scruffy snout, wearing a tattered vest" bind:value={reRollDetails.avatar_look} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.avatar_look = getSuggestion('look')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom: 1.25rem;">
|
|
||||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What does your Pi-Rat smell like? (Determines name)</label>
|
|
||||||
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
|
||||||
<input type="text" placeholder="e.g. Spiced rum, salty cheese, gunpowder" bind:value={reRollDetails.avatar_smell} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.avatar_smell = getSuggestion('smell')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom: 1.25rem;">
|
|
||||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What were your first words after transforming?</label>
|
|
||||||
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
|
||||||
<input type="text" placeholder="e.g. Shiver my decimals!" bind:value={reRollDetails.first_words} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.first_words = getSuggestion('first_words')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom: 1.5rem;">
|
|
||||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">Is your Pi-Rat good at Math?</label>
|
|
||||||
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
|
||||||
<input type="text" placeholder="e.g. Thinks Pi is a dessert" bind:value={reRollDetails.good_at_math} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.good_at_math = getSuggestion('good_at_math')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 1rem; margin-top: 1.5rem;">
|
|
||||||
<button class="btn btn-secondary" on:click={() => isRolling = false} style="flex: 1; padding: 0.75rem;">Cancel</button>
|
|
||||||
<button class="btn btn-primary" on:click={submitReRoll} style="flex: 2; padding: 0.75rem; background: var(--gold); border: 1px solid var(--gold); color: black;">Submit New Character</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="between-grid">
|
{#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 -->
|
<!-- Ranks and Voting Card -->
|
||||||
<div class="card glass-panel voting-card">
|
<div class="card glass-panel voting-card">
|
||||||
<h3>1. Pirate Ranking (Voting)</h3>
|
<h3>1. Pirate Ranking (Voting)</h3>
|
||||||
@@ -258,21 +206,27 @@
|
|||||||
|
|
||||||
{#if state.game.phase === 'deep_upkeep'}
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
<div class="voted-confirmation-box glass-panel">
|
<div class="voted-confirmation-box glass-panel">
|
||||||
<p class="success-text">✔️ Voting complete! Results tallied.</p>
|
{#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>
|
</div>
|
||||||
{:else if state.votes.find(v => v.voter_player_id === state.player.id)}
|
{:else if hasVoted}
|
||||||
<div class="voted-confirmation-box glass-panel">
|
<div class="voted-confirmation-box glass-panel">
|
||||||
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
|
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
|
||||||
<div class="form-group inline-group">
|
<div class="form-group inline-group">
|
||||||
<select class="select-field" bind:value={selectedVoteId} required>
|
<select class="select-field" bind:value={selectedVoteId} required>
|
||||||
<option value="">Nominate crewmate...</option>
|
<option value="">Nominate crewmate...</option>
|
||||||
{#each state.players as p}
|
{#each myNominees as p}
|
||||||
{#if p.id !== state.player.id && p.role !== 'deep'}
|
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
||||||
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button>
|
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button>
|
||||||
@@ -280,34 +234,31 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Voting Roster Status -->
|
|
||||||
{#if state.game.phase !== 'deep_upkeep'}
|
|
||||||
<div class="vote-status-table margin-top">
|
|
||||||
<h4>Nomination Progress:</h4>
|
|
||||||
<div class="player-chips list-chips" id="voting-roster">
|
|
||||||
{#each state.players as p}
|
|
||||||
{@const voted = state.votes.some(v => v.voter_player_id === p.id)}
|
|
||||||
<div class="player-chip {voted ? 'voted-chip' : 'not-voted-chip'}">
|
|
||||||
<span class="name">{displayName(p)}</span>
|
|
||||||
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resting Deep Player Card -->
|
<!-- 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">
|
<div class="card glass-panel deep-rest-card">
|
||||||
<h3>2. Resting Deep Upkeep</h3>
|
<h3>2. Resting Deep Upkeep</h3>
|
||||||
|
|
||||||
{#if state.player.role === "deep"}
|
{#if state.player.role === "deep"}
|
||||||
<div class="deep-rest-panel glass-panel">
|
<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>
|
<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.game.phase === 'between_scenes'}
|
{#if state.player.is_ready}
|
||||||
<p class="info-text text-center gold-text" style="margin-top: 1rem;">Voting is currently in progress. Hand refresh will begin after voting concludes.</p>
|
<!-- Confirmation: the refresh went through; show the freshly-drawn hand. -->
|
||||||
{:else if state.game.phase === 'deep_upkeep'}
|
<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;">
|
<p class="info-text" style="margin-top: 1rem;">
|
||||||
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
|
<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.
|
Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
|
||||||
@@ -350,8 +301,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isOverLimit}
|
{#if isOverLimit}
|
||||||
<div style="border: 1px solid var(--red-suit); border-radius: 4px; padding: 1rem; margin: 1rem 0; background: rgba(255, 42, 95, 0.1);">
|
<div class="notice-banner danger" style="margin: 1rem 0; max-width: none;">
|
||||||
<p style="color: var(--red-suit); font-weight: bold; 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>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -361,31 +312,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if state.game.phase === 'between_scenes'}
|
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
|
||||||
<p class="info-text text-center">Your Pi-Rat was active. Your hand cards carry over to the next scene.</p>
|
|
||||||
{:else if state.game.phase === 'deep_upkeep'}
|
|
||||||
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="roster-sheet-summary margin-top text-left glass-panel">
|
|
||||||
<h4>Crew Rank Ledger:</h4>
|
|
||||||
<ul class="ranks-ledger">
|
|
||||||
{#each state.players as p}
|
|
||||||
<li>
|
|
||||||
<strong>{displayName(p)}:</strong> Rank {p.rank}
|
|
||||||
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge" style="background: rgba(120, 150, 180, 0.15); border: 1px solid #7890a8; color: #a0b0c0; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; margin-left: 0.5rem; display: inline-block;">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Next Scene Ready Up -->
|
<!-- Next Scene Ready Up -->
|
||||||
<div class="action-box margin-top">
|
<div class="action-box margin-top">
|
||||||
{#if state.game.phase === 'deep_upkeep'}
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
{#if state.player.role === 'deep'}
|
{#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>
|
<p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="waiting-box">
|
<div class="waiting-box">
|
||||||
@@ -394,7 +330,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{#if !state.votes.find(v => v.voter_player_id === state.player.id)}
|
{#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>
|
<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>
|
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -413,8 +349,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if state.player.is_creator && state.game.phase === 'between_scenes'}
|
{#if state.player.is_admin && state.game.phase === 'between_scenes'}
|
||||||
<div style="margin-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
|
<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>
|
<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>
|
<button on:click={finishGame} class="btn btn-danger">🏴☠️ End the Story</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
import { getCardDisplay, isJoker, displayName, playerName as lookupName } from '../../lib/cards';
|
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;
|
export let state;
|
||||||
|
|
||||||
let error = '';
|
let error = '';
|
||||||
let taxTargetId = '';
|
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) : [];
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
$: 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;
|
$: 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;
|
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
|
||||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
|
||||||
function playerName(id) {
|
function playerName(id) {
|
||||||
return lookupName(state.players, id);
|
return lookupName(state.players, id);
|
||||||
@@ -53,6 +63,31 @@
|
|||||||
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>
|
</script>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -60,11 +95,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each openChallenges as ch}
|
{#each openChallenges as ch}
|
||||||
{@const chPlays = JSON.parse(ch.plays || '[]')}
|
|
||||||
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
|
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
|
||||||
<div class="glass-panel" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red-suit, #ff2a5f); border-radius: 8px; background: rgba(255, 42, 95, 0.06);">
|
<div class="challenge-item">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
<div class="challenge-head">
|
||||||
<h4 style="margin: 0; font-family: var(--font-heading);">
|
<h4>
|
||||||
{#if ch.challenge_type === 'pvp'}
|
{#if ch.challenge_type === 'pvp'}
|
||||||
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
|
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -72,39 +106,44 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</h4>
|
</h4>
|
||||||
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
|
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<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-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>
|
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if ch.stakes}
|
{#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>
|
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if ch.challenge_type === 'pvp'}
|
{#if ch.challenge_type === 'pvp'}
|
||||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||||
Temporary Obstacle: <strong>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
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>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
<div class="challenge-obstacles">
|
||||||
Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'}
|
{#each chObstacleIds as oid}
|
||||||
— attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
|
{@const obs = state.obstacles.find(o => o.id === oid)}
|
||||||
Other Pi-Rats may assist (assistants don't draw back).
|
{#if obs}
|
||||||
</p>
|
<ObstacleItem {state} {obs} embedded />
|
||||||
|
{:else}
|
||||||
|
<p class="info-text" style="margin: 0;">An applied Obstacle was discarded.</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if chPlays.length > 0}
|
|
||||||
<p class="info-text" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;">
|
|
||||||
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Tax: pending request aimed at me -->
|
<!-- Tax: pending request aimed at me -->
|
||||||
{#if ch.tax_state === 'requested'}
|
{#if ch.tax_state === 'requested'}
|
||||||
{#if myTaxRequest && myTaxRequest.id === ch.id}
|
{#if myTaxRequest && myTaxRequest.id === ch.id}
|
||||||
<div style="margin-top: 0.75rem; padding: 0.75rem; border: 1px dashed var(--gold); border-radius: 6px;">
|
<div class="tax-callout">
|
||||||
<p style="margin: 0 0 0.5rem 0;"><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><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>
|
<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 style="display: flex; gap: 0.5rem;">
|
<div class="challenge-actions">
|
||||||
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
|
<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>
|
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,8 +155,8 @@
|
|||||||
|
|
||||||
<!-- Tax: option for the challenged player -->
|
<!-- 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}
|
{#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 style="margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
<div class="challenge-actions" style="margin-top: 0.75rem;">
|
||||||
<span class="info-text" style="font-size: 0.9rem;">No {taxType(state.player)}? Make it someone else's problem:</span>
|
<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;">
|
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
|
||||||
<option value="">Pick a crewmate...</option>
|
<option value="">Pick a crewmate...</option>
|
||||||
{#each eligibleTaxTargets() as p}
|
{#each eligibleTaxTargets() as p}
|
||||||
@@ -132,12 +171,55 @@
|
|||||||
{#if myPvpDefense && myPvpDefense.id === ch.id}
|
{#if myPvpDefense && myPvpDefense.id === ch.id}
|
||||||
<div style="margin-top: 0.75rem;">
|
<div style="margin-top: 0.75rem;">
|
||||||
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
<div class="challenge-actions">
|
||||||
{#each hand.filter(c => !isJoker(c)) as card}
|
{#each hand.filter(c => !isJoker(c)) as card}
|
||||||
<button class="btn btn-secondary" on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
<button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable, true) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/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}
|
||||||
|
|||||||
@@ -1,161 +1,213 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
import { getCardDisplay, isJoker, displayName } from '../../lib/cards';
|
import { getCardDisplay, isJoker, displayName, crewLabel, captainName, cardObstacleInfo, pvpObstacleInfo, obstacleTable } from '../../lib/cards';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
export let target; // the player whose sheet is being viewed
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
function close() { dispatch('close'); }
|
||||||
|
|
||||||
let error = '';
|
let error = '';
|
||||||
let newNameInput = '';
|
|
||||||
let pvpOpponentId = '';
|
|
||||||
let pvpCard = '';
|
let pvpCard = '';
|
||||||
|
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: viewer = state.player;
|
||||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
$: isSelf = target.id === viewer.id;
|
||||||
$: duelTargets = scenePirats.filter(p => p.id !== state.player.id && !p.is_dead);
|
$: 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);
|
||||||
|
|
||||||
async function submitNewName() {
|
function getPlayerName(id) {
|
||||||
if (!newNameInput.trim()) return;
|
if (!id) return 'a crewmate';
|
||||||
error = '';
|
const p = state.players.find(pl => pl.id === id);
|
||||||
try {
|
return p ? displayName(p) : 'Unknown';
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
|
|
||||||
new_name: newNameInput.trim()
|
|
||||||
});
|
|
||||||
newNameInput = '';
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPvp() {
|
async function createPvp() {
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
|
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/challenge/pvp/create`, 'POST', {
|
||||||
defender_id: pvpOpponentId,
|
defender_id: target.id,
|
||||||
card_code: pvpCard
|
card_code: pvpCard
|
||||||
});
|
});
|
||||||
pvpOpponentId = '';
|
|
||||||
pvpCard = '';
|
pvpCard = '';
|
||||||
} catch(e) {
|
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;
|
error = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card glass-panel sheet-card">
|
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||||
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
|
||||||
{#if state.player.player_name && state.player.player_name !== state.player.name}
|
|
||||||
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
|
|
||||||
Played by {state.player.player_name}{#if !state.player.completed_personal_2} — they'll go by their smell until they earn a Name!{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="character-sheet-details">
|
<div class="modal-backdrop" on:click|self={close}>
|
||||||
{#if error}
|
<div class="modal-box sheet-modal character-sheet-modal glass-panel">
|
||||||
<div class="alert alert-danger">{error}</div>
|
<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}
|
{/if}
|
||||||
|
|
||||||
{#if state.player.is_dead}
|
<div class="character-sheet-details">
|
||||||
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.1); border: 1px dashed var(--red-suit); padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
<div class="sheet-main-column">
|
||||||
<h4 style="color: var(--red-suit); margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">💀 You have Died / Retired!</h4>
|
{#if error}
|
||||||
<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 class="alert alert-danger">{error}</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if state.player.is_ghost}
|
{#if isSelf && state.player.needs_reroll}
|
||||||
<div class="sheet-group prompt-box" style="background: rgba(120, 150, 180, 0.1); border: 1px dashed #7890a8; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
<div class="prompt-box accent text-center">
|
||||||
<h4 style="color: #a0b0c0; margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">👻 Playing as a Ghost</h4>
|
<h4>🐀 Recruit Incoming!</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>
|
<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 state.player.needs_name}
|
|
||||||
<div class="sheet-group prompt-box" style="background: rgba(212, 175, 55, 0.1); border: 1px dashed var(--gold); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
|
|
||||||
<h4 style="color: var(--gold); margin-top: 0;">🏴☠️ You Earned a Name!</h4>
|
|
||||||
<p class="info-text" style="margin-bottom: 0.5rem;">You completed your second objective and ranked up! What is your new Pirate Name?</p>
|
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
|
||||||
<input type="text" bind:value={newNameInput} class="form-control" placeholder="Enter new name...">
|
|
||||||
<button class="btn btn-gold" on:click={submitNewName} disabled={!newNameInput.trim()}>Claim Name</button>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if state.player.tax_banned}
|
{#if target.other_like || target.other_hate}
|
||||||
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.08); border: 1px dashed var(--red-suit); padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
<div class="sheet-group margin-top">
|
||||||
<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>
|
<h4>What the Crew Thinks:</h4>
|
||||||
</div>
|
{#if target.other_like}
|
||||||
{/if}
|
<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}
|
||||||
|
|
||||||
<div class="sheet-group">
|
<!-- Secret techniques are visible only on your own sheet -->
|
||||||
<h4>Description:</h4>
|
{#if isSelf}
|
||||||
<p><strong>Look:</strong> {state.player.avatar_look}</p>
|
<div class="sheet-group margin-top">
|
||||||
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
|
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
||||||
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
|
<ul class="techniques-list-sheet">
|
||||||
</div>
|
<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}
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
<!-- Duel this Pi-Rat (target is fixed to this sheet) -->
|
||||||
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
{#if canDuel}
|
||||||
<ul class="techniques-list-sheet">
|
<div class="sheet-group margin-top">
|
||||||
<li><strong>Jack (J):</strong> "{state.player.tech_jack}"</li>
|
<h4>⚔️ Duel {crewLabel(target, state.game.captain_player_id)}</h4>
|
||||||
<li><strong>Queen (Q):</strong> "{state.player.tech_queen}"</li>
|
<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>
|
||||||
<li><strong>King (K):</strong> "{state.player.tech_king}"</li>
|
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
</ul>
|
<option value="">Throw which card?</option>
|
||||||
</div>
|
{#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}
|
||||||
|
|
||||||
<!-- Duel another Pi-Rat -->
|
{#if canManageCaptain}
|
||||||
{#if !state.player.is_dead && duelTargets.length > 0}
|
<div class="sheet-group margin-top">
|
||||||
<div class="sheet-group margin-top">
|
<h4>🏴☠️ Captaincy</h4>
|
||||||
<h4 style="color: var(--gold);">⚔️ Duel a Pi-Rat</h4>
|
{#if isCaptain}
|
||||||
<p class="info-text" style="font-size: 0.85rem;">Challenge a crewmate (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
|
<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>
|
||||||
<select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
|
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(false)}>Remove as Captain</button>
|
||||||
<option value="">Challenge whom?</option>
|
{:else}
|
||||||
{#each duelTargets as p}
|
<p class="info-text" style="font-size: 0.85rem;">Hand this Pi-Rat the Captaincy (e.g. after a leadership duel or resignation).</p>
|
||||||
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(true)}>Make Captain</button>
|
||||||
{/each}
|
{/if}
|
||||||
</select>
|
</div>
|
||||||
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
{/if}
|
||||||
<option value="">Throw which card?</option>
|
</div> <!-- end sheet-main-column -->
|
||||||
{#each hand.filter(c => !isJoker(c)) as card}
|
|
||||||
<option value={card}>{getCardDisplay(card)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
<div class="sheet-side-column">
|
||||||
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
<div class="sheet-group">
|
||||||
<div class="objectives-checklist">
|
<h4>Personal Objectives</h4>
|
||||||
<label class="checkbox-label">
|
{#if canManageObjectives}
|
||||||
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
|
<p class="info-text" style="font-size: 0.8rem;">As the Deep, tick these off in sequence (1 → 2 → 3).</p>
|
||||||
<span>Steal a Ship</span>
|
{/if}
|
||||||
</label>
|
<div class="objectives-checklist">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
|
<input type="checkbox" checked={target.completed_personal_1} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_1')}>
|
||||||
<span>Choose a Captain</span>
|
<span>1. Gat</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
|
<input type="checkbox" checked={target.completed_personal_2} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_2')}>
|
||||||
<span>Commit Piracy</span>
|
<span>2. Name</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<label class="checkbox-label">
|
||||||
</div>
|
<input type="checkbox" checked={target.completed_personal_3} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_3')}>
|
||||||
|
<span>3. Die/Retire</span>
|
||||||
<div class="sheet-group margin-top">
|
</label>
|
||||||
<h4 style="color: var(--gold);">Personal Objectives</h4>
|
</div>
|
||||||
<div class="objectives-checklist">
|
</div>
|
||||||
<label class="checkbox-label">
|
</div> <!-- end sheet-side-column -->
|
||||||
<input type="checkbox" checked={state.player.completed_personal_1} disabled>
|
|
||||||
<span>1. Gat</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" checked={state.player.completed_personal_2} disabled>
|
|
||||||
<span>2. Name</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" checked={state.player.completed_personal_3} disabled>
|
|
||||||
<span>3. Die/Retire</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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}
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { apiRequest } from '../../lib/api';
|
|
||||||
import { displayName } from '../../lib/cards';
|
|
||||||
|
|
||||||
export let state;
|
|
||||||
|
|
||||||
let error = '';
|
|
||||||
let challengeTargetId = '';
|
|
||||||
let challengeStakes = '';
|
|
||||||
let selectedObstacles = {};
|
|
||||||
let captainSelectId = '';
|
|
||||||
|
|
||||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
|
||||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
|
||||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
|
||||||
|
|
||||||
async function createChallenge() {
|
|
||||||
error = '';
|
|
||||||
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', {
|
|
||||||
target_player_id: challengeTargetId,
|
|
||||||
obstacle_ids: JSON.stringify(ids),
|
|
||||||
stakes: challengeStakes
|
|
||||||
});
|
|
||||||
challengeTargetId = '';
|
|
||||||
challengeStakes = '';
|
|
||||||
selectedObstacles = {};
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setCaptain() {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-captain`, 'POST', {
|
|
||||||
target_player_id: captainSelectId
|
|
||||||
});
|
|
||||||
captainSelectId = '';
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleObjective(targetPlayerId, type) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', { type });
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function endScene() {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
|
|
||||||
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="alert alert-danger">{error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Call a Challenge -->
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4 style="color: var(--gold);">⚔️ Call a Challenge</h4>
|
|
||||||
<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="form-control" placeholder="Stakes (optional): what happens on success/failure?" bind:value={challengeStakes} 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>
|
|
||||||
|
|
||||||
<!-- Captain Assignment -->
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4 style="color: var(--gold);">🏴☠️ Captaincy</h4>
|
|
||||||
<p class="info-text" style="font-size: 0.85rem;">The Captain holds power until they step down, lose a duel, die, or the ship is lost. Reassign after a leadership duel or resignation.</p>
|
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
|
||||||
<select class="select-field" bind:value={captainSelectId} style="flex: 1;">
|
|
||||||
<option value="">No Captain</option>
|
|
||||||
{#each scenePirats.filter(p => !p.is_dead) as p}
|
|
||||||
<option value={p.id}>{displayName(p)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-secondary" on:click={setCaptain}>Set</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
|
||||||
<div class="objectives-checklist">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" checked={state.game.completed_crew_1} on:change={() => toggleObjective(state.player.id, 'crew_1')}>
|
|
||||||
<span>Steal a Ship</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" checked={state.game.completed_crew_2} on:change={() => toggleObjective(state.player.id, 'crew_2')}>
|
|
||||||
<span>Choose a Captain</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" checked={state.game.completed_crew_3} on:change={() => toggleObjective(state.player.id, 'crew_3')}>
|
|
||||||
<span>Commit Piracy</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4 style="color: var(--gold);">Pi-Rat Personal Objectives</h4>
|
|
||||||
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
|
|
||||||
{#each scenePirats as p}
|
|
||||||
<div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
|
|
||||||
<strong>{displayName(p)}</strong>
|
|
||||||
<div class="objectives-checklist" style="margin-top: 0.25rem;">
|
|
||||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_1} on:change={() => toggleObjective(p.id, 'personal_1')}> <span>1. Gat</span></label>
|
|
||||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_2} on:change={() => toggleObjective(p.id, 'personal_2')}> <span>2. Name</span></label>
|
|
||||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_3} on:change={() => toggleObjective(p.id, 'personal_3')}> <span>3. Die/Retire</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- End Scene Button -->
|
|
||||||
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
|
|
||||||
<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>
|
|
||||||
@@ -1,131 +1,35 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../../lib/api';
|
import ObstacleItem from './ObstacleItem.svelte';
|
||||||
import { getCardDisplay, isJoker } from '../../lib/cards';
|
|
||||||
import Card from '../Card.svelte';
|
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
let error = '';
|
|
||||||
|
|
||||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||||
|
// When a Challenge is active, the involved Obstacles move up into the Challenge
|
||||||
// A face-card Obstacle is worth the challenged Rat's Rank. When the Obstacle is
|
// panel; the rest of the list collapses so attention stays on the Challenge.
|
||||||
// part of an open Challenge we know who that is — return their rank, else null.
|
$: challengeActive = openChallenges.length > 0;
|
||||||
function challengedRankFor(obstacleId) {
|
$: looseObstacles = state.obstacles.filter(o => !challengedObstacleIds.has(o.id));
|
||||||
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obstacleId));
|
|
||||||
if (!ch) return null;
|
|
||||||
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playCard(obstacleId, cardCode) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
|
|
||||||
obstacle_id: obstacleId,
|
|
||||||
card_code: cardCode
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playJoker(obstacleId, cardCode) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-joker`, 'POST', {
|
|
||||||
obstacle_id: obstacleId,
|
|
||||||
card_code: cardCode
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearObstacle(obstacleId) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if error}
|
{#if challengeActive}
|
||||||
<div class="alert alert-danger">{error}</div>
|
{#if looseObstacles.length}
|
||||||
{/if}
|
<details class="obstacle-collapse">
|
||||||
|
<summary>🌊 {looseObstacles.length} other Obstacle{looseObstacles.length === 1 ? '' : 's'} on the list — show</summary>
|
||||||
{#each state.obstacles as obs}
|
<div class="obstacles-container">
|
||||||
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
|
{#each looseObstacles as obs (obs.id)}
|
||||||
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card}
|
<ObstacleItem {state} {obs} />
|
||||||
{@const success_count = column_cards.filter(c => c.success).length}
|
|
||||||
{@const is_completed = success_count >= state.players.length}
|
|
||||||
{@const in_challenge = challengedObstacleIds.has(obs.id)}
|
|
||||||
<div class="obstacle-item suit-{obs.suit.toLowerCase()} {is_completed ? 'completed' : ''}"
|
|
||||||
style={in_challenge ? 'box-shadow: 0 0 0 2px var(--red-suit, #ff2a5f);' : ''}
|
|
||||||
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(obs.id, cardCode);
|
|
||||||
else playCard(obs.id, cardCode);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
|
|
||||||
<Card card={active_card_code} size="medium" title="Active Card: {getCardDisplay(active_card_code)}" />
|
|
||||||
</div>
|
|
||||||
<div class="obstacle-details">
|
|
||||||
<h4>{obs.title} {#if in_challenge}<span style="color: var(--red-suit, #ff2a5f); font-size: 0.8rem;">⚔️ In Challenge</span>{/if}</h4>
|
|
||||||
<p class="desc">{obs.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Value Display -->
|
|
||||||
<div class="obstacle-value-display text-center">
|
|
||||||
<span class="val-label">Current Difficulty</span>
|
|
||||||
<span class="val-number">
|
|
||||||
{#if ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1))}
|
|
||||||
{@const rank = challengedRankFor(obs.id)}
|
|
||||||
{rank !== null ? `${rank} (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" style="font-size: 1.5rem;">
|
|
||||||
{success_count} / {state.players.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Played Cards Column -->
|
|
||||||
<div class="played-column">
|
|
||||||
<h5>Card History (Column)</h5>
|
|
||||||
<div class="column-cards-flex">
|
|
||||||
<Card card={obs.original_card} size="mini" />
|
|
||||||
{#each column_cards as pc}
|
|
||||||
<Card card={pc.card} size="mini" rotated success={pc.success}
|
|
||||||
owner={pc.player_name.slice(0, 4)}
|
|
||||||
title="{pc.player_name} played {getCardDisplay(pc.card)}" />
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
|
{:else}
|
||||||
{#if is_completed && state.player.role === 'deep'}
|
<p class="empty-text">All Obstacles are tied up in the Challenge above.</p>
|
||||||
<div class="text-center" style="margin-top: 1rem;">
|
{/if}
|
||||||
<button class="btn btn-success" on:click={() => clearObstacle(obs.id)} style="width: 100%; border: 1px solid rgba(255,255,255,0.2);">Clear Obstacle</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
<div class="obstacles-container">
|
||||||
{/each}
|
{#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>
|
||||||
@@ -17,7 +17,9 @@ export async function apiRequest(endpoint, method = 'GET', data = null) {
|
|||||||
if (errBody.error) msg = errBody.error;
|
if (errBody.error) msg = errBody.error;
|
||||||
else if (errBody.detail) msg = errBody.detail;
|
else if (errBody.detail) msg = errBody.detail;
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
throw new Error(msg);
|
const error = new Error(msg);
|
||||||
|
error.status = res.status;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,118 @@
|
|||||||
// Card-code helpers shared across components.
|
// Card-code helpers shared across components.
|
||||||
// Codes look like "10D", "KH", "Joker1" (black) / "Joker2" (red).
|
// 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: '♦️' };
|
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
|
||||||
|
|
||||||
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
|
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
|
||||||
export const SUIT_THEMES = {
|
const SUIT_THEMES = {
|
||||||
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
|
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
|
||||||
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
|
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
|
||||||
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
|
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
|
||||||
H: "♥ Hearts — Shoutin' and Singin': social skills, performance, or charisma",
|
H: "♥ Hearts — Shoutin' and Singin': social skills, performance, or charisma",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default tooltip for a card: its suit theme (or what a Joker does).
|
// The rulebook's obstacle tables (what each card represents when it surfaces
|
||||||
export function cardTooltip(code) {
|
// 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 (!code) return '';
|
||||||
if (isJoker(code)) return '🃏 Joker — discards an Obstacle outright and draws a replacement';
|
if (isJoker(code)) {
|
||||||
return SUIT_THEMES[cardSuit(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) {
|
export function isJoker(code) {
|
||||||
@@ -48,3 +146,20 @@ export function displayName(p) {
|
|||||||
export function playerName(players, id) {
|
export function playerName(players, id) {
|
||||||
return displayName(players.find(p => p.id === 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)));
|
||||||
|
}
|
||||||
@@ -1242,13 +1242,3 @@ function loadTechniquePool() {
|
|||||||
export async function getTechniqueSuggestion(exclude = []) {
|
export async function getTechniqueSuggestion(exclude = []) {
|
||||||
return pickFrom(await loadTechniquePool(), exclude);
|
return pickFrom(await loadTechniquePool(), exclude);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draws `count` distinct technique suggestions.
|
|
||||||
export async function getTechniqueSuggestions(count, exclude = []) {
|
|
||||||
const pool = await loadTechniquePool();
|
|
||||||
const picked = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
picked.push(pickFrom(pool, [...exclude, ...picked]));
|
|
||||||
}
|
|
||||||
return picked;
|
|
||||||
}
|
|
||||||
|
|||||||
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
import { mount } from 'svelte'
|
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.css'
|
||||||
import App from './App.svelte'
|
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, {
|
const app = mount(App, {
|
||||||
target: document.getElementById('app'),
|
target: document.getElementById('app'),
|
||||||
|
|||||||
@@ -1,13 +1,61 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { setPageTitle } from '../lib/title';
|
||||||
|
import { getSessions } from '../lib/sessions';
|
||||||
|
|
||||||
export let params = {};
|
export let params = {};
|
||||||
let gameId = params.id;
|
let gameId = params.id;
|
||||||
let adminKey = '';
|
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 data = null;
|
||||||
let error = '';
|
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(() => {
|
onMount(() => {
|
||||||
// Try to load admin_key from local storage
|
// Try to load admin_key from local storage
|
||||||
@@ -27,16 +75,49 @@
|
|||||||
data = null;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto p-4 space-y-6">
|
<div class="admin-page">
|
||||||
<h1 class="text-3xl font-bold text-gold">Admin Panel</h1>
|
<h1>Admin Panel</h1>
|
||||||
|
|
||||||
{#if !data}
|
{#if !data}
|
||||||
<div class="card p-6">
|
<div class="card">
|
||||||
<h2 class="text-xl mb-4">Enter Admin Key</h2>
|
<h2 class="mb-4">Enter Admin Key</h2>
|
||||||
<form on:submit|preventDefault={loadAdminData} class="flex gap-4">
|
<form on:submit|preventDefault={loadAdminData} class="flex gap-4">
|
||||||
<input type="text" bind:value={adminKey} class="form-control flex-grow" placeholder="Admin Key" required/>
|
<input type="text" bind:value={adminKey} class="input-field flex-grow" placeholder="Admin Key" required/>
|
||||||
<button type="submit" class="btn btn-primary">Login</button>
|
<button type="submit" class="btn btn-primary">Login</button>
|
||||||
</form>
|
</form>
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -44,47 +125,109 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card p-6">
|
<div class="card">
|
||||||
<h2 class="text-xl font-bold text-silver mb-4">Game: {data.game.crew_name}</h2>
|
<div class="admin-summary">
|
||||||
<p><strong>Phase:</strong> {data.game.phase}</p>
|
<div class="admin-summary-details">
|
||||||
<p><strong>Admin Key:</strong> <span class="bg-dark-900 px-2 py-1 font-mono text-sm">{data.game.admin_key}</span></p>
|
<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>
|
||||||
|
|
||||||
<h3 class="text-xl font-bold text-silver mt-8 mb-4">Players</h3>
|
{#if error}
|
||||||
<table class="w-full text-left bg-dark-900 rounded border border-gray-700">
|
<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>
|
<thead>
|
||||||
<tr class="border-b border-gray-700">
|
<tr>
|
||||||
<th class="p-3">Pi-Rat</th>
|
<th>Pi-Rat</th>
|
||||||
<th class="p-3">Player</th>
|
<th>Player</th>
|
||||||
<th class="p-3">Role</th>
|
<th>Role</th>
|
||||||
<th class="p-3">Status</th>
|
<th>Status</th>
|
||||||
<th class="p-3">Rank</th>
|
<th>Rank</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Re-join Link</th>
|
||||||
|
<th>Kick</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.players as p}
|
{#each data.players as p}
|
||||||
<tr class="border-b border-gray-800">
|
<tr>
|
||||||
<td class="p-3">{p.name}</td>
|
<td>
|
||||||
<td class="p-3">{p.player_name || p.name}</td>
|
{#if p.name && p.name !== p.player_name}
|
||||||
<td class="p-3">
|
{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
|
{#if p.role === 'deep'} 🌊 Deep
|
||||||
{:else if p.role === 'pirat'} 🐀 Pi-Rat
|
{:else if p.role === 'pirat'} 🐀 Pi-Rat
|
||||||
{:else} None
|
{:else} None
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">
|
<td>
|
||||||
{#if p.is_ghost} 👻 Ghost
|
{#if p.is_ghost} 👻 Ghost
|
||||||
{:else if p.is_dead} 💀 Dead
|
{:else if p.is_dead} 💀 Dead
|
||||||
{:else} Alive
|
{:else} Alive
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">{p.rank}</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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<a href="/#/game/{gameId}/join" class="btn btn-secondary">Back to Game Join Page</a>
|
<a href={backLink} class="btn btn-secondary">{backLabel}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { push } from 'svelte-spa-router';
|
||||||
import { apiRequest } from '../lib/api';
|
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 LobbyPhase from '../components/LobbyPhase.svelte';
|
||||||
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
|
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
|
||||||
import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
|
import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
|
||||||
import ScenePhase from '../components/ScenePhase.svelte';
|
import ScenePhase from '../components/ScenePhase.svelte';
|
||||||
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
||||||
|
import RecruitPhase from '../components/RecruitPhase.svelte';
|
||||||
import GameOverPhase from '../components/GameOverPhase.svelte';
|
import GameOverPhase from '../components/GameOverPhase.svelte';
|
||||||
|
import CrewSidebar from '../components/CrewSidebar.svelte';
|
||||||
import EventLog from '../components/EventLog.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 = {};
|
export let params = {};
|
||||||
let gameId = params.id;
|
let gameId = params.id;
|
||||||
@@ -17,58 +28,244 @@
|
|||||||
let state = null;
|
let state = null;
|
||||||
let error = '';
|
let error = '';
|
||||||
let intervalId;
|
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() {
|
async function fetchState() {
|
||||||
|
if (staleSession) return;
|
||||||
try {
|
try {
|
||||||
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
|
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;
|
state = data;
|
||||||
error = '';
|
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) {
|
} catch (err) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
async function skipCharacterCreation() {
|
||||||
fetchState();
|
// Suggested values for everyone's free-text fields; the backend only
|
||||||
// Poll every 2 seconds
|
// applies them to fields players haven't filled in themselves.
|
||||||
intervalId = setInterval(fetchState, 2000);
|
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(() => {
|
onDestroy(() => {
|
||||||
|
destroyed = true;
|
||||||
if (intervalId) clearInterval(intervalId);
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
if (ws) ws.close();
|
||||||
|
extraMenuLinks.set([]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert alert-danger" style="margin: 20px;">
|
<div class="alert alert-danger" style="margin: 20px;">
|
||||||
Error connecting to game: {error}
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if state}
|
{#if state}
|
||||||
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
||||||
{#if state.game.phase === 'lobby'}
|
{#if state.game.phase === 'scene'}
|
||||||
<LobbyPhase {state} />
|
|
||||||
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques'}
|
|
||||||
<CharacterCreationPhase {state} />
|
|
||||||
{:else if state.game.phase === 'scene_setup'}
|
|
||||||
<SceneSetupPhase {state} />
|
|
||||||
{:else if state.game.phase === 'scene'}
|
|
||||||
<ScenePhase {state} />
|
<ScenePhase {state} />
|
||||||
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
|
||||||
<UpkeepPhase {state} />
|
|
||||||
{:else if state.game.phase === 'ended'}
|
|
||||||
<GameOverPhase {state} />
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EventLog {state} />
|
{#if state.game.phase !== 'scene'}
|
||||||
|
<button
|
||||||
{#if state.player.is_creator}
|
class="log-reopen-btn"
|
||||||
<a class="admin-corner-link" href="#/game/{gameId}/admin" title="Open the Admin Panel">🛠️ Admin</a>
|
title={phaseLogOpen ? 'Hide the event log' : 'Show the event log'}
|
||||||
|
on:click={() => (phaseLogOpen = !phaseLogOpen)}
|
||||||
|
>
|
||||||
|
{phaseLogOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<NameModal {state} />
|
||||||
|
<GatModal {state} />
|
||||||
|
<RankBonusModal {state} />
|
||||||
{:else if !error}
|
{:else if !error}
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<p>Loading game state...</p>
|
<p>Loading game state...</p>
|
||||||
@@ -76,24 +273,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.admin-corner-link {
|
|
||||||
position: fixed;
|
|
||||||
top: 12px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 1001;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(10, 15, 25, 0.9);
|
|
||||||
border: 1px solid var(--gold, #d4af37);
|
|
||||||
color: var(--gold, #d4af37);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
}
|
|
||||||
.admin-corner-link:hover {
|
|
||||||
background: rgba(212, 175, 55, 0.15);
|
|
||||||
}
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,10 +1,45 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { apiRequest } from '../lib/api';
|
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 crewName = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
let creating = false;
|
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() {
|
async function createGame() {
|
||||||
if (!crewName.trim()) return;
|
if (!crewName.trim()) return;
|
||||||
@@ -21,41 +56,190 @@
|
|||||||
creating = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="welcome-container">
|
<div class="welcome-container">
|
||||||
<div class="header">
|
<header class="welcome-hero">
|
||||||
<h1>RATS with GATS</h1>
|
<p class="hero-overline">A Remote Play Companion for</p>
|
||||||
<p class="subtitle">A Remote Play Companion App</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>
|
||||||
|
|
||||||
<div class="card creation-card">
|
<div class="glass-panel creation-card">
|
||||||
<h2>Start a New Game</h2>
|
<h2>Muster a New Crew</h2>
|
||||||
<form on:submit|preventDefault={createGame}>
|
<form on:submit|preventDefault={createGame}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="crewName">Crew Name</label>
|
<label for="crewName">Crew Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="crewName"
|
id="crewName"
|
||||||
bind:value={crewName}
|
bind:value={crewName}
|
||||||
required
|
required
|
||||||
placeholder="e.g. The Salty Dogs"
|
placeholder="e.g. The Salty Dogs"
|
||||||
class="form-control input-large"
|
class="input-field input-large"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert alert-danger">{error}</div>
|
<div class="alert alert-danger">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-large btn-block mt-4" disabled={creating}>
|
<button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
|
||||||
{creating ? 'Creating...' : 'Create Game'}
|
{creating ? 'Creating...' : 'Create Game'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="links mt-4">
|
<div class="welcome-links">
|
||||||
<a href="/rules" class="text-muted">Read the Rules</a>
|
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { setPageTitle } from '../lib/title';
|
||||||
|
import { saveSession } from '../lib/sessions';
|
||||||
export let params = {};
|
export let params = {};
|
||||||
|
|
||||||
|
const PLAYER_NAME_KEY = 'pirats-player-name';
|
||||||
|
|
||||||
let gameId = params.id;
|
let gameId = params.id;
|
||||||
let playerName = '';
|
let playerName = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
let joining = false;
|
let joining = false;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setPageTitle('Join the Crew');
|
||||||
|
playerName = localStorage.getItem(PLAYER_NAME_KEY) || '';
|
||||||
|
});
|
||||||
|
|
||||||
// We can extract ?creator=true from $querystring if needed,
|
// We can extract ?creator=true from $querystring if needed,
|
||||||
// though the backend determines creator based on if they are the first player.
|
// 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.
|
// For UI purposes, we could show "You are the creator" if we want.
|
||||||
@@ -17,6 +28,10 @@
|
|||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
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}`);
|
push(`/game/${gameId}/player/${data.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
@@ -26,11 +41,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="welcome-container">
|
<div class="welcome-container">
|
||||||
<div class="header">
|
<header class="welcome-hero">
|
||||||
<h1>Join Crew</h1>
|
<p class="hero-overline">Rats with Gats</p>
|
||||||
</div>
|
<h1 class="wordmark">Join the Crew</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="card creation-card">
|
<div class="glass-panel creation-card">
|
||||||
<h2>Enter your name</h2>
|
<h2>Enter your name</h2>
|
||||||
<p class="info-text" style="margin-bottom: 1rem;">
|
<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
|
This is <strong>your</strong> name as a player, not your Pi-Rat's. Your Pi-Rat will be known
|
||||||
@@ -44,7 +60,7 @@
|
|||||||
id="playerName"
|
id="playerName"
|
||||||
bind:value={playerName}
|
bind:value={playerName}
|
||||||
required
|
required
|
||||||
class="form-control input-large"
|
class="input-field input-large"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
@@ -54,9 +70,13 @@
|
|||||||
<div class="alert alert-danger">{error}</div>
|
<div class="alert alert-danger">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-large btn-block mt-4" disabled={joining}>
|
<button type="submit" class="btn btn-primary btn-large btn-full mt-4" disabled={joining}>
|
||||||
{joining ? 'Joining...' : 'Join Game'}
|
{joining ? 'Joining...' : 'Join Game'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="welcome-links">
|
||||||
|
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:8000',
|
target: 'http://127.0.0.1:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/rules': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +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",
|
||||||
|
"websockets",
|
||||||
"python-multipart",
|
"python-multipart",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ pirats = "pirats.main:main"
|
|||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
pirats = ["static/**/*"]
|
pirats = ["static/**/*", "rules.html", "migrations/**/*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src"]
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ from .crud_character import *
|
|||||||
from .crud_scene import *
|
from .crud_scene import *
|
||||||
from .crud_challenge import *
|
from .crud_challenge import *
|
||||||
from .crud_upkeep import *
|
from .crud_upkeep import *
|
||||||
|
from .crud_rollback import *
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from .models import Game, Player, GameEvent
|
from .models import Game, Player, GameEvent, new_join_code
|
||||||
from . import cards
|
from . import cards
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
FACE_VALUES = ("J", "Q", "K")
|
FACE_VALUES = ("J", "Q", "K")
|
||||||
|
|
||||||
# --- Card and Hand Utilities ---
|
# --- Card and Hand Utilities ---
|
||||||
@@ -110,10 +114,6 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player], capt
|
|||||||
return base_size + 1
|
return base_size + 1
|
||||||
return base_size
|
return base_size
|
||||||
|
|
||||||
def is_player_captain(player: Player, game: Game) -> bool:
|
|
||||||
"""The Captain is a persistent position tracked on the Game (set once the crew steals a ship)."""
|
|
||||||
return game.captain_player_id is not None and game.captain_player_id == player.id
|
|
||||||
|
|
||||||
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict:
|
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."""
|
"""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}
|
return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
|
||||||
@@ -138,6 +138,18 @@ def change_player_rank(db: Session, game: Game, player: Player, delta: int):
|
|||||||
db.commit()
|
db.commit()
|
||||||
apply_hand_increases(db, game, old_maxes)
|
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 = ""):
|
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."""
|
"""Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
|
||||||
if game.captain_player_id == player_id:
|
if game.captain_player_id == player_id:
|
||||||
@@ -222,55 +234,100 @@ def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -
|
|||||||
|
|
||||||
# --- CRUD Operations ---
|
# --- 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:
|
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
||||||
deck = cards.get_fresh_deck()
|
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(
|
game = Game(
|
||||||
|
join_code=join_code,
|
||||||
deck_cards=json.dumps(deck),
|
deck_cards=json.dumps(deck),
|
||||||
phase="lobby",
|
phase="lobby",
|
||||||
current_scene_number=1,
|
current_scene_number=1,
|
||||||
extra_obstacles=0,
|
extra_obstacles=0,
|
||||||
crew_name=crew_name
|
crew_name=crew_name,
|
||||||
|
dev_mode=dev_mode_default()
|
||||||
)
|
)
|
||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(game)
|
db.refresh(game)
|
||||||
|
logger.info("Game %s created (crew_name=%r, dev_mode=%s)", game.id, crew_name, game.dev_mode)
|
||||||
return game
|
return game
|
||||||
|
|
||||||
def get_game(db: Session, game_id: str) -> Optional[Game]:
|
def get_game(db: Session, game_id: str) -> Optional[Game]:
|
||||||
return db.get(Game, game_id)
|
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]:
|
def get_player(db: Session, player_id: str) -> Optional[Player]:
|
||||||
return db.get(Player, player_id)
|
return db.get(Player, player_id)
|
||||||
|
|
||||||
def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player:
|
def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player:
|
||||||
game = db.get(Game, game_id)
|
game = db.get(Game, game_id)
|
||||||
role = None
|
|
||||||
if game and game.phase == "scene":
|
# Players who join after initial character creation create their Pi-Rat in
|
||||||
role = "pirat"
|
# the between-scenes recruit_creation phase; until then they spectate.
|
||||||
|
needs_reroll = bool(game and game.phase not in ("lobby", "character_creation"))
|
||||||
|
|
||||||
player = Player(
|
player = Player(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
|
name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
|
||||||
player_name=name,
|
player_name=name,
|
||||||
is_creator=is_creator,
|
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)
|
rank=2, # default starting rank (will be set properly when starting character creation)
|
||||||
hand_cards="[]",
|
hand_cards="[]",
|
||||||
is_ready=False,
|
is_ready=False,
|
||||||
role=role
|
role=None,
|
||||||
|
needs_reroll=needs_reroll
|
||||||
)
|
)
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(player)
|
db.refresh(player)
|
||||||
|
|
||||||
# Players joining mid-scene would otherwise never receive cards (starting
|
if game:
|
||||||
# hands are only dealt when scene 1 starts), so deal them in immediately.
|
|
||||||
if game and game.phase == "scene":
|
|
||||||
db.refresh(game)
|
db.refresh(game)
|
||||||
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
|
if game.phase == "character_creation":
|
||||||
draw_cards_for_player(db, game, player, max_size)
|
# 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)
|
||||||
|
|
||||||
add_game_event(db, game_id, f"{name} joined the crew!", kind="join")
|
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
|
return player
|
||||||
|
|
||||||
def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
|
def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ def create_challenge(
|
|||||||
deep_player_id: str,
|
deep_player_id: str,
|
||||||
target_player_id: str,
|
target_player_id: str,
|
||||||
obstacle_ids: List[str],
|
obstacle_ids: List[str],
|
||||||
stakes: str = ""
|
stakes_success: str = "",
|
||||||
|
stakes_failure: str = ""
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat."""
|
"""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)
|
game = get_game(db, game_id)
|
||||||
@@ -50,6 +51,8 @@ def create_challenge(
|
|||||||
return False, "The Deep cannot challenge itself. Pick a Pi-Rat!"
|
return False, "The Deep cannot challenge itself. Pick a Pi-Rat!"
|
||||||
if target.is_dead:
|
if target.is_dead:
|
||||||
return False, f"{target.name} is dead. The Deep has no quarrel with the deceased."
|
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:
|
if not obstacle_ids:
|
||||||
return False, "Apply at least one Obstacle to the Challenge."
|
return False, "Apply at least one Obstacle to the Challenge."
|
||||||
|
|
||||||
@@ -72,15 +75,20 @@ def create_challenge(
|
|||||||
acting_player_id=target.id,
|
acting_player_id=target.id,
|
||||||
challenger_player_id=deep_player.id,
|
challenger_player_id=deep_player.id,
|
||||||
obstacle_ids=json.dumps(obstacle_ids),
|
obstacle_ids=json.dumps(obstacle_ids),
|
||||||
stakes=stakes.strip(),
|
stakes_success=stakes_success.strip(),
|
||||||
|
stakes_failure=stakes_failure.strip(),
|
||||||
)
|
)
|
||||||
db.add(challenge)
|
db.add(challenge)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids)
|
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}."
|
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
|
||||||
if stakes.strip():
|
success_text = stakes_success.strip()
|
||||||
msg += f" Stakes: {stakes.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")
|
add_game_event(db, game.id, msg, kind="challenge")
|
||||||
return True, "Challenge called!"
|
return True, "Challenge called!"
|
||||||
|
|
||||||
@@ -205,6 +213,9 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
|
|||||||
if challenge.tax_type == "gat":
|
if challenge.tax_type == "gat":
|
||||||
acting.completed_personal_1 = False
|
acting.completed_personal_1 = False
|
||||||
refuser.completed_personal_1 = True
|
refuser.completed_personal_1 = True
|
||||||
|
# The Gat (and its description) returns to its owner.
|
||||||
|
refuser.gat_description = acting.gat_description
|
||||||
|
acting.gat_description = ""
|
||||||
else:
|
else:
|
||||||
# Return the stolen name string; the failed thief reverts to
|
# Return the stolen name string; the failed thief reverts to
|
||||||
# their smell-based recruit identity.
|
# their smell-based recruit identity.
|
||||||
@@ -262,7 +273,7 @@ def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id
|
|||||||
return False, "A Tax has already been called on this Challenge."
|
return False, "A Tax has already been called on this Challenge."
|
||||||
if requester.tax_banned:
|
if requester.tax_banned:
|
||||||
return False, "You failed a refused Tax this scene — no more Gat/Name Taxes for you!"
|
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:
|
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."
|
return False, "Pick another living Pi-Rat in the scene."
|
||||||
|
|
||||||
if not requester.completed_personal_1:
|
if not requester.completed_personal_1:
|
||||||
@@ -336,6 +347,9 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
|
|||||||
if challenge.tax_type == "gat":
|
if challenge.tax_type == "gat":
|
||||||
requester.completed_personal_1 = True
|
requester.completed_personal_1 = True
|
||||||
responder.completed_personal_1 = False
|
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!"
|
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:
|
else:
|
||||||
# The Name itself is stolen: the requester literally takes the responder's
|
# The Name itself is stolen: the requester literally takes the responder's
|
||||||
@@ -380,6 +394,8 @@ def create_pvp_challenge(
|
|||||||
return False, "You cannot challenge yourself. (Well, you can, but not with cards.)"
|
return False, "You cannot challenge yourself. (Well, you can, but not with cards.)"
|
||||||
if defender.is_dead:
|
if defender.is_dead:
|
||||||
return False, f"{defender.name} is dead. Let them rest."
|
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:
|
for challenge in game.challenges:
|
||||||
if challenge.status == "open" and defender.id in (challenge.target_player_id, challenge.acting_player_id):
|
if challenge.status == "open" and defender.id in (challenge.target_player_id, challenge.acting_player_id):
|
||||||
|
|||||||
@@ -113,40 +113,87 @@ def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3
|
|||||||
# Check if all players have submitted techniques. If so, trigger the swap!
|
# Check if all players have submitted techniques. If so, trigger the swap!
|
||||||
if not game:
|
if not game:
|
||||||
return None
|
return None
|
||||||
|
maybe_trigger_technique_swap(db, game)
|
||||||
|
return None
|
||||||
|
|
||||||
all_submitted = True
|
def unsubmit_techniques(db: Session, player_id: str):
|
||||||
for p in game.players:
|
"""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)
|
techs = json.loads(p.created_techniques)
|
||||||
if len(techs) < 3 or not all(techs):
|
if len(techs) < 3 or not all(techs):
|
||||||
all_submitted = False
|
return
|
||||||
break
|
trigger_technique_swap(db, game)
|
||||||
|
|
||||||
if all_submitted:
|
|
||||||
trigger_technique_swap(db, game)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def trigger_technique_swap(db: Session, game: Game):
|
def trigger_technique_swap(db: Session, game: Game):
|
||||||
"""
|
"""
|
||||||
Shuffles and distributes 3 techniques created by OTHER players to each player.
|
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 = game.players
|
players = swap_participants(game)
|
||||||
if not players:
|
if not players:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build a pool of all techniques with their creators
|
# Reject the pool if any two techniques collide (case-insensitively)
|
||||||
pool = []
|
|
||||||
seen_texts = set()
|
seen_texts = set()
|
||||||
has_duplicates = False
|
has_duplicates = False
|
||||||
|
|
||||||
for p in players:
|
for p in players:
|
||||||
techs = json.loads(p.created_techniques)
|
for t in json.loads(p.created_techniques):
|
||||||
for t in techs:
|
|
||||||
t_clean = t.strip().lower()
|
t_clean = t.strip().lower()
|
||||||
if t_clean in seen_texts:
|
if t_clean in seen_texts:
|
||||||
has_duplicates = True
|
has_duplicates = True
|
||||||
seen_texts.add(t_clean)
|
seen_texts.add(t_clean)
|
||||||
pool.append({"text": t, "creator_id": p.id})
|
|
||||||
|
|
||||||
if has_duplicates:
|
if has_duplicates:
|
||||||
# Force players to resubmit
|
# Force players to resubmit
|
||||||
for p in players:
|
for p in players:
|
||||||
@@ -155,207 +202,625 @@ def trigger_technique_swap(db: Session, game: Game):
|
|||||||
db.commit()
|
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")
|
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
|
return
|
||||||
|
|
||||||
# Swap algorithm with simple retry backtracking
|
for p in players:
|
||||||
# We want to assign 3 techniques from the pool to each player, ensuring creator_id != player.id.
|
p.held_techniques = json.dumps([{"text": t, "creator_id": p.id} for t in json.loads(p.created_techniques)])
|
||||||
# For a small number of players, random shuffling with retries is highly efficient.
|
p.swapped_techniques = "[]"
|
||||||
success = False
|
p.swap_offer_to_id = None
|
||||||
for attempt in range(200):
|
p.swap_offer_technique = ""
|
||||||
random.shuffle(pool)
|
p.is_ready = False
|
||||||
assignments = {p.id: [] for p in players}
|
db.add(p)
|
||||||
temp_pool = pool.copy()
|
game.phase = "swap_techniques"
|
||||||
|
db.add(game)
|
||||||
# Greedy assignment
|
db.commit()
|
||||||
failed = False
|
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 p in players:
|
||||||
valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id]
|
for t in json.loads(p.created_techniques):
|
||||||
if len(valid_for_p) < 3:
|
pool.append({"text": t, "creator_id": p.id})
|
||||||
# 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).
|
# Random shuffling with retries: for a small table this finds a valid
|
||||||
if len(players) < 3:
|
# creator != assignee distribution almost immediately.
|
||||||
valid_for_p = temp_pool.copy()
|
assignments = None
|
||||||
else:
|
for attempt in range(200):
|
||||||
failed = True
|
random.shuffle(pool)
|
||||||
break
|
candidate = {p.id: [] for p in players}
|
||||||
|
temp_pool = pool.copy()
|
||||||
# Take 3
|
failed = False
|
||||||
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:
|
for p in players:
|
||||||
p.swapped_techniques = json.dumps(assignments[p.id])
|
valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id]
|
||||||
p.is_ready = False # Reset ready flag for J/Q/K assignment step
|
if len(valid_for_p) < 3:
|
||||||
db.add(p)
|
# With 1-2 players a non-owned distribution can be impossible;
|
||||||
game.phase = "swap_techniques"
|
# fall back to allowing own techniques.
|
||||||
db.add(game)
|
if len(players) < 3:
|
||||||
db.commit()
|
valid_for_p = temp_pool.copy()
|
||||||
add_game_event(db, game.id, "Phase changed: Swap Techniques", kind="phase")
|
else:
|
||||||
success = True
|
failed = True
|
||||||
break
|
break
|
||||||
|
chosen = valid_for_p[:3]
|
||||||
if not success:
|
for c in chosen:
|
||||||
# Fallback in case of failure (e.g., edge cases, single player testing): just distribute whatever
|
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:
|
for p in players:
|
||||||
# Just give them the first 3 from the pool
|
p.held_techniques = json.dumps(assignments[p.id])
|
||||||
p.swapped_techniques = json.dumps([t["text"] for t in pool[:3]])
|
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
|
p.is_ready = False
|
||||||
db.add(p)
|
db.add(p)
|
||||||
game.phase = "swap_techniques"
|
game.phase = "assign_techniques"
|
||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
add_game_event(db, game.id, "Phase changed: Swap Techniques", kind="phase")
|
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):
|
def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
|
||||||
player = get_player(db, player_id)
|
player = get_player(db, player_id)
|
||||||
if not player:
|
if not player:
|
||||||
return
|
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_jack = jack
|
||||||
player.tech_queen = queen
|
player.tech_queen = queen
|
||||||
player.tech_king = king
|
player.tech_king = king
|
||||||
player.is_ready = True
|
player.is_ready = True
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
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()
|
|
||||||
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
|
||||||
|
|
||||||
def roll_new_character(
|
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
|
||||||
db: Session,
|
maybe_finish_assign_techniques(db, game)
|
||||||
game_id: str,
|
return True, "Techniques assigned."
|
||||||
player_id: str,
|
|
||||||
avatar_look: str,
|
def skip_character_creation(db: Session, game: Game, fills: dict):
|
||||||
avatar_smell: str,
|
"""
|
||||||
first_words: str,
|
Dev Mode shortcut: auto-completes every unfinished part of character
|
||||||
good_at_math: str
|
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)
|
player = get_player(db, player_id)
|
||||||
game = get_game(db, game_id)
|
if not player:
|
||||||
if not player or not game:
|
return False, "Player not found."
|
||||||
return
|
if player.needs_reroll:
|
||||||
|
return True, "Already queued for a new recruit."
|
||||||
# 1. Return current hand cards to the deck
|
if not player.is_dead or player.is_ghost:
|
||||||
from .crud_base import get_player_hand, set_player_hand, get_game_deck, set_game_deck, draw_cards_for_player, calculate_max_hand_size
|
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)
|
hand = get_player_hand(player)
|
||||||
deck = get_game_deck(game)
|
if hand:
|
||||||
deck.extend(hand)
|
deck = get_game_deck(game)
|
||||||
random.shuffle(deck)
|
deck.extend(hand)
|
||||||
set_game_deck(game, deck)
|
random.shuffle(deck)
|
||||||
set_player_hand(player, [])
|
set_game_deck(game, deck)
|
||||||
|
set_player_hand(player, [])
|
||||||
# 2. Reset properties. New recruits always start at Rank 1.
|
|
||||||
player.rank = 1
|
|
||||||
player.is_ready = False
|
|
||||||
player.completed_personal_1 = False
|
|
||||||
player.completed_personal_2 = False
|
|
||||||
player.completed_personal_3 = False
|
|
||||||
player.needs_name = False
|
|
||||||
player.needs_rank_3_bonus = False
|
|
||||||
player.is_dead = False
|
|
||||||
player.is_ghost = False
|
|
||||||
player.tax_banned = False
|
|
||||||
|
|
||||||
# A dead Captain's position does not pass to their replacement recruit
|
# A dead Captain's position does not pass to their replacement recruit
|
||||||
if game.captain_player_id == player.id:
|
if game.captain_player_id == player.id:
|
||||||
game.captain_player_id = None
|
game.captain_player_id = None
|
||||||
|
|
||||||
# Save player-provided details
|
# New recruits always start at Rank 1 with a blank sheet
|
||||||
player.avatar_look = avatar_look.strip()
|
player.rank = 1
|
||||||
player.avatar_smell = avatar_smell.strip()
|
player.role = None
|
||||||
player.first_words = first_words.strip()
|
player.is_ready = False
|
||||||
player.good_at_math = good_at_math.strip()
|
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)
|
player.name = recruit_name(player)
|
||||||
|
|
||||||
# 3. Randomly assign 3 new secret techniques, avoiding any technique already
|
# Crewmates fill in the social questions and write the recruit's techniques.
|
||||||
# in play on another player's sheet (collisions make for boring recruits).
|
# Prefer helpers who aren't busy creating their own recruit.
|
||||||
from .cards import TECHNIQUE_SUGGESTIONS
|
others = [p for p in game.players if p.id != player.id]
|
||||||
in_use = set()
|
helpers = [p for p in others if not p.needs_reroll] or others
|
||||||
for p in game.players:
|
if helpers:
|
||||||
if p.id == player.id:
|
random.shuffle(helpers)
|
||||||
continue
|
player.other_like_from_player_id = helpers[0].id
|
||||||
for t in [p.tech_jack, p.tech_queen, p.tech_king] + json.loads(p.created_techniques) + json.loads(p.swapped_techniques):
|
|
||||||
if t:
|
|
||||||
in_use.add(t.strip().lower())
|
|
||||||
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use]
|
|
||||||
if len(available) < 3:
|
|
||||||
available = TECHNIQUE_SUGGESTIONS
|
|
||||||
chosen_techs = random.sample(available, 3)
|
|
||||||
player.created_techniques = json.dumps(chosen_techs)
|
|
||||||
player.swapped_techniques = json.dumps(chosen_techs)
|
|
||||||
player.tech_jack = chosen_techs[0]
|
|
||||||
player.tech_queen = chosen_techs[1]
|
|
||||||
player.tech_king = chosen_techs[2]
|
|
||||||
|
|
||||||
# 4. Auto-delegate Like/Hate questions to other players
|
|
||||||
other_players = [p for p in game.players if p.id != player.id]
|
|
||||||
if other_players:
|
|
||||||
like_target = random.choice(other_players)
|
|
||||||
player.other_like_from_player_id = like_target.id
|
|
||||||
player.other_like = ""
|
player.other_like = ""
|
||||||
|
player.other_hate_from_player_id = helpers[1 % len(helpers)].id
|
||||||
if len(other_players) >= 2:
|
|
||||||
remaining = [op for op in other_players if op.id != like_target.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 = ""
|
player.other_hate = ""
|
||||||
|
slots = [{"from_id": helpers[i % len(helpers)].id, "text": ""} for i in range(3)]
|
||||||
|
player.incoming_techniques = json.dumps(slots)
|
||||||
else:
|
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_from_player_id = None
|
||||||
player.other_like = ""
|
player.other_like = ""
|
||||||
player.other_hate_from_player_id = None
|
player.other_hate_from_player_id = None
|
||||||
player.other_hate = ""
|
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(player)
|
||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 5. Draw cards up to new hand size based on new Rank
|
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)
|
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
|
||||||
draw_cards_for_player(db, game, player, max_size)
|
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")
|
||||||
add_game_event(db, game.id, f"{player.name} has joined the crew as a new 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."
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
import random
|
import logging
|
||||||
from typing import Tuple, Dict, Any
|
from typing import Tuple, Dict, Any
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .models import Game, Player, Obstacle
|
from .models import Game, Player, Obstacle
|
||||||
@@ -7,12 +7,21 @@ from . import cards
|
|||||||
from .crud_base import (
|
from .crud_base import (
|
||||||
get_player, get_game, get_player_hand, set_player_hand,
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
||||||
change_player_rank, set_captain, evaluate_card_play
|
change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS,
|
||||||
|
is_eligible_nominee
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# --- Scene Setup Operations ---
|
# --- Scene Setup Operations ---
|
||||||
|
|
||||||
|
VALID_ROLES = ("pirat", "deep")
|
||||||
|
|
||||||
def update_scene_role(db: Session, player_id: str, role: str):
|
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)
|
player = get_player(db, player_id)
|
||||||
if not player:
|
if not player:
|
||||||
return
|
return
|
||||||
@@ -33,13 +42,20 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
if not game:
|
if not game:
|
||||||
return False, "Game not found."
|
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
|
players = game.players
|
||||||
|
|
||||||
# Default any unselected roles to 'pirat'
|
# 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:
|
for p in players:
|
||||||
if p.role is None:
|
if p.role is None and not p.needs_reroll:
|
||||||
p.role = "pirat"
|
p.role = "pirat"
|
||||||
db.add(p)
|
db.add(p)
|
||||||
|
elif p.needs_reroll:
|
||||||
|
p.role = None
|
||||||
|
db.add(p)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
if game.current_scene_number == 1:
|
if game.current_scene_number == 1:
|
||||||
@@ -63,7 +79,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
# 2. Check single eligible Deep player
|
# 2. Check single eligible Deep player
|
||||||
# If only one player is eligible to play the Deep, they must play the Deep.
|
# If only one player is eligible to play the Deep, they must play the Deep.
|
||||||
if game.current_scene_number > 1:
|
if game.current_scene_number > 1:
|
||||||
eligible_deeps = [p for p in players if p.previous_role != "deep"]
|
eligible_deeps = [p for p in players if p.previous_role != "deep" and not p.needs_reroll]
|
||||||
if len(eligible_deeps) == 1:
|
if len(eligible_deeps) == 1:
|
||||||
must_be_deep_player = eligible_deeps[0]
|
must_be_deep_player = eligible_deeps[0]
|
||||||
if must_be_deep_player.role != "deep":
|
if must_be_deep_player.role != "deep":
|
||||||
@@ -134,6 +150,8 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
# players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw.
|
# players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw.
|
||||||
if game.current_scene_number == 1:
|
if game.current_scene_number == 1:
|
||||||
for p in players:
|
for p in players:
|
||||||
|
if p.needs_reroll:
|
||||||
|
continue
|
||||||
max_size = calculate_max_hand_size(p, players, game.captain_player_id)
|
max_size = calculate_max_hand_size(p, players, game.captain_player_id)
|
||||||
current_hand = get_player_hand(p)
|
current_hand = get_player_hand(p)
|
||||||
while len(current_hand) < max_size and deck:
|
while len(current_hand) < max_size and deck:
|
||||||
@@ -160,6 +178,10 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
|
|||||||
players = game.players
|
players = game.players
|
||||||
allowed = ["pirat", "deep"]
|
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:
|
if game.current_scene_number == 1:
|
||||||
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either.
|
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either.
|
||||||
if len(players) >= 2:
|
if len(players) >= 2:
|
||||||
@@ -174,7 +196,7 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
|
|||||||
allowed.remove("deep")
|
allowed.remove("deep")
|
||||||
|
|
||||||
# Rule 2: If only 1 eligible Deep, they MUST play Deep
|
# Rule 2: If only 1 eligible Deep, they MUST play Deep
|
||||||
eligible_deeps = [p for p in players if p.previous_role != "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 len(eligible_deeps) == 1 and eligible_deeps[0].id == player_id:
|
||||||
if "pirat" in allowed:
|
if "pirat" in allowed:
|
||||||
allowed.remove("pirat")
|
allowed.remove("pirat")
|
||||||
@@ -292,33 +314,6 @@ def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) ->
|
|||||||
|
|
||||||
def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool):
|
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."""
|
"""Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly."""
|
||||||
if obj_type.startswith("crew_"):
|
|
||||||
game = get_game(db, game_id)
|
|
||||||
if not game: return
|
|
||||||
if obj_type == "crew_1":
|
|
||||||
game.completed_crew_1 = status
|
|
||||||
db.add(game)
|
|
||||||
db.commit()
|
|
||||||
if status and not game.captain_player_id:
|
|
||||||
# "Once the crew controls a ship, the highest-Ranked Pi-Rat becomes the de facto Captain."
|
|
||||||
pi_rats = [p for p in game.players if p.role != "deep" and not p.is_dead and not p.is_ghost]
|
|
||||||
if pi_rats:
|
|
||||||
max_rank = max(p.rank for p in pi_rats)
|
|
||||||
candidates = [p for p in pi_rats if p.rank == max_rank]
|
|
||||||
set_captain(db, game, random.choice(candidates).id, "Highest-ranked Pi-Rat aboard the stolen ship")
|
|
||||||
elif not status and game.captain_player_id:
|
|
||||||
# The ship is lost: the Captain's position goes with it.
|
|
||||||
set_captain(db, game, None, "The ship was lost")
|
|
||||||
elif obj_type == "crew_2":
|
|
||||||
game.completed_crew_2 = status
|
|
||||||
db.add(game)
|
|
||||||
db.commit()
|
|
||||||
elif obj_type == "crew_3":
|
|
||||||
game.completed_crew_3 = status
|
|
||||||
db.add(game)
|
|
||||||
db.commit()
|
|
||||||
return
|
|
||||||
|
|
||||||
player = get_player(db, target_id)
|
player = get_player(db, target_id)
|
||||||
if not player:
|
if not player:
|
||||||
return
|
return
|
||||||
@@ -326,12 +321,34 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
|||||||
if not game:
|
if not game:
|
||||||
return
|
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
|
rank_diff = 0
|
||||||
if obj_type == "personal_1":
|
if obj_type == "personal_1":
|
||||||
if status and not player.completed_personal_1:
|
if status and not player.completed_personal_1:
|
||||||
rank_diff = 1
|
rank_diff = 1
|
||||||
|
player.needs_gat_description = True
|
||||||
elif not status and player.completed_personal_1:
|
elif not status and player.completed_personal_1:
|
||||||
rank_diff = -1
|
rank_diff = -1
|
||||||
|
player.needs_gat_description = False
|
||||||
|
player.gat_description = ""
|
||||||
player.completed_personal_1 = status
|
player.completed_personal_1 = status
|
||||||
|
|
||||||
elif obj_type == "personal_2":
|
elif obj_type == "personal_2":
|
||||||
@@ -366,53 +383,24 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
|||||||
status_text = "completed" if status else "unmarked"
|
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")
|
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
|
||||||
|
|
||||||
def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]:
|
def grant_story_bonus_rank(db: Session, game: Game, granter: Player, target):
|
||||||
obstacle = db.get(Obstacle, obstacle_id)
|
"""A Pi-Rat who finished their story (3rd Personal Objective) grants another
|
||||||
if not obstacle:
|
eligible Pi-Rat +1 Rank. `target=None` forfeits the bonus (e.g. no eligible
|
||||||
return False, "Obstacle not found."
|
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."""
|
||||||
played_list = json.loads(obstacle.played_cards)
|
if not granter.needs_rank_3_bonus:
|
||||||
if not played_list:
|
return False, "There is no story Rank bonus to grant."
|
||||||
return False, "No cards to roll back."
|
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."
|
||||||
last_play = played_list.pop()
|
if target is not None:
|
||||||
card_code = last_play["card"]
|
change_player_rank(db, game, target, 1)
|
||||||
player_id = last_play["player_id"]
|
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")
|
||||||
|
|
||||||
# Update obstacle's value
|
|
||||||
if played_list:
|
|
||||||
prev_card_code = played_list[-1]["card"]
|
|
||||||
obstacle.current_value = cards.parse_card(prev_card_code)["numeric_value"]
|
|
||||||
else:
|
else:
|
||||||
obstacle.current_value = cards.get_obstacle_info(obstacle.original_card)["initial_value"]
|
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
|
||||||
obstacle.played_cards = json.dumps(played_list)
|
db.add(granter)
|
||||||
db.add(obstacle)
|
|
||||||
|
|
||||||
# Remove the matching play from any open challenge that applied this obstacle
|
|
||||||
player = get_player(db, player_id)
|
|
||||||
if player:
|
|
||||||
game = get_game(db, player.game_id)
|
|
||||||
if game:
|
|
||||||
for challenge in game.challenges:
|
|
||||||
if challenge.status != "open":
|
|
||||||
continue
|
|
||||||
plays = json.loads(challenge.plays)
|
|
||||||
for i in range(len(plays) - 1, -1, -1):
|
|
||||||
p = plays[i]
|
|
||||||
if p.get("obstacle_id") == obstacle_id and p.get("card") == card_code and p.get("player_id") == player_id:
|
|
||||||
plays.pop(i)
|
|
||||||
challenge.plays = json.dumps(plays)
|
|
||||||
db.add(challenge)
|
|
||||||
break
|
|
||||||
# Return card to player's hand
|
|
||||||
hand = get_player_hand(player)
|
|
||||||
hand.append(card_code)
|
|
||||||
set_player_hand(player, hand)
|
|
||||||
db.add(player)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return True, "Rolled back the last played card."
|
return True, "Rank granted."
|
||||||
|
|
||||||
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
||||||
obstacle = db.get(Obstacle, obstacle_id)
|
obstacle = db.get(Obstacle, obstacle_id)
|
||||||
@@ -430,3 +418,4 @@ def finish_game(db: Session, game_id: str):
|
|||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
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")
|
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)
|
||||||
|
|||||||
@@ -1,28 +1,36 @@
|
|||||||
|
import logging
|
||||||
import random
|
import random
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from .models import Game, Vote
|
from .models import Game, Player, Vote
|
||||||
from .crud_base import (
|
from .crud_base import (
|
||||||
get_player, get_game, get_player_hand, set_player_hand,
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
|
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
|
||||||
calculate_max_hand_size, change_player_rank
|
calculate_max_hand_size, change_player_rank, set_captain, is_eligible_nominee
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# --- Between Scenes Operations ---
|
# --- 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]:
|
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
|
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.
|
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)
|
voter = get_player(db, voter_id)
|
||||||
nominee = get_player(db, nominated_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:
|
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
|
||||||
return False, "Player not found."
|
return False, "Player not found."
|
||||||
if voter_id == nominated_id:
|
if not is_eligible_nominee(voter, nominee):
|
||||||
return False, "You cannot nominate yourself. Nice try."
|
return False, "You can only nominate a living crewmate who isn't this scene's Deep — and not yourself."
|
||||||
if nominee.role == "deep":
|
|
||||||
return False, "Deep Players from the previous scene cannot be nominated."
|
|
||||||
|
|
||||||
# Remove any existing vote by this voter for this game
|
# Remove any existing vote by this voter for this game
|
||||||
existing = db.exec(
|
existing = db.exec(
|
||||||
@@ -41,21 +49,43 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
|
|||||||
db.commit()
|
db.commit()
|
||||||
return True, "Vote submitted."
|
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):
|
def end_scene_and_transition(db: Session, game_id: str):
|
||||||
"""Moves game phase to between_scenes."""
|
"""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)
|
game = get_game(db, game_id)
|
||||||
if not game:
|
if not game:
|
||||||
return
|
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"
|
game.phase = "between_scenes"
|
||||||
|
|
||||||
# Clear ready flags for players
|
# Clear ready flags for players
|
||||||
for p in game.players:
|
for p in game.players:
|
||||||
p.is_ready = False
|
p.is_ready = False
|
||||||
db.add(p)
|
db.add(p)
|
||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
|
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):
|
def process_between_scenes_votes(db: Session, game: Game):
|
||||||
"""
|
"""
|
||||||
@@ -63,24 +93,30 @@ def process_between_scenes_votes(db: Session, game: Game):
|
|||||||
Then sets players who were Deep to discard and redraw.
|
Then sets players who were Deep to discard and redraw.
|
||||||
"""
|
"""
|
||||||
votes = game.votes
|
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:
|
if not votes:
|
||||||
|
db.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Count votes
|
# Count votes
|
||||||
counts = {}
|
counts = {}
|
||||||
for v in votes:
|
for v in votes:
|
||||||
counts[v.nominated_player_id] = counts.get(v.nominated_player_id, 0) + 1
|
counts[v.nominated_player_id] = counts.get(v.nominated_player_id, 0) + 1
|
||||||
|
|
||||||
if counts:
|
if counts:
|
||||||
max_votes = max(counts.values())
|
max_votes = max(counts.values())
|
||||||
winners = [pid for pid, val in counts.items() if val == max_votes]
|
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 there is a clear winner (or random in case of tie)
|
||||||
if winners:
|
if winners:
|
||||||
winner_id = random.choice(winners)
|
winner_id = random.choice(winners)
|
||||||
winner = get_player(db, winner_id)
|
winner = get_player(db, winner_id)
|
||||||
if winner:
|
if winner:
|
||||||
change_player_rank(db, game, winner, 1)
|
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")
|
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
|
# Clear all votes
|
||||||
@@ -88,38 +124,59 @@ def process_between_scenes_votes(db: Session, game: Game):
|
|||||||
db.delete(v)
|
db.delete(v)
|
||||||
db.commit()
|
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):
|
def transition_to_deep_upkeep(db: Session, game_id: str):
|
||||||
game = get_game(db, game_id)
|
game = get_game(db, game_id)
|
||||||
if not game:
|
if not game:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Process votes & rank up winner
|
# Process votes & rank up winner
|
||||||
process_between_scenes_votes(db, game)
|
process_between_scenes_votes(db, game)
|
||||||
|
|
||||||
# Reset ready flags
|
# Reset ready flags
|
||||||
for p in game.players:
|
for p in game.players:
|
||||||
p.is_ready = False
|
p.is_ready = False
|
||||||
db.add(p)
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
# Check if we have resting Deep players (previous_role was 'deep')
|
# Check if we have resting Deep players (previous_role was 'deep')
|
||||||
has_resting_deep = any(p.previous_role == "deep" for p in game.players)
|
has_resting_deep = any(p.previous_role == "deep" for p in game.players)
|
||||||
|
|
||||||
if has_resting_deep:
|
if has_resting_deep:
|
||||||
game.phase = "deep_upkeep"
|
game.phase = "deep_upkeep"
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
|
add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
|
||||||
else:
|
else:
|
||||||
# Fallback in case there were no Deep players
|
# No resting Deep players: skip straight past the deep_upkeep phase
|
||||||
# Just advance directly to scene_setup
|
advance_after_upkeep(db, game)
|
||||||
game.current_scene_number += 1
|
|
||||||
game.phase = "scene_setup"
|
|
||||||
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
|
||||||
for p in game.players:
|
|
||||||
p.role = None
|
|
||||||
p.is_ready = False
|
|
||||||
db.add(p)
|
|
||||||
|
|
||||||
db.add(game)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
|
def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
|
||||||
player = get_player(db, player_id)
|
player = get_player(db, player_id)
|
||||||
@@ -155,18 +212,95 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
|
|||||||
player.is_ready = True
|
player.is_ready = True
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Check if all resting deep players are ready
|
# 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"]
|
resting_deep = [p for p in game.players if p.previous_role == "deep"]
|
||||||
if all(p.is_ready for p in resting_deep):
|
if all(p.is_ready for p in resting_deep):
|
||||||
# All resting deep players have refreshed their hands!
|
advance_after_upkeep(db, game)
|
||||||
# Transition to scene_setup!
|
|
||||||
game.current_scene_number += 1
|
# --- Kicking a player ---
|
||||||
game.phase = "scene_setup"
|
|
||||||
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
def recheck_phase_completion(db: Session, game: Game):
|
||||||
for p in game.players:
|
"""Re-evaluate the current phase's "everyone is done" gate and advance if it
|
||||||
p.is_ready = False
|
is now satisfied. Called after the roster changes (a kick) so a vanished
|
||||||
p.role = None
|
player can't leave the table stuck on a step they'll never complete."""
|
||||||
db.add(p)
|
from . import crud_character as cc
|
||||||
db.add(game)
|
phase = game.phase
|
||||||
db.commit()
|
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,17 +15,29 @@ 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
|
||||||
# Ensure crew_name column exists (migration for existing DBs)
|
# create_all + ad-hoc ALTER TABLE era.
|
||||||
from sqlalchemy import text
|
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:
|
with Session(engine) as session:
|
||||||
try:
|
|
||||||
session.execute(text("ALTER TABLE game ADD COLUMN crew_name VARCHAR"))
|
|
||||||
except Exception:
|
|
||||||
pass # Column already exists or table doesn't exist yet
|
|
||||||
|
|
||||||
columns = [
|
columns = [
|
||||||
|
("game", "crew_name", "VARCHAR"),
|
||||||
("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"),
|
("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"),
|
||||||
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
|
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
|
||||||
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
|
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
|
||||||
@@ -34,21 +50,34 @@ def create_db_and_tables():
|
|||||||
("player", "player_name", "VARCHAR DEFAULT ''"),
|
("player", "player_name", "VARCHAR DEFAULT ''"),
|
||||||
("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
|
("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for table, col, def_type in columns:
|
for table, col, def_type in columns:
|
||||||
try:
|
try:
|
||||||
session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}"))
|
session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}"))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass # Column already exists
|
||||||
|
|
||||||
# Backfill: pre-existing players used a single name for both player and Pi-Rat
|
# Backfill: pre-existing players used a single name for both player and Pi-Rat
|
||||||
try:
|
session.execute(
|
||||||
session.execute(text("UPDATE player SET player_name = name WHERE player_name = '' OR player_name IS NULL"))
|
text("UPDATE player SET player_name = name WHERE player_name = '' OR player_name IS NULL")
|
||||||
except Exception:
|
)
|
||||||
pass
|
|
||||||
|
|
||||||
session.commit()
|
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:
|
||||||
yield session
|
yield session
|
||||||
|
|||||||
@@ -1,27 +1,236 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter
|
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 sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import crud
|
from . import crud
|
||||||
from .database import create_db_and_tables, get_session
|
from . import maintenance
|
||||||
|
from .database import run_migrations, get_session, engine
|
||||||
|
from .validation import sanitize_name
|
||||||
|
from .ws import manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
EVENT_PAGE_SIZE = 50
|
EVENT_PAGE_SIZE = 50
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
_logging_configured = False
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
"""Set up backend logging: always to stderr, plus a rotating file when
|
||||||
|
PIRATS_LOG_FILE is set (the NixOS service points it at /var/log/pirats/).
|
||||||
|
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
|
||||||
|
|
||||||
|
level_name = os.environ.get("PIRATS_LOG_LEVEL", "INFO").upper()
|
||||||
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level)
|
||||||
|
fmt = logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
stderr_handler = logging.StreamHandler(sys.stderr)
|
||||||
|
stderr_handler.setFormatter(fmt)
|
||||||
|
root.addHandler(stderr_handler)
|
||||||
|
|
||||||
|
log_file = os.environ.get("PIRATS_LOG_FILE")
|
||||||
|
if log_file:
|
||||||
|
try:
|
||||||
|
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_handler = RotatingFileHandler(
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
create_db_and_tables()
|
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
|
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)
|
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)
|
||||||
api = APIRouter()
|
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 Core Route Handlers ---
|
||||||
|
|
||||||
@api.post("/game")
|
@api.post("/game")
|
||||||
@@ -29,10 +238,18 @@ def create_game_route(
|
|||||||
crew_name: str = Form(...),
|
crew_name: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
game = crud.create_game(db, crew_name=crew_name.strip())
|
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
|
# admin_key is only revealed here, to the creator; the state endpoint hides it
|
||||||
return {"id": game.id, "admin_key": game.admin_key}
|
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")
|
@api.post("/game/{game_id}/join")
|
||||||
def join_game_submit(
|
def join_game_submit(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
@@ -45,9 +262,21 @@ 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 {"id": player.id}
|
||||||
|
|
||||||
|
@api.post("/game/{game_id}/player/{player_id}/leave")
|
||||||
|
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")
|
@api.get("/game/{game_id}/player/{player_id}/state")
|
||||||
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
|
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)
|
||||||
@@ -58,14 +287,19 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi
|
|||||||
events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
|
events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"game": game.model_dump(exclude={"admin_key"}),
|
# admin_key stays hidden except from Admins, who need it to open the admin panel
|
||||||
|
"game": game.model_dump(exclude=(set() if player.is_admin else {"admin_key"})),
|
||||||
"player": player.model_dump(),
|
"player": player.model_dump(),
|
||||||
"players": [p.model_dump() for p in game.players],
|
# A swap offer must reveal nothing but its existence: only the offerer
|
||||||
|
# gets to see which technique they put on the table.
|
||||||
|
"players": [p.model_dump(exclude=(set() if p.id == player.id else {"swap_offer_technique"})) for p in game.players],
|
||||||
"obstacles": [o.model_dump() for o in game.obstacles],
|
"obstacles": [o.model_dump() for o in game.obstacles],
|
||||||
"challenges": [c.model_dump() for c in game.challenges],
|
"challenges": [c.model_dump() for c in game.challenges],
|
||||||
"votes": [v.model_dump() for v in game.votes],
|
"votes": [v.model_dump() for v in game.votes],
|
||||||
"events": [e.model_dump() for e in events],
|
"events": [e.model_dump() for e in events],
|
||||||
"events_have_more": events_have_more,
|
"events_have_more": events_have_more,
|
||||||
|
# The newest checkpoint id, so the log can tell which event is "you are here".
|
||||||
|
"latest_checkpoint_id": crud.latest_checkpoint_id(db, game_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
@api.get("/game/{game_id}/events")
|
@api.get("/game/{game_id}/events")
|
||||||
@@ -94,6 +328,7 @@ from .routes_scene import router as scene_router
|
|||||||
from .routes_challenge import router as challenge_router
|
from .routes_challenge import router as challenge_router
|
||||||
from .routes_upkeep import router as upkeep_router
|
from .routes_upkeep import router as upkeep_router
|
||||||
from .routes_admin import router as admin_router
|
from .routes_admin import router as admin_router
|
||||||
|
from .routes_rollback import router as rollback_router
|
||||||
|
|
||||||
api.include_router(lobby_router)
|
api.include_router(lobby_router)
|
||||||
api.include_router(character_router)
|
api.include_router(character_router)
|
||||||
@@ -101,16 +336,23 @@ api.include_router(scene_router)
|
|||||||
api.include_router(challenge_router)
|
api.include_router(challenge_router)
|
||||||
api.include_router(upkeep_router)
|
api.include_router(upkeep_router)
|
||||||
api.include_router(admin_router)
|
api.include_router(admin_router)
|
||||||
|
api.include_router(rollback_router)
|
||||||
|
|
||||||
# Mount API at /api
|
# Mount API at /api
|
||||||
app.include_router(api, prefix="/api")
|
app.include_router(api, prefix="/api")
|
||||||
|
|
||||||
|
# Static rulebook page (registered before the SPA mount so it takes precedence)
|
||||||
|
@app.get("/rules", include_in_schema=False)
|
||||||
|
def rules_page():
|
||||||
|
return FileResponse(BASE_DIR / "rules.html", media_type="text/html")
|
||||||
|
|
||||||
# Mount SPA
|
# Mount SPA
|
||||||
static_dir = BASE_DIR / "static"
|
static_dir = BASE_DIR / "static"
|
||||||
if os.path.exists(static_dir):
|
if os.path.exists(static_dir):
|
||||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||||
|
|
||||||
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,26 +1,40 @@
|
|||||||
import time
|
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()))
|
||||||
|
join_code: str = Field(default_factory=new_join_code, index=True, unique=True)
|
||||||
crew_name: Optional[str] = Field(default=None)
|
crew_name: Optional[str] = Field(default=None)
|
||||||
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "ended"
|
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) # Permanent increase to required obstacles (from Jokers drawn as Obstacles)
|
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_1: bool = Field(default=False) # Steal a Ship
|
||||||
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
||||||
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
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
|
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)
|
||||||
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)
|
events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
challenges: List["Challenge"] = 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)
|
||||||
@@ -33,6 +47,7 @@ class Player(SQLModel, table=True):
|
|||||||
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="")
|
||||||
@@ -48,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="")
|
||||||
@@ -58,12 +76,18 @@ 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
|
# States for Milestones & Death
|
||||||
needs_name: bool = Field(default=False)
|
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)
|
needs_rank_3_bonus: bool = Field(default=False)
|
||||||
is_dead: bool = Field(default=False)
|
is_dead: bool = Field(default=False)
|
||||||
is_ghost: 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
|
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")
|
||||||
@@ -98,7 +122,9 @@ class Challenge(SQLModel, table=True):
|
|||||||
challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
|
challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
|
||||||
obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges)
|
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
|
temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
|
||||||
stakes: str = Field(default="")
|
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"}]
|
plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
|
||||||
status: str = Field(default="open") # "open", "succeeded", "failed"
|
status: str = Field(default="open") # "open", "succeeded", "failed"
|
||||||
|
|
||||||
@@ -123,5 +149,21 @@ class GameEvent(SQLModel, table=True):
|
|||||||
timestamp: float = Field(default_factory=time.time)
|
timestamp: float = Field(default_factory=time.time)
|
||||||
message: str = Field(...)
|
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"
|
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")
|
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")
|
||||||
|
|||||||
@@ -1,10 +1,108 @@
|
|||||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
import json
|
||||||
|
from fastapi import APIRouter, Request, Form, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
|
|
||||||
router = APIRouter()
|
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
|
# Creator Admin Panel
|
||||||
@router.get("/game/{game_id}/admin")
|
@router.get("/game/{game_id}/admin")
|
||||||
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
|
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
|
||||||
@@ -26,6 +124,8 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
|
|||||||
"is_dead": p.is_dead,
|
"is_dead": p.is_dead,
|
||||||
"is_ghost": p.is_ghost,
|
"is_ghost": p.is_ghost,
|
||||||
"role": p.role,
|
"role": p.role,
|
||||||
|
"is_creator": p.is_creator,
|
||||||
|
"is_admin": p.is_admin,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -33,9 +133,60 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
|
|||||||
"game": {
|
"game": {
|
||||||
"id": game.id,
|
"id": game.id,
|
||||||
"crew_name": game.crew_name,
|
"crew_name": game.crew_name,
|
||||||
|
"join_code": game.join_code,
|
||||||
"phase": game.phase,
|
"phase": game.phase,
|
||||||
"admin_key": game.admin_key
|
"admin_key": game.admin_key
|
||||||
},
|
},
|
||||||
"players": players_data,
|
"players": players_data,
|
||||||
"base_url": base_url
|
"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"}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
|
from .validation import sanitize_text
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ def create_challenge_route(
|
|||||||
player_id: str,
|
player_id: str,
|
||||||
target_player_id: str = Form(...),
|
target_player_id: str = Form(...),
|
||||||
obstacle_ids: str = Form(...), # JSON list of obstacle ids
|
obstacle_ids: str = Form(...), # JSON list of obstacle ids
|
||||||
stakes: str = Form(""),
|
stakes_success: str = Form(""),
|
||||||
|
stakes_failure: str = Form(""),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -22,7 +24,8 @@ def create_challenge_route(
|
|||||||
assert isinstance(ids, list)
|
assert isinstance(ids, list)
|
||||||
except Exception:
|
except Exception:
|
||||||
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
|
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
|
||||||
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes)
|
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:
|
if not ok:
|
||||||
return JSONResponse({"error": msg}, status_code=400)
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlmodel import Session
|
|||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
from .cards import TECHNIQUE_SUGGESTIONS
|
from .cards import TECHNIQUE_SUGGESTIONS
|
||||||
|
from .validation import sanitize_text, sanitize_name
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -27,10 +28,10 @@ def player_save_basic_details(
|
|||||||
if not player:
|
if not player:
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
|
||||||
player.avatar_look = avatar_look.strip()
|
player.avatar_look = sanitize_text(avatar_look)
|
||||||
player.avatar_smell = avatar_smell.strip()
|
player.avatar_smell = sanitize_name(avatar_smell) # seeds the "Recruit {smell}" name
|
||||||
player.first_words = first_words.strip()
|
player.first_words = sanitize_text(first_words)
|
||||||
player.good_at_math = good_at_math.strip()
|
player.good_at_math = sanitize_text(good_at_math)
|
||||||
# Until a Pi-Rat earns a Name they are identified by their smell
|
# Until a Pi-Rat earns a Name they are identified by their smell
|
||||||
if not player.completed_personal_2:
|
if not player.completed_personal_2:
|
||||||
player.name = crud.recruit_name(player)
|
player.name = crud.recruit_name(player)
|
||||||
@@ -88,28 +89,6 @@ def player_delegate_question(
|
|||||||
crud.delegate_question(db, player_id, question_type, target_player_id)
|
crud.delegate_question(db, player_id, question_type, target_player_id)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Revoke a delegated question task
|
|
||||||
@router.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}")
|
|
||||||
def player_revoke_delegation(
|
|
||||||
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()
|
|
||||||
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
# Answer a delegated question for another player
|
# Answer a delegated question for another player
|
||||||
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
||||||
def player_submit_delegated_answer(
|
def player_submit_delegated_answer(
|
||||||
@@ -120,7 +99,7 @@ def player_submit_delegated_answer(
|
|||||||
answer: str = Form(...),
|
answer: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id)
|
crud.submit_delegated_answer(db, target_player_id, question_type, sanitize_text(answer), player_id)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Submit 3 Secret Pirate Techniques
|
# Submit 3 Secret Pirate Techniques
|
||||||
@@ -133,11 +112,89 @@ def player_submit_techniques(
|
|||||||
tech3: str = Form(...),
|
tech3: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
error = crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip())
|
error = crud.submit_techniques(db, player_id, sanitize_text(tech1), sanitize_text(tech2), sanitize_text(tech3))
|
||||||
if error:
|
if error:
|
||||||
return JSONResponse({"error": error}, status_code=400)
|
return JSONResponse({"error": error}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Unlock your submitted techniques for revision (before swapping begins)
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/unsubmit-techniques")
|
||||||
|
def player_unsubmit_techniques(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.unsubmit_techniques(db, player_id)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Unlock your J/Q/K assignment for revision (while assignment is in progress)
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/unassign-face-techniques")
|
||||||
|
def player_unassign_face_techniques(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.unassign_face_techniques(db, player_id)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Offer one of your held techniques to another player as a blind swap
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/offer-swap")
|
||||||
|
def player_offer_swap(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
target_player_id: str = Form(...),
|
||||||
|
technique: str = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.offer_swap(db, player_id, target_player_id, technique)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Withdraw your pending swap offer
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/cancel-swap-offer")
|
||||||
|
def player_cancel_swap_offer(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.cancel_swap_offer(db, player_id)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Accept (trading back one of your techniques) or decline an incoming swap offer
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/respond-swap-offer")
|
||||||
|
def player_respond_swap_offer(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
offerer_player_id: str = Form(...),
|
||||||
|
accept: bool = Form(...),
|
||||||
|
technique: str = Form(""),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.respond_swap_offer(db, player_id, offerer_player_id, accept, technique)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Declare yourself done (or not done) swapping techniques
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/swap-ready")
|
||||||
|
def player_swap_ready(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
ready: bool = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.set_swap_ready(db, player_id, ready)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Assign Swapped Techniques to Face Cards (Jack, Queen, King)
|
# Assign Swapped Techniques to Face Cards (Jack, Queen, King)
|
||||||
@router.post("/game/{game_id}/player/{player_id}/assign-face-techniques")
|
@router.post("/game/{game_id}/player/{player_id}/assign-face-techniques")
|
||||||
def player_assign_face_techniques(
|
def player_assign_face_techniques(
|
||||||
@@ -150,28 +207,52 @@ def player_assign_face_techniques(
|
|||||||
):
|
):
|
||||||
if len({jack, queen, king}) < 3:
|
if len({jack, queen, king}) < 3:
|
||||||
return JSONResponse({"error": "Each card must be assigned a unique technique."}, status_code=400)
|
return JSONResponse({"error": "Each card must be assigned a unique technique."}, status_code=400)
|
||||||
|
|
||||||
crud.assign_face_card_techniques(db, player_id, jack, queen, king)
|
ok, msg = crud.assign_face_card_techniques(db, player_id, jack, queen, king)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Roll a new character after death
|
# Dead Pi-Rat opts to come back as a fresh recruit (created between scenes)
|
||||||
@router.post("/game/{game_id}/player/{player_id}/roll-new-character")
|
@router.post("/game/{game_id}/player/{player_id}/choose-reroll")
|
||||||
def roll_new_character_route(
|
def choose_reroll_route(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
avatar_look: str = Form(...),
|
|
||||||
avatar_smell: str = Form(...),
|
|
||||||
first_words: str = Form(...),
|
|
||||||
good_at_math: str = Form(...),
|
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
crud.roll_new_character(
|
ok, msg = crud.choose_reroll(db, player_id)
|
||||||
db,
|
if not ok:
|
||||||
game_id,
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
player_id,
|
return {"status": "ok"}
|
||||||
avatar_look=avatar_look,
|
|
||||||
avatar_smell=avatar_smell,
|
# Write (or revise) one of your assigned technique slots for a pending recruit
|
||||||
first_words=first_words,
|
@router.post("/game/{game_id}/player/{player_id}/contribute-technique")
|
||||||
good_at_math=good_at_math
|
def contribute_technique_route(
|
||||||
)
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
recruit_id: str = Form(...),
|
||||||
|
slot: int = Form(...),
|
||||||
|
text: str = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
ok, msg = crud.contribute_recruit_technique(db, player_id, recruit_id, slot, sanitize_text(text))
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Recruit assigns their crew-written techniques to J/Q/K and joins the crew
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/finalize-recruit")
|
||||||
|
def finalize_recruit_route(
|
||||||
|
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:
|
||||||
|
return JSONResponse({"error": "Each card must be assigned a unique technique."}, status_code=400)
|
||||||
|
ok, msg = crud.finalize_recruit(db, player_id, jack, queen, king)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
@@ -10,6 +11,8 @@ def lobby_start_character_creation(game_id: str, db: Session = Depends(get_sessi
|
|||||||
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")
|
||||||
|
if not crud.has_min_players(game):
|
||||||
|
return JSONResponse({"error": f"You need at least {crud.MIN_PLAYERS} players to start a game."}, status_code=400)
|
||||||
game.phase = "character_creation"
|
game.phase = "character_creation"
|
||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
28
src/pirats/routes_rollback.py
Normal file
28
src/pirats/routes_rollback.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from fastapi import APIRouter, Form, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
from .database import get_session
|
||||||
|
from . import crud
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# Roll the game back to a checkpoint. Player-id authenticated; the actual permission
|
||||||
|
# check (Admin or Deep player, during a scene) lives in crud.rollback_to_checkpoint.
|
||||||
|
# The "/rollback" path suffix is how the broadcast middleware knows to skip capturing
|
||||||
|
# a checkpoint for this request.
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/rollback")
|
||||||
|
def rollback_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
checkpoint_id: int = Form(...),
|
||||||
|
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 or player.game_id != game.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||||
|
ok, msg = crud.rollback_to_checkpoint(db, game, player, checkpoint_id)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -3,9 +3,22 @@ from fastapi.responses import JSONResponse
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
|
from .cards import OBSTACLES_DATA
|
||||||
|
from .validation import sanitize_text, sanitize_name
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# The rulebook's obstacle tables: what every card represents when drawn as an
|
||||||
|
# Obstacle (used by the frontend for card tooltips)
|
||||||
|
@router.get("/obstacles")
|
||||||
|
def obstacle_table():
|
||||||
|
return {
|
||||||
|
"obstacles": {
|
||||||
|
suit: {val: {"title": t, "description": d} for val, (t, d) in table.items()}
|
||||||
|
for suit, table in OBSTACLES_DATA.items()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Set Role during Scene Setup
|
# Set Role during Scene Setup
|
||||||
@router.post("/game/{game_id}/player/{player_id}/set-role")
|
@router.post("/game/{game_id}/player/{player_id}/set-role")
|
||||||
def player_set_role(
|
def player_set_role(
|
||||||
@@ -77,35 +90,31 @@ def toggle_objective_route(
|
|||||||
):
|
):
|
||||||
if type.startswith("crew_"):
|
if type.startswith("crew_"):
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
if not game: raise HTTPException(status_code=404)
|
if not game:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
current_status = False
|
current_status = False
|
||||||
if type == "crew_1": current_status = game.completed_crew_1
|
if type == "crew_1":
|
||||||
elif type == "crew_2": current_status = game.completed_crew_2
|
current_status = game.completed_crew_1
|
||||||
elif type == "crew_3": current_status = game.completed_crew_3
|
elif type == "crew_2":
|
||||||
|
current_status = game.completed_crew_2
|
||||||
|
elif type == "crew_3":
|
||||||
|
current_status = game.completed_crew_3
|
||||||
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
||||||
else:
|
else:
|
||||||
player = crud.get_player(db, player_id)
|
player = crud.get_player(db, player_id)
|
||||||
if not player: raise HTTPException(status_code=404, detail="Player not found")
|
if not player:
|
||||||
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
current_status = False
|
current_status = False
|
||||||
if type == "personal_1": current_status = player.completed_personal_1
|
if type == "personal_1":
|
||||||
elif type == "personal_2": current_status = player.completed_personal_2
|
current_status = player.completed_personal_1
|
||||||
elif type == "personal_3": current_status = player.completed_personal_3
|
elif type == "personal_2":
|
||||||
|
current_status = player.completed_personal_2
|
||||||
|
elif type == "personal_3":
|
||||||
|
current_status = player.completed_personal_3
|
||||||
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Deep rollbacks a card play
|
|
||||||
@router.post("/game/{game_id}/scene/rollback")
|
|
||||||
def rollback_card_play_route(
|
|
||||||
game_id: str,
|
|
||||||
obstacle_id: str = Form(...),
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
ok, msg = crud.rollback_card_play(db, obstacle_id)
|
|
||||||
if not ok:
|
|
||||||
return JSONResponse({"error": msg}, status_code=400)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
# Pi-Rat sets a new name
|
# Pi-Rat sets a new name
|
||||||
@router.post("/game/{game_id}/player/{player_id}/set-name")
|
@router.post("/game/{game_id}/player/{player_id}/set-name")
|
||||||
def set_name_route(
|
def set_name_route(
|
||||||
@@ -116,29 +125,45 @@ def set_name_route(
|
|||||||
):
|
):
|
||||||
player = crud.get_player(db, player_id)
|
player = crud.get_player(db, player_id)
|
||||||
if player and player.needs_name:
|
if player and player.needs_name:
|
||||||
player.name = new_name
|
player.name = sanitize_name(new_name)
|
||||||
player.needs_name = False
|
player.needs_name = False
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Pi-Rat bonus rank up for another player
|
# Pi-Rat describes the Gat they just earned
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/set-gat-description")
|
||||||
|
def set_gat_description_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
description: str = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
player = crud.get_player(db, player_id)
|
||||||
|
if player and player.needs_gat_description:
|
||||||
|
player.gat_description = sanitize_text(description)
|
||||||
|
player.needs_gat_description = False
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# A Pi-Rat who finished their story (3rd Personal Objective) grants another Pi-Rat a
|
||||||
|
# bonus Rank. An empty target_player_id forfeits the bonus (e.g. no eligible crewmate).
|
||||||
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
|
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
|
||||||
def bonus_rank_up_route(
|
def bonus_rank_up_route(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
target_player_id: str = Form(...),
|
target_player_id: str = Form(""),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
target = crud.get_player(db, target_player_id)
|
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
if player and target and game and player.needs_rank_3_bonus:
|
player = crud.get_player(db, player_id)
|
||||||
player.needs_rank_3_bonus = False
|
if not game or not player:
|
||||||
db.add(player)
|
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||||
db.commit()
|
target = crud.get_player(db, target_player_id) if target_player_id else None
|
||||||
crud.change_player_rank(db, game, target, 1)
|
ok, msg = crud.grant_story_bonus_rank(db, game, player, target)
|
||||||
crud.add_game_event(db, game_id, f"{player.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.")
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Pi-Rat becomes a ghost
|
# Pi-Rat becomes a ghost
|
||||||
@@ -152,6 +177,7 @@ def become_ghost_route(
|
|||||||
if player and player.is_dead:
|
if player and player.is_dead:
|
||||||
player.is_ghost = True
|
player.is_ghost = True
|
||||||
player.is_dead = False
|
player.is_dead = False
|
||||||
|
player.needs_reroll = False # in case they queued a re-roll first, ghosthood wins
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@@ -159,7 +185,9 @@ def become_ghost_route(
|
|||||||
# Deep ends the scene
|
# Deep ends the scene
|
||||||
@router.post("/game/{game_id}/scene/end")
|
@router.post("/game/{game_id}/scene/end")
|
||||||
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
|
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
|
||||||
crud.end_scene_and_transition(db, game_id)
|
ok, msg = crud.end_scene_and_transition(db, game_id)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Deep clears a completed obstacle
|
# Deep clears a completed obstacle
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_ses
|
|||||||
|
|
||||||
# Check if all players in the game are ready. If so, transition to deep upkeep!
|
# Check if all players in the game are ready. If so, transition to deep upkeep!
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
if game and all(p.is_ready for p in game.players):
|
if game:
|
||||||
crud.transition_to_deep_upkeep(db, game_id)
|
crud.maybe_transition_to_deep_upkeep(db, game)
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Confirm Hand Refresh for Deep player
|
# Confirm Hand Refresh for Deep player
|
||||||
|
|||||||
1562
src/pirats/rules.html
Normal file
1562
src/pirats/rules.html
Normal file
File diff suppressed because it is too large
Load Diff
41
src/pirats/validation.py
Normal file
41
src/pirats/validation.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Light input hardening for free-text fields submitted by players.
|
||||||
|
|
||||||
|
The app is deliberately cooperative and light on auth (see the trust model), but
|
||||||
|
it's exposed on the open internet, so player-authored free text gets a basic
|
||||||
|
scrub before it is stored: drop control characters and unpaired surrogates that
|
||||||
|
could corrupt JSON or the display, trim surrounding whitespace, and cap the
|
||||||
|
length. SQL injection is already handled by SQLModel's parameterized queries —
|
||||||
|
this is only about content hygiene and bounding payload size.
|
||||||
|
|
||||||
|
Only fields whose value is authored-and-stored go through here. Identifiers
|
||||||
|
(player/obstacle ids), enums (role, objective type), and values matched against
|
||||||
|
existing stored text (a swap offer, a face-card technique assignment) are left
|
||||||
|
untouched so they still compare equal.
|
||||||
|
"""
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
MAX_NAME_LENGTH = 120 # crew / player / Pi-Rat names and the smell that seeds them
|
||||||
|
MAX_TEXT_LENGTH = 2000 # everything else: catchphrases, techniques, stakes, descriptions
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_text(value: str, max_length: int = MAX_TEXT_LENGTH) -> str:
|
||||||
|
"""`value` with control characters and unpaired surrogates removed,
|
||||||
|
surrounding whitespace trimmed, and length capped at `max_length`.
|
||||||
|
Tabs and newlines are kept; emoji (incl. ZWJ sequences) survive."""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
cleaned = "".join(
|
||||||
|
ch for ch in value
|
||||||
|
if ch in ("\t", "\n")
|
||||||
|
or (unicodedata.category(ch) != "Cc" and not 0xD800 <= ord(ch) <= 0xDFFF)
|
||||||
|
)
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
if len(cleaned) > max_length:
|
||||||
|
cleaned = cleaned[:max_length].rstrip()
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_name(value: str) -> str:
|
||||||
|
"""Sanitize a single-line, name-like field: the tighter name cap, plus
|
||||||
|
collapsing any internal whitespace runs to single spaces."""
|
||||||
|
return " ".join(sanitize_text(value, max_length=MAX_NAME_LENGTH).split())
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user