Compare commits
83 Commits
443acd8400
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d68a80a3 | |||
| b9cbbda1a2 | |||
| 3e912b9458 | |||
| 929941b8ff | |||
| 9a5a319cb9 | |||
| a5bfb7905f | |||
| b253a06b45 | |||
| 8470da4aa9 | |||
| 2de4648288 | |||
| 3fe3333768 | |||
| feb595af16 | |||
| 1a4bc86139 | |||
| 2210bcd48e | |||
| bf800a06db | |||
| 692c6d26c1 | |||
| 122e66b817 | |||
| ccb32e7103 | |||
| 5e9f9bfb13 | |||
| b1a559cf39 | |||
| 6fa7073f10 | |||
| b18bf683a9 | |||
| e1779292e5 | |||
| 1c8db78c43 | |||
| c03efd4a65 | |||
| e4c78391fc | |||
| 922fc38c0a | |||
| cf9ee33b52 | |||
| 17160f9d9b | |||
| 44f7fd939b | |||
| 5b5087ac23 | |||
| daeac91d07 | |||
| 045857a8fd | |||
| 2ea91c066a | |||
| 8614a6c820 | |||
| 44bbb88836 | |||
| 7825ad2cca | |||
| 473fd74a00 | |||
| ceaf88ef22 | |||
| 39dc07d6b6 | |||
| 5a8919d967 | |||
| b37b655118 | |||
| bfe982251e | |||
| 5a9d712c95 | |||
| 45fefc0c71 | |||
| f40724c9d2 | |||
| 3bb4940df7 | |||
| 55f4085d7a | |||
| 56cdebdeda | |||
| 6964aeabb6 | |||
| 0bce291ab9 | |||
| a7f174b214 | |||
| e7db19f27e | |||
| 428af784e3 | |||
| d04e5013fa | |||
| 345ef4088f | |||
| d68333ec05 | |||
| fb17c632b0 | |||
| e502155af7 | |||
| 952b1be872 | |||
| ba32c96b7b | |||
| fa37cffe47 | |||
| 121f2f7498 | |||
| 5ad9c9db07 | |||
| e24e6d3b46 | |||
| 3395b127b4 | |||
| f61201144f | |||
| df7a0cc83f | |||
| 4e262a5345 | |||
| 5f647d1940 | |||
| 96eeb442fd | |||
| 0535c68647 | |||
| 0744893e7b | |||
| ba6bf35579 | |||
| b72aea9747 | |||
| 4fcd2b693c | |||
| 683c60fff9 | |||
| 69e7d24e10 | |||
| ed7900656d | |||
| bc867c95a1 | |||
| 3a00774b50 | |||
| 6dcb2a9ae9 | |||
| a074ff77b1 | |||
| a7cab7bcb6 |
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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,4 +1,11 @@
|
|||||||
.venv
|
.venv
|
||||||
rats_with_gats.db
|
rats_with_gats.db
|
||||||
|
smoke_test.db
|
||||||
result
|
result
|
||||||
*.pdf
|
*.pdf
|
||||||
|
# Built Svelte frontend (copied in from frontend/dist by the Nix build)
|
||||||
|
src/pirats/static/
|
||||||
|
# Machine-local Claude Code permission approvals
|
||||||
|
.claude/settings.local.json
|
||||||
|
# Emacs autosaves
|
||||||
|
\#*
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
74
README.md
74
README.md
@@ -1,8 +1,8 @@
|
|||||||
# Rats with Gats Remote Play (`pirats`)
|
# Rats with Gats Remote Play (`pirats`)
|
||||||
|
|
||||||
A web-based remote play companion app for the tabletop roleplaying game **Rats with Gats** (where players roleplay as space-faring pirate rats, or "Pi-Rats"). The application manages lobbies, character creation, card mechanics, scene phases, obstacles, challenges, and player voting.
|
A web-based remote play companion app for the tabletop roleplaying game **Rats with Gats** (where players roleplay as gunslinging pirate rats, or "Pi-Rats", in the magical land of Yeld). The application manages lobbies, character creation, card mechanics, scene phases, obstacles, challenges, and player voting. See [RULEBOOK.md](RULEBOOK.md) for the game rules.
|
||||||
|
|
||||||
It is built with **FastAPI**, **SQLModel** (SQLite database), **Jinja2 templates**, and **HTMX** for dynamic, real-time page updates.
|
It is built with a **FastAPI** + **SQLModel** (SQLite) backend and a **Svelte 5** single-page frontend (in `frontend/`), which polls the backend's JSON state endpoint for near-real-time updates.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -125,16 +151,38 @@ nix develop --command pytest
|
|||||||
|
|
||||||
```
|
```
|
||||||
pirats/
|
pirats/
|
||||||
├── pyproject.toml # Python package metadata and dependencies
|
├── pyproject.toml # Python package metadata and dependencies
|
||||||
├── flake.nix # Nix package, dev shell, and NixOS service module
|
├── flake.nix # Nix package (incl. frontend build), dev shell, NixOS service module
|
||||||
├── tests/ # Unit and integration tests
|
├── tests/
|
||||||
│ └── test_game.py # Game logic and CRUD database test suites
|
│ └── test_game.py # Game logic and CRUD test suite (pure-crud, no HTTP for most)
|
||||||
|
├── frontend/ # Svelte 5 + Vite SPA
|
||||||
|
│ └── src/
|
||||||
|
│ ├── pages/ # Routes: Home, Join, Dashboard (game UI), Admin
|
||||||
|
│ ├── components/ # One component per game phase, Card.svelte, and scene/ (ScenePhase sub-panels)
|
||||||
|
│ └── lib/ # api.js (fetch wrapper), cards.js (card display helpers), suggestions.js (Suggest-button pools)
|
||||||
└── src/
|
└── src/
|
||||||
└── pirats/ # Core Python package
|
└── pirats/ # FastAPI backend package
|
||||||
├── main.py # FastAPI routes, HTTP endpoints, CLI entrypoint
|
├── main.py # App setup, /api/game create/join/state endpoints, CLI entrypoint
|
||||||
├── crud.py # Core database CRUD, game mechanics, and transitions
|
├── models.py # SQLModel tables (Game, Player, Obstacle, Challenge, Vote, GameEvent)
|
||||||
├── models.py # SQLModel database tables (Game, Player, Obstacle, Challenge, Vote)
|
├── cards.py # Card parsing, deck building, obstacle table from the rulebook
|
||||||
├── cards.py # Playing card parser, deck setups, and obstacle definitions
|
├── crud.py # Facade re-exporting all crud_* modules
|
||||||
├── templates/ # Jinja2 HTML pages and HTMX snippets
|
├── crud_*.py # Game logic by phase: base (deck/hands/rank), character, scene, challenge, upkeep
|
||||||
└── static/ # CSS styles and frontend assets
|
├── routes_*.py # Thin API routers by phase, mounted under /api
|
||||||
|
└── static/ # Built frontend lands here (gitignored; populated by the Nix build)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
|
||||||
|
Start the backend and Vite dev server together from the repository root. Pressing Ctrl-C stops both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To run only the frontend, start the backend separately on port 8000, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite proxies `/api` to the backend on port 8000.
|
||||||
|
|||||||
15
TODO.md
Normal file
15
TODO.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
## Polish
|
||||||
|
|
||||||
|
- [x] The character sheet modal popup is narrower than it needs to be on wide screens. I think it could safely be like 90% as wide as the non-modal UI.
|
||||||
|
- [x] Popping out the event log should leave a close button where the "Event Log" open button is, so that you don't have to move your mouse to quickly toggle it. The existing close button can remain.
|
||||||
|
- [x] Use the same vertical UI for the player list in all phases
|
||||||
|
- [x] In the admin panel, if a pi-rat doesn't have a name yet, it should show something like 'not chosen yet' rather than the player name
|
||||||
|
- [x] In the admin panel, the "Open" and "Copy" buttons should be the same size
|
||||||
|
- [x] Store the player name in local storage and pre-populate it when joining a new game (but don't force it, let the player edit it if they'd prefer a different name)
|
||||||
|
- [x] If a player tries to rejoin a game that has ended or that they've been kicked from, they should get an error message and it should be removed from the list of re-joinable games
|
||||||
|
- [x] Games should have join codes in addition to join links. Join codes should be a sequence of 5 alphanumeric characters (upper case letters only, no ambiguous letters like 0/O/1/I). Add a panel to the home page for joining a game via code, and display the join code on the admin page
|
||||||
|
|
||||||
|
## Words Words Words
|
||||||
|
|
||||||
|
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
|
||||||
|
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
|
||||||
47
alembic.ini
Normal file
47
alembic.ini
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Dev-only config for the alembic CLI (the app runs migrations itself at
|
||||||
|
# startup via pirats.database.run_migrations).
|
||||||
|
#
|
||||||
|
# New migration: .venv/bin/alembic revision --autogenerate -m "add foo column"
|
||||||
|
# Apply: .venv/bin/alembic upgrade head
|
||||||
|
#
|
||||||
|
# No sqlalchemy.url here: env.py uses the app's engine (DATABASE_URL or
|
||||||
|
# ./rats_with_gats.db). Override with: alembic -x url=sqlite:///other.db ...
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = src/pirats/migrations
|
||||||
|
file_template = %%(rev)s_%%(slug)s
|
||||||
|
prepend_sys_path = src
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
38
dev.sh
Executable file
38
dev.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PIDS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
|
||||||
|
if ((${#PIDS[@]})); then
|
||||||
|
kill "${PIDS[@]}" 2>/dev/null || true
|
||||||
|
wait "${PIDS[@]}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
echo "Starting backend at http://localhost:8000"
|
||||||
|
"$ROOT_DIR/.venv/bin/uvicorn" pirats.main:app --host 0.0.0.0 --port 8000 --reload &
|
||||||
|
PIDS+=("$!")
|
||||||
|
|
||||||
|
echo "Starting frontend at http://localhost:5173"
|
||||||
|
(
|
||||||
|
cd "$ROOT_DIR/frontend"
|
||||||
|
exec ./node_modules/.bin/vite
|
||||||
|
) &
|
||||||
|
PIDS+=("$!")
|
||||||
|
|
||||||
|
echo "Press Ctrl-C to stop both servers."
|
||||||
|
wait
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
82
frontend/src/components/AboutModal.svelte
Normal file
82
frontend/src/components/AboutModal.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { VERSION, CHANGELOG } from '../lib/changelog';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
function close() { dispatch('close'); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||||
|
|
||||||
|
<div class="modal-backdrop" on:click|self={close}>
|
||||||
|
<div class="modal-box sheet-modal glass-panel about-modal">
|
||||||
|
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||||
|
<h3>About</h3>
|
||||||
|
<p class="about-tagline">A remote-play companion for <em>Rats with Gats</em>.</p>
|
||||||
|
<p class="about-version">Version {VERSION}</p>
|
||||||
|
|
||||||
|
<h4 class="about-heading">What's New</h4>
|
||||||
|
<ul class="changelog">
|
||||||
|
{#each CHANGELOG as entry (entry.version)}
|
||||||
|
<li class="changelog-entry">
|
||||||
|
<div class="changelog-head">
|
||||||
|
<span class="changelog-ver">v{entry.version}</span>
|
||||||
|
<span class="changelog-date text-muted">{entry.date}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="changelog-changes">
|
||||||
|
{#each entry.changes as change}
|
||||||
|
<li>{change}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.about-tagline {
|
||||||
|
margin: 0.25rem 0 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.about-version {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.about-heading {
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
.changelog {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.changelog-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.changelog-ver {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.changelog-date {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.changelog-changes {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
59
frontend/src/components/Card.svelte
Normal file
59
frontend/src/components/Card.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script>
|
||||||
|
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, cardTooltipHtml, obstacleTable } from '../lib/cards';
|
||||||
|
import { tooltip } from '../lib/tooltip';
|
||||||
|
|
||||||
|
export let card; // card code, e.g. "10D" or "Joker1"
|
||||||
|
export let size = 'large'; // 'large' | 'medium' | 'mini'
|
||||||
|
export let draggable = false;
|
||||||
|
export let rotated = false; // mini: rendered as a played (rotated) column card
|
||||||
|
export let success = null; // mini: true/false adds success/failure styling
|
||||||
|
export let owner = ''; // mini: short label of who played the card
|
||||||
|
export let title = ''; // explicit override; otherwise the card describes itself
|
||||||
|
export let techs = null; // large: {J, Q, K} technique names for the face-card overlay
|
||||||
|
export let style = '';
|
||||||
|
|
||||||
|
$: joker = isJoker(card);
|
||||||
|
$: val = joker ? '🃏' : cardValue(card);
|
||||||
|
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
|
||||||
|
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
|
||||||
|
$: techName = techs && !joker ? techs[cardValue(card)] : null;
|
||||||
|
// Tooltip: an explicit title wins (plain text); otherwise a rich graphical
|
||||||
|
// tooltip describing the card itself (suit theme + Obstacle meaning), which
|
||||||
|
// recomputes once the obstacle table loads.
|
||||||
|
$: tipContent = title ? title : { html: cardTooltipHtml(card, $obstacleTable) };
|
||||||
|
$: ariaLabel = title || cardTooltip(card, $obstacleTable);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if size === 'mini'}
|
||||||
|
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}"
|
||||||
|
use:tooltip={tipContent} aria-label={ariaLabel}>
|
||||||
|
<span class="val">{val}</span>
|
||||||
|
<span class="suit">{suit}</span>
|
||||||
|
{#if owner}<span class="owner">{owner}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
|
||||||
|
use:tooltip={tipContent} aria-label={ariaLabel} {draggable} {style}
|
||||||
|
on:dragstart on:dragend on:click>
|
||||||
|
<div class="card-corner top-left">
|
||||||
|
<span class="val">{val}</span>
|
||||||
|
<span class="suit">{suit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-center">
|
||||||
|
{#if joker}
|
||||||
|
🃏
|
||||||
|
{:else}
|
||||||
|
<span class="giant-symbol" style={size === 'medium' ? 'font-size: 1.5rem;' : ''}>{suit}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if techName}
|
||||||
|
<div class="card-tech-overlay">
|
||||||
|
<div class="tech-tag" use:tooltip={`Automatic success when played\n\n"${techName}"`}>{cardValue(card)}: "{techName}"</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="card-corner bottom-right">
|
||||||
|
<span class="val">{val}</span>
|
||||||
|
<span class="suit">{suit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { getSuggestion, getSuggestions } from '../lib/suggestions';
|
import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
|
||||||
|
import { displayName } from '../lib/cards';
|
||||||
|
import FaceCardAssigner from './FaceCardAssigner.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
@@ -14,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 {
|
||||||
@@ -26,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 () => {
|
||||||
@@ -42,13 +61,17 @@
|
|||||||
function getPlayerName(id) {
|
function getPlayerName(id) {
|
||||||
if (!id) return "None";
|
if (!id) return "None";
|
||||||
const p = state.players.find(x => x.id === id);
|
const p = state.players.find(x => x.id === id);
|
||||||
return p ? p.name : "Unknown";
|
return p ? displayName(p) : "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Techniques
|
// Techniques
|
||||||
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;
|
||||||
@@ -57,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 {
|
||||||
@@ -64,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;
|
||||||
@@ -78,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 {
|
||||||
@@ -101,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] = getSuggestions('techniques', 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>
|
||||||
|
|
||||||
@@ -170,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">
|
||||||
|
|
||||||
@@ -195,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>
|
||||||
@@ -203,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">
|
||||||
@@ -210,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>
|
||||||
@@ -261,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>
|
||||||
@@ -272,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 {p.name}?</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 {p.name}?</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>
|
||||||
@@ -303,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>
|
||||||
@@ -313,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={() => tech1 = getSuggestion('techniques', [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={() => tech2 = getSuggestion('techniques', [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={() => tech3 = getSuggestion('techniques', [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>
|
||||||
@@ -500,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>{p.name}</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}
|
||||||
515
frontend/src/components/EventLog.svelte
Normal file
515
frontend/src/components/EventLog.svelte
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
<script>
|
||||||
|
import { tick, onMount, createEventDispatcher } from 'svelte';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
// Inline mode pins the log open as a column (scene phase) instead of the
|
||||||
|
// floating, collapsible corner panel used in every other phase.
|
||||||
|
export let inline = false;
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
||||||
|
const TOP_LOAD_THRESHOLD = 60; // px from the top that triggers loading older events
|
||||||
|
|
||||||
|
const KIND_ICONS = {
|
||||||
|
join: '👋',
|
||||||
|
phase: '🧭',
|
||||||
|
scene: '🎬',
|
||||||
|
captain: '🏴☠️',
|
||||||
|
challenge: '⚔️',
|
||||||
|
card: '🃏',
|
||||||
|
tax: '💰',
|
||||||
|
objective: '🎯',
|
||||||
|
vote: '🗳️',
|
||||||
|
obstacle: '🌊',
|
||||||
|
joker: '🤡',
|
||||||
|
victory: '🎉',
|
||||||
|
warning: '⚠️',
|
||||||
|
rank: '🎖️',
|
||||||
|
info: '📜',
|
||||||
|
};
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
// The content is visible whenever the floating log is open OR it's pinned inline.
|
||||||
|
$: shown = open || inline;
|
||||||
|
let logEl;
|
||||||
|
let events = []; // ascending by timestamp; accumulated across polls + history loads
|
||||||
|
let seenIds = new Set();
|
||||||
|
let haveMore = true;
|
||||||
|
let loadingOlder = false;
|
||||||
|
let atBottom = true;
|
||||||
|
let error = '';
|
||||||
|
let timelineVersion = null;
|
||||||
|
// Toast that briefly previews a freshly-arrived event while the log is
|
||||||
|
// collapsed, then animates down into the Event Log button.
|
||||||
|
let toast = null; // { id, message, kind }
|
||||||
|
let initialized = false; // suppresses a toast flood on the first state load
|
||||||
|
|
||||||
|
|
||||||
|
// Rollback buttons appear during a scene, for Admins and Deep players (server-enforced too).
|
||||||
|
$: canRollback = state.game?.phase === 'scene'
|
||||||
|
&& (state.player?.is_admin || state.player?.role === 'deep');
|
||||||
|
// When set, the game is at a rolled-back state; events past this checkpoint are the
|
||||||
|
// greyed, still-undoable future. Rolling forward to one of them redoes the rollback.
|
||||||
|
$: head = state.game?.rollback_head_checkpoint_id ?? null;
|
||||||
|
// The point in time the game is currently at: the rollback head if set, otherwise
|
||||||
|
// the newest checkpoint (supplied by the backend). A button targeting it would be a
|
||||||
|
// no-op, so the "you are here" event — and, in live play, the latest event — has none.
|
||||||
|
$: currentPos = head !== null ? head : (state.latest_checkpoint_id ?? 0);
|
||||||
|
|
||||||
|
$: mergePolled(state.events);
|
||||||
|
|
||||||
|
// The poll only carries the newest PAGE_SIZE events, so accumulate them here —
|
||||||
|
// otherwise events the player paged back to would vanish as the window slides.
|
||||||
|
async function mergePolled(polled) {
|
||||||
|
if (!polled) return;
|
||||||
|
// Plain rollback/redo keeps every event (only `head` moves); but taking a new
|
||||||
|
// action from a rolled-back state truncates the abandoned future and bumps the
|
||||||
|
// version, which tells us to drop what we'd accumulated and reseed.
|
||||||
|
const version = state.game?.rollback_timeline_version ?? 0;
|
||||||
|
let reset = false;
|
||||||
|
if (timelineVersion !== null && version !== timelineVersion) {
|
||||||
|
events = [];
|
||||||
|
seenIds = new Set();
|
||||||
|
reset = true;
|
||||||
|
}
|
||||||
|
timelineVersion = version;
|
||||||
|
if (events.length === 0) {
|
||||||
|
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
||||||
|
}
|
||||||
|
let added = false;
|
||||||
|
let newest = null;
|
||||||
|
for (const e of polled) {
|
||||||
|
if (!seenIds.has(e.id)) {
|
||||||
|
seenIds.add(e.id);
|
||||||
|
events.push(e);
|
||||||
|
added = true;
|
||||||
|
newest = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added) {
|
||||||
|
events = events;
|
||||||
|
if (shown && atBottom) {
|
||||||
|
await tick();
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
// Toast only for genuinely new events (not the initial seed or a
|
||||||
|
// rollback reseed) and only while the floating log is closed.
|
||||||
|
if (initialized && !reset && !shown && newest) {
|
||||||
|
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An open/inline log makes the preview toast redundant.
|
||||||
|
$: if (shown) toast = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (inline) scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearToast(id) {
|
||||||
|
if (toast && toast.id === id) toast = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
atBottom = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (!logEl) return;
|
||||||
|
atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight <= BOTTOM_TOLERANCE;
|
||||||
|
if (logEl.scrollTop <= TOP_LOAD_THRESHOLD) loadOlder();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlder() {
|
||||||
|
if (loadingOlder || !haveMore || events.length === 0) return;
|
||||||
|
loadingOlder = true;
|
||||||
|
try {
|
||||||
|
const oldest = events[0].timestamp;
|
||||||
|
const data = await apiRequest(`/game/${state.game.id}/events?before=${oldest}&limit=${PAGE_SIZE}`);
|
||||||
|
haveMore = data.have_more;
|
||||||
|
const fresh = data.events.filter(e => !seenIds.has(e.id));
|
||||||
|
for (const e of fresh) seenIds.add(e.id);
|
||||||
|
if (fresh.length) {
|
||||||
|
// Prepending grows the content above the viewport; restore the
|
||||||
|
// player's position so the log doesn't visually jump.
|
||||||
|
const prevHeight = logEl.scrollHeight;
|
||||||
|
const prevTop = logEl.scrollTop;
|
||||||
|
events = [...fresh, ...events];
|
||||||
|
await tick();
|
||||||
|
logEl.scrollTop = prevTop + (logEl.scrollHeight - prevHeight);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Transient fetch failure; scrolling again retries.
|
||||||
|
} finally {
|
||||||
|
loadingOlder = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleOpen() {
|
||||||
|
open = !open;
|
||||||
|
if (open) {
|
||||||
|
await tick();
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both rollback (to a past event) and redo (to a greyed future event) are the same
|
||||||
|
// call — it just moves the head. It's reversible until someone takes a new action,
|
||||||
|
// so no confirm; the greyed future makes the state obvious.
|
||||||
|
async function rollback(event) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(
|
||||||
|
`/game/${state.game.id}/player/${state.player.id}/rollback`,
|
||||||
|
'POST',
|
||||||
|
{ checkpoint_id: event.checkpoint_id },
|
||||||
|
);
|
||||||
|
// The state-changed ping drives Dashboard to refetch the restored state.
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="floating-event-log {shown ? 'open' : ''} {inline ? 'inline' : ''}">
|
||||||
|
{#if toast && !shown}
|
||||||
|
{#key toast.id}
|
||||||
|
<button
|
||||||
|
class="event-toast log-kind-{toast.kind}"
|
||||||
|
title="Open the Event Log"
|
||||||
|
on:click={toggleOpen}
|
||||||
|
on:animationend={() => clearToast(toast.id)}
|
||||||
|
>
|
||||||
|
<span class="log-icon">{KIND_ICONS[toast.kind] || KIND_ICONS.info}</span>
|
||||||
|
<span class="toast-message">{toast.message}</span>
|
||||||
|
</button>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
{#if inline}
|
||||||
|
<div class="log-header">
|
||||||
|
📜 Event Log
|
||||||
|
<button class="log-hide-btn" title="Collapse the event log" on:click={() => dispatch('collapse')}>✕</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="toggle-log-btn" on:click={toggleOpen}>
|
||||||
|
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if shown}
|
||||||
|
{#if error}
|
||||||
|
<div class="log-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if head !== null}
|
||||||
|
<div class="log-rolledback">
|
||||||
|
↩ Rolled back — greyed entries are undone. Click one to redo, or take an action to make it permanent.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
|
||||||
|
{#if events.length}
|
||||||
|
<div class="log-top">
|
||||||
|
{#if loadingOlder}
|
||||||
|
<span class="log-top-note">Hauling up older entries…</span>
|
||||||
|
{:else if haveMore}
|
||||||
|
<button class="load-older-btn" on:click={loadOlder}>⬆️ Load earlier events</button>
|
||||||
|
{:else}
|
||||||
|
<span class="log-top-note">⚓ The story begins here</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#each events as event (event.id)}
|
||||||
|
{@const isFuture = head !== null && event.checkpoint_id > head}
|
||||||
|
<div class="log-entry log-kind-{event.kind || 'info'}" class:greyed={isFuture}>
|
||||||
|
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
|
||||||
|
<span class="log-message">{event.message}</span>
|
||||||
|
<span class="log-time">{formatTime(event.timestamp)}</span>
|
||||||
|
{#if canRollback && event.checkpoint_id > 0 && event.checkpoint_id !== currentPos}
|
||||||
|
<button
|
||||||
|
class="rollback-btn"
|
||||||
|
title={isFuture ? 'Redo forward to just after this event' : 'Roll the game back to just after this event'}
|
||||||
|
on:click={() => rollback(event)}
|
||||||
|
>{isFuture ? '↪' : '↩'}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="log-empty">No events yet.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !atBottom}
|
||||||
|
<button class="jump-to-bottom" on:click={scrollToBottom}>⬇️ Latest</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.floating-event-log {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 380px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 60vh;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.floating-event-log:not(.open) {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
/* Inline mode: pinned as the scene's third column instead of floating. The
|
||||||
|
top offset matches .dashboard-container's top padding (clears the fixed
|
||||||
|
corner menu) so the panel doesn't shift as the page scrolls; the height
|
||||||
|
fills to ~1rem above the viewport bottom, so the panel never runs past
|
||||||
|
the screen and its .log-content scrolls internally instead. */
|
||||||
|
.floating-event-log.inline {
|
||||||
|
position: sticky;
|
||||||
|
top: 3.5rem;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 4.5rem);
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.floating-event-log.inline {
|
||||||
|
position: static;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.log-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.log-hide-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 6px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.log-hide-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--text) 12%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.toggle-log-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
.toggle-log-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
|
}
|
||||||
|
.log-content {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.log-top {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 0 6px;
|
||||||
|
}
|
||||||
|
.log-top-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.load-older-btn {
|
||||||
|
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.load-older-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--text) 12%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.log-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: color-mix(in srgb, var(--text) 3%, transparent);
|
||||||
|
border-left: 3px solid var(--edge);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: color-mix(in srgb, var(--text) 85%, var(--text-muted));
|
||||||
|
}
|
||||||
|
.rollback-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.rollback-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.log-error {
|
||||||
|
margin: 6px 8px 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.log-rolledback {
|
||||||
|
margin: 6px 8px 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.log-entry.greyed {
|
||||||
|
opacity: 0.45;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.log-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.log-message {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.log-time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.log-empty {
|
||||||
|
padding: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.jump-to-bottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
.jump-to-bottom:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast preview of a fresh event, anchored above the button */
|
||||||
|
.event-toast {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
width: 320px;
|
||||||
|
max-width: 80vw;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
cursor: pointer;
|
||||||
|
transform-origin: bottom right;
|
||||||
|
animation: event-toast-into-button 3.4s ease-in forwards;
|
||||||
|
}
|
||||||
|
.event-toast .toast-message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
@keyframes event-toast-into-button {
|
||||||
|
0% { opacity: 0; transform: translateY(-6px) scale(0.96); }
|
||||||
|
8% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
75% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
100% { opacity: 0; transform: translateY(40px) scale(0.4); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.event-toast { animation-duration: 2.5s; animation-timing-function: linear; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent colors by event kind */
|
||||||
|
.log-kind-challenge { border-left-color: var(--danger); }
|
||||||
|
.log-kind-card { border-left-color: var(--deep); }
|
||||||
|
.log-kind-captain,
|
||||||
|
.log-kind-tax,
|
||||||
|
.log-kind-rank,
|
||||||
|
.log-kind-victory { border-left-color: var(--accent); }
|
||||||
|
.log-kind-scene,
|
||||||
|
.log-kind-phase { border-left-color: var(--pirat); }
|
||||||
|
.log-kind-joker,
|
||||||
|
.log-kind-warning { border-left-color: var(--warning); }
|
||||||
|
.log-kind-obstacle { border-left-color: var(--deep); }
|
||||||
|
.log-kind-join,
|
||||||
|
.log-kind-vote,
|
||||||
|
.log-kind-objective { border-left-color: var(--mystic); }
|
||||||
|
.log-kind-victory {
|
||||||
|
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
frontend/src/components/FaceCardAssigner.svelte
Normal file
79
frontend/src/components/FaceCardAssigner.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script>
|
||||||
|
// Drag-and-drop assignment of 3 techniques onto the J/Q/K face cards.
|
||||||
|
// Used during initial character creation and between-scenes recruit creation.
|
||||||
|
export let techniques = [];
|
||||||
|
export let jack = '';
|
||||||
|
export let queen = '';
|
||||||
|
export let king = '';
|
||||||
|
|
||||||
|
const slots = [
|
||||||
|
{ key: 'jack', letter: 'J', title: 'Jack' },
|
||||||
|
{ key: 'queen', letter: 'Q', title: 'Queen' },
|
||||||
|
{ key: 'king', letter: 'K', title: 'King' },
|
||||||
|
];
|
||||||
|
|
||||||
|
$: values = { jack, queen, king };
|
||||||
|
$: assigned = [jack, queen, king];
|
||||||
|
|
||||||
|
function set(key, val) {
|
||||||
|
if (key === 'jack') jack = val;
|
||||||
|
else if (key === 'queen') queen = val;
|
||||||
|
else king = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e, key) {
|
||||||
|
const tech = e.dataTransfer.getData('text/plain');
|
||||||
|
if (!tech) return;
|
||||||
|
for (const s of slots) {
|
||||||
|
if (s.key !== key && values[s.key] === tech) set(s.key, '');
|
||||||
|
}
|
||||||
|
set(key, tech);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="available-techniques-container">
|
||||||
|
<h4>Available Techniques</h4>
|
||||||
|
<p class="instruction-help">Drag a technique onto a card slot below to assign it.</p>
|
||||||
|
<div class="techniques-drag-pool">
|
||||||
|
{#each techniques as tech}
|
||||||
|
<div
|
||||||
|
draggable={!assigned.includes(tech)}
|
||||||
|
on:dragstart={(e) => e.dataTransfer.setData('text/plain', tech)}
|
||||||
|
class="draggable-tech-chip {assigned.includes(tech) ? 'assigned-hidden' : ''}"
|
||||||
|
>
|
||||||
|
<span class="drag-icon">⋮⋮</span> {tech}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="face-cards-slots-grid">
|
||||||
|
{#each slots as s}
|
||||||
|
<div class="face-card-slot-wrapper">
|
||||||
|
<div class="face-card-slot {values[s.key] ? 'has-assignment' : ''}"
|
||||||
|
on:dragover|preventDefault
|
||||||
|
on:drop={(e) => handleDrop(e, s.key)}>
|
||||||
|
<div class="card-bg-letter">{s.letter}</div>
|
||||||
|
<div class="card-slot-header">
|
||||||
|
<span class="rank">{s.letter}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-slot-body">
|
||||||
|
<div class="card-title">{s.title}</div>
|
||||||
|
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
||||||
|
<div class="assigned-tech-content">
|
||||||
|
{#if values[s.key]}
|
||||||
|
<div class="assigned-tech-chip">
|
||||||
|
{values[s.key]}
|
||||||
|
<span class="remove-tech-btn" on:click={() => set(s.key, '')}>×</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-slot-footer">
|
||||||
|
<span></span>
|
||||||
|
<span class="rank">{s.letter}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
<script>
|
<script>
|
||||||
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>
|
||||||
@@ -27,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>{p.name}</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,775 +1,79 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../lib/api';
|
import Card from './Card.svelte';
|
||||||
|
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||||
|
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||||
|
import CrewColumn from './scene/CrewColumn.svelte';
|
||||||
|
import EventLog from './EventLog.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
let playingCard = false;
|
// The inline Event Log can be toggled from a fixed corner button to declutter.
|
||||||
let error = '';
|
let logOpen = true;
|
||||||
|
|
||||||
// Helpers
|
|
||||||
$: 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;
|
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
||||||
$: isCaptain = state.game.captain_player_id === state.player.id;
|
$: isDeep = state.player.role === 'deep';
|
||||||
$: challenges = state.challenges || [];
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
$: openChallenges = challenges.filter(c => c.status === 'open');
|
// The Challenge area is shown when something is happening there, or to the Deep
|
||||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
// (who calls Challenges and ends the scene from it).
|
||||||
$: myOpenChallenge = openChallenges.find(c => c.acting_player_id === state.player.id) || null;
|
$: showChallengeArea = isDeep || openChallenges.length > 0;
|
||||||
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
|
|
||||||
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
|
|
||||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
|
||||||
|
|
||||||
function playerName(id) {
|
|
||||||
return state.players.find(p => p.id === id)?.name || '???';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isJoker(code) {
|
|
||||||
return !!code && code.startsWith('Joker');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playCard(obstacleId, cardCode) {
|
|
||||||
playingCard = true;
|
|
||||||
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;
|
|
||||||
} finally {
|
|
||||||
playingCard = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playJoker(obstacleId, cardCode) {
|
|
||||||
playingCard = true;
|
|
||||||
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;
|
|
||||||
} finally {
|
|
||||||
playingCard = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function endScene() {
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
|
||||||
} catch(e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearObstacle(obstacleId) {
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
|
|
||||||
} catch(e) {
|
|
||||||
console.error(e);
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleObjective(targetPlayerId, type) {
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', {
|
|
||||||
type: type
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Challenge controls (Deep) ---
|
|
||||||
let challengeTargetId = '';
|
|
||||||
let challengeStakes = '';
|
|
||||||
let selectedObstacles = {};
|
|
||||||
|
|
||||||
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 resolveChallenge(challengeId) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/resolve`, 'POST');
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelChallenge(challengeId) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/cancel`, 'POST');
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Captain controls (Deep / current Captain) ---
|
|
||||||
let captainSelectId = '';
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Gat/Name Tax ---
|
|
||||||
let taxTargetId = '';
|
|
||||||
function taxType(player) {
|
|
||||||
if (!player.completed_personal_1) return 'Gat';
|
|
||||||
if (!player.completed_personal_2) return 'Name';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function eligibleTaxTargets(challenge) {
|
|
||||||
const me = state.player;
|
|
||||||
const type = taxType(me);
|
|
||||||
if (!type) return [];
|
|
||||||
return scenePirats.filter(p =>
|
|
||||||
p.id !== me.id && !p.is_dead &&
|
|
||||||
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
async function requestTax(challengeId) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/request`, 'POST', {
|
|
||||||
target_player_id: taxTargetId
|
|
||||||
});
|
|
||||||
taxTargetId = '';
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function respondTax(challengeId, accept) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/respond`, 'POST', {
|
|
||||||
accept: accept ? 'true' : 'false'
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Pi-Rat vs Pi-Rat duels ---
|
|
||||||
let pvpOpponentId = '';
|
|
||||||
let pvpCard = '';
|
|
||||||
async function createPvp() {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
|
|
||||||
defender_id: pvpOpponentId,
|
|
||||||
card_code: pvpCard
|
|
||||||
});
|
|
||||||
pvpOpponentId = '';
|
|
||||||
pvpCard = '';
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function pvpDefend(challengeId, cardCode) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/pvp/defend`, 'POST', {
|
|
||||||
card_code: cardCode
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let showEventLog = false;
|
|
||||||
|
|
||||||
let newNameInput = '';
|
|
||||||
async function submitNewName() {
|
|
||||||
if (!newNameInput.trim()) return;
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
|
|
||||||
new_name: newNameInput.trim()
|
|
||||||
});
|
|
||||||
newNameInput = '';
|
|
||||||
} catch(e) {
|
|
||||||
console.error(e);
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parsing card helpers
|
|
||||||
function getCardDisplay(code) {
|
|
||||||
if (!code) return "";
|
|
||||||
if (isJoker(code)) return "🃏 JOKER";
|
|
||||||
let rank = code.slice(0, -1);
|
|
||||||
let suit = code.slice(-1);
|
|
||||||
let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit;
|
|
||||||
return `${rank}${suitEmoji}`;
|
|
||||||
}
|
|
||||||
</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: {captain.name} (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;">
|
|
||||||
🌊 {p.name} (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} {p.name} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="alert alert-danger">{error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- OPEN CHALLENGES -->
|
|
||||||
{#each openChallenges as ch}
|
|
||||||
{@const chPlays = JSON.parse(ch.plays || '[]')}
|
|
||||||
{@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 style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
|
||||||
<h4 style="margin: 0; font-family: var(--font-heading);">
|
|
||||||
{#if ch.challenge_type === 'pvp'}
|
|
||||||
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
|
|
||||||
{:else}
|
|
||||||
⚔️ The Deep challenges {playerName(ch.target_player_id)}!
|
|
||||||
{/if}
|
|
||||||
</h4>
|
|
||||||
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
|
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
|
||||||
<button class="btn btn-primary" on:click={() => resolveChallenge(ch.id)} disabled={ch.tax_state === 'requested'}>Resolve</button>
|
|
||||||
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if ch.stakes}
|
|
||||||
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
|
|
||||||
{/if}
|
|
||||||
{#if ch.challenge_type === 'pvp'}
|
|
||||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
|
||||||
Temporary Obstacle: <strong>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
|
||||||
Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'}
|
|
||||||
— 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)` : ''}.
|
|
||||||
Other Pi-Rats may assist (assistants don't draw back).
|
|
||||||
</p>
|
|
||||||
{/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 -->
|
|
||||||
{#if ch.tax_state === 'requested'}
|
|
||||||
{#if myTaxRequest && myTaxRequest.id === ch.id}
|
|
||||||
<div style="margin-top: 0.75rem; padding: 0.75rem; border: 1px dashed var(--gold); border-radius: 6px;">
|
|
||||||
<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 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;">
|
|
||||||
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
|
|
||||||
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="info-text gold-text" style="margin-top: 0.5rem;">⏳ Waiting for {playerName(ch.tax_target_id)} to answer the {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax...</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Tax: option for the challenged player -->
|
|
||||||
{#if ch.challenge_type === 'deep' && ch.acting_player_id === state.player.id && !ch.tax_state && !state.player.tax_banned && taxType(state.player) && eligibleTaxTargets(ch).length > 0}
|
|
||||||
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
|
||||||
<span class="info-text" style="font-size: 0.9rem;">No {taxType(state.player)}? Make it someone else's problem:</span>
|
|
||||||
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
|
|
||||||
<option value="">Pick a crewmate...</option>
|
|
||||||
{#each eligibleTaxTargets(ch) as p}
|
|
||||||
<option value={p.id}>{p.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- PvP: defender picks a card -->
|
|
||||||
{#if myPvpDefense && myPvpDefense.id === ch.id}
|
|
||||||
<div style="margin-top: 0.75rem;">
|
|
||||||
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
|
||||||
{#each hand.filter(c => !isJoker(c)) as card}
|
|
||||||
<button class="btn btn-secondary" on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#each state.obstacles as obs}
|
|
||||||
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
|
|
||||||
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card}
|
|
||||||
{@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;">
|
|
||||||
<div class="card-medium suit-{active_card_code.slice(-1).toLowerCase()}" title="Active Card: {getCardDisplay(active_card_code)}">
|
|
||||||
<div class="card-corner top-left">
|
|
||||||
<span class="val">{active_card_code.slice(0, -1)}</span>
|
|
||||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-center">
|
|
||||||
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-corner bottom-right">
|
|
||||||
<span class="val">{active_card_code.slice(0, -1)}</span>
|
|
||||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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((column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card).slice(0, -1))}
|
|
||||||
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">
|
|
||||||
<div class="card-mini base-card">
|
|
||||||
<span class="val">{obs.original_card.slice(0, -1)}</span>
|
|
||||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[obs.original_card.slice(-1)]}</span>
|
|
||||||
</div>
|
|
||||||
{#each column_cards as pc}
|
|
||||||
<div class="card-mini played-card rotated {pc.success ? 'success' : 'failure'}"
|
|
||||||
title="{pc.player_name} played {getCardDisplay(pc.card)}">
|
|
||||||
<span class="val">{pc.card.slice(0, -1)}</span>
|
|
||||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
|
|
||||||
<span class="owner">{pc.player_name.slice(0,4)}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if is_completed && state.player.role === 'deep'}
|
|
||||||
<div class="text-center" style="margin-top: 1rem;">
|
|
||||||
<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}
|
|
||||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
</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}
|
||||||
<!-- DEEP CONTROLS: Challenges, End Scene & Objectives -->
|
<div class="log-column">
|
||||||
{#if state.player.role === "deep"}
|
<EventLog {state} inline on:collapse={() => (logOpen = false)} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 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}>{p.name} (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}>{p.name}</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 state.players.filter(p => p.role === 'pirat') as p}
|
|
||||||
<div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
|
|
||||||
<strong>{p.name}</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>
|
|
||||||
{/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}
|
|
||||||
<div class="card-large suit-{isJoker(card) ? 'joker' : card.slice(-1).toLowerCase()} {isJoker(card) ? 'joker-card' : ''}"
|
|
||||||
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");
|
|
||||||
}}>
|
|
||||||
<div class="card-corner top-left">
|
|
||||||
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
|
|
||||||
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-center">
|
|
||||||
{#if isJoker(card)}
|
|
||||||
🃏
|
|
||||||
{:else}
|
|
||||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-tech-overlay">
|
|
||||||
{#if !isJoker(card) && card.slice(0, -1) === "J"}
|
|
||||||
<div class="tech-tag" title="Automatic success when played">J: "{state.player.tech_jack}"</div>
|
|
||||||
{:else if !isJoker(card) && card.slice(0, -1) === "Q"}
|
|
||||||
<div class="tech-tag" title="Automatic success when played">Q: "{state.player.tech_queen}"</div>
|
|
||||||
{:else if !isJoker(card) && card.slice(0, -1) === "K"}
|
|
||||||
<div class="tech-tag" title="Automatic success when played">K: "{state.player.tech_king}"</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-corner bottom-right">
|
|
||||||
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
|
|
||||||
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Player Character Sheet Card -->
|
|
||||||
{#if state.player.role !== "deep"}
|
|
||||||
<div class="card glass-panel sheet-card">
|
|
||||||
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
|
||||||
|
|
||||||
<div class="character-sheet-details">
|
|
||||||
{#if state.player.is_dead}
|
|
||||||
<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;">
|
|
||||||
<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>
|
|
||||||
<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 state.player.is_ghost}
|
|
||||||
<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;">
|
|
||||||
<h4 style="color: #a0b0c0; margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">👻 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 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 state.player.tax_banned}
|
|
||||||
<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;">
|
|
||||||
<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> {state.player.avatar_look}</p>
|
|
||||||
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
|
|
||||||
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
|
||||||
<ul class="techniques-list-sheet">
|
|
||||||
<li><strong>Jack (J):</strong> "{state.player.tech_jack}"</li>
|
|
||||||
<li><strong>Queen (Q):</strong> "{state.player.tech_queen}"</li>
|
|
||||||
<li><strong>King (K):</strong> "{state.player.tech_king}"</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Duel another Pi-Rat -->
|
|
||||||
{#if !state.player.is_dead && scenePirats.filter(p => p.id !== state.player.id && !p.is_dead).length > 0}
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4 style="color: var(--gold);">⚔️ Duel a Pi-Rat</h4>
|
|
||||||
<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>
|
|
||||||
<select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
|
|
||||||
<option value="">Challenge whom?</option>
|
|
||||||
{#each scenePirats.filter(p => p.id !== state.player.id && !p.is_dead) as p}
|
|
||||||
<option value={p.id}>{p.name} (Rank {p.rank})</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
|
||||||
<option value="">Throw which card?</option>
|
|
||||||
{#each hand.filter(c => !isJoker(c)) as card}
|
|
||||||
<option value={card}>{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">
|
|
||||||
<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} disabled>
|
|
||||||
<span>Steal a Ship</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
|
|
||||||
<span>Choose a Captain</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
|
|
||||||
<span>Commit Piracy</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4 style="color: var(--gold);">Personal Objectives</h4>
|
|
||||||
<div class="objectives-checklist">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<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>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Floating Event Log -->
|
|
||||||
<div class="floating-event-log {showEventLog ? 'open' : ''}">
|
|
||||||
<button class="toggle-log-btn" on:click={() => showEventLog = !showEventLog}>
|
|
||||||
{showEventLog ? '⬇️ Hide Log' : '📜 Event Log'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if showEventLog}
|
|
||||||
<div class="log-content font-mono text-sm space-y-2 p-2">
|
|
||||||
{#each state.events as event}
|
|
||||||
<div class="p-2 border-l-2 border-gray-600 bg-black text-gray-400" style="border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 0.25rem;">
|
|
||||||
{event.message}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="p-2 text-gray-500">No events yet.</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<button
|
||||||
.floating-event-log {
|
class="log-reopen-btn"
|
||||||
position: fixed;
|
title={logOpen ? 'Hide the event log' : 'Show the event log'}
|
||||||
bottom: 20px;
|
on:click={() => (logOpen = !logOpen)}
|
||||||
right: 20px;
|
>
|
||||||
width: 350px;
|
{logOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||||
background: rgba(10, 15, 25, 0.95);
|
</button>
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 60vh;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
.toggle-log-btn {
|
|
||||||
background: var(--dark-bg, #1a1a2e);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
border-radius: 8px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
.toggle-log-btn:hover {
|
|
||||||
background: rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
.log-content {
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 10px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.floating-event-log:not(.open) {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -40,21 +40,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: 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">
|
||||||
<h2>Scene Setup (Scene #{state.game.current_scene_number})</h2>
|
<h2>Scene Setup (Scene #{state.game.current_scene_number})</h2>
|
||||||
<p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p>
|
<p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p>
|
||||||
|
|
||||||
<div class="setup-grid">
|
{#if state.game.current_scene_number === 1}
|
||||||
|
<div class="notice-banner accent">
|
||||||
|
<h4>🚢 First Scene Framing</h4>
|
||||||
|
<p class="info-text" style="margin: 0; font-size: 0.95rem;">
|
||||||
|
The first scene must establish that the Pi-Rats are <strong>on a ship they can steal</strong> (or have an immediate opportunity to steal one). <strong>Don't start far from water!</strong>
|
||||||
|
{#if state.player.role === 'deep'}
|
||||||
|
<br/><span class="text-accent">You're playing the Deep — once the scene starts, describe where the crew is and what ship is ripe for the taking before calling any Challenges.</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="setup-grid single">
|
||||||
<!-- Role Selection Card -->
|
<!-- 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')}
|
||||||
@@ -70,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">{p.name} <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">
|
||||||
@@ -118,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,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { getSuggestion } from '../lib/suggestions';
|
import { displayName } from '../lib/cards';
|
||||||
|
import Card from './Card.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
@@ -12,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');
|
||||||
@@ -36,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 {
|
||||||
@@ -45,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) {
|
||||||
@@ -180,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>
|
||||||
@@ -256,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}>{p.name} (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>
|
||||||
@@ -278,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">{p.name}</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.
|
||||||
@@ -319,28 +272,10 @@
|
|||||||
<h4 class="gold-text">Hand (Keep)</h4>
|
<h4 class="gold-text">Hand (Keep)</h4>
|
||||||
<div class="upkeep-flex">
|
<div class="upkeep-flex">
|
||||||
{#each keepCards as card}
|
{#each keepCards as card}
|
||||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}"
|
<Card {card} draggable={true} style="cursor: pointer;"
|
||||||
draggable="true"
|
title="Click or drag to discard"
|
||||||
on:dragstart={(e) => handleDragStart(e, card)}
|
on:dragstart={(e) => handleDragStart(e, card)}
|
||||||
on:click={() => moveToDiscard(card)}
|
on:click={() => moveToDiscard(card)} />
|
||||||
style="cursor: pointer;"
|
|
||||||
title="Click or drag to discard">
|
|
||||||
<div class="card-corner top-left">
|
|
||||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
|
||||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-center">
|
|
||||||
{#if card.startsWith('Joker')}
|
|
||||||
🃏
|
|
||||||
{:else}
|
|
||||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="card-corner bottom-right">
|
|
||||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
|
||||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty-text text-center w-full">No cards in hand.</p>
|
<p class="empty-text text-center w-full">No cards in hand.</p>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -354,28 +289,10 @@
|
|||||||
<h4 class="gold-text">Discard Pile</h4>
|
<h4 class="gold-text">Discard Pile</h4>
|
||||||
<div class="upkeep-flex">
|
<div class="upkeep-flex">
|
||||||
{#each discardCards as card}
|
{#each discardCards as card}
|
||||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}"
|
<Card {card} draggable={true} style="cursor: pointer;"
|
||||||
draggable="true"
|
title="Click or drag to keep"
|
||||||
on:dragstart={(e) => handleDragStart(e, card)}
|
on:dragstart={(e) => handleDragStart(e, card)}
|
||||||
on:click={() => moveToKeep(card)}
|
on:click={() => moveToKeep(card)} />
|
||||||
style="cursor: pointer;"
|
|
||||||
title="Click or drag to keep">
|
|
||||||
<div class="card-corner top-left">
|
|
||||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
|
||||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-center">
|
|
||||||
{#if card.startsWith('Joker')}
|
|
||||||
🃏
|
|
||||||
{:else}
|
|
||||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="card-corner bottom-right">
|
|
||||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
|
||||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty-text text-center w-full">Drag cards here to discard them.</p>
|
<p class="empty-text text-center w-full">Drag cards here to discard them.</p>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -384,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}
|
||||||
|
|
||||||
@@ -395,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>{p.name}:</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">
|
||||||
@@ -428,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}
|
||||||
@@ -447,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>
|
||||||
|
|||||||
225
frontend/src/components/scene/ChallengePanel.svelte
Normal file
225
frontend/src/components/scene/ChallengePanel.svelte
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltipHtml, obstacleTable } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
|
import ObstacleItem from './ObstacleItem.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let error = '';
|
||||||
|
let taxTargetId = '';
|
||||||
|
// Deep: call-a-challenge form (moved here from the old Deep Control Panel)
|
||||||
|
let showCreate = false;
|
||||||
|
let challengeTargetId = '';
|
||||||
|
let stakesSuccess = '';
|
||||||
|
let stakesFailure = '';
|
||||||
|
let selectedObstacles = {};
|
||||||
|
|
||||||
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||||
|
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
|
||||||
|
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
|
||||||
|
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
|
||||||
|
function playerName(id) {
|
||||||
|
return lookupName(state.players, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function taxType(player) {
|
||||||
|
if (!player.completed_personal_1) return 'Gat';
|
||||||
|
if (!player.completed_personal_2) return 'Name';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eligibleTaxTargets() {
|
||||||
|
const type = taxType(state.player);
|
||||||
|
if (!type) return [];
|
||||||
|
return scenePirats.filter(p =>
|
||||||
|
p.id !== state.player.id && !p.is_dead &&
|
||||||
|
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, data = null) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/${path}`, 'POST', data);
|
||||||
|
return true;
|
||||||
|
} catch(e) {
|
||||||
|
error = e.message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveChallenge = (id) => post(`challenge/${id}/resolve`);
|
||||||
|
const cancelChallenge = (id) => post(`challenge/${id}/cancel`);
|
||||||
|
const respondTax = (id, accept) => post(`challenge/${id}/tax/respond`, { accept: accept ? 'true' : 'false' });
|
||||||
|
const pvpDefend = (id, cardCode) => post(`challenge/${id}/pvp/defend`, { card_code: cardCode });
|
||||||
|
|
||||||
|
async function requestTax(challengeId) {
|
||||||
|
if (await post(`challenge/${challengeId}/tax/request`, { target_player_id: taxTargetId })) {
|
||||||
|
taxTargetId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createChallenge() {
|
||||||
|
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
|
||||||
|
if (await post('challenge/create', {
|
||||||
|
target_player_id: challengeTargetId,
|
||||||
|
obstacle_ids: JSON.stringify(ids),
|
||||||
|
stakes_success: stakesSuccess,
|
||||||
|
stakes_failure: stakesFailure,
|
||||||
|
})) {
|
||||||
|
challengeTargetId = '';
|
||||||
|
stakesSuccess = '';
|
||||||
|
stakesFailure = '';
|
||||||
|
selectedObstacles = {};
|
||||||
|
showCreate = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endScene() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each openChallenges as ch}
|
||||||
|
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
|
||||||
|
<div class="challenge-item">
|
||||||
|
<div class="challenge-head">
|
||||||
|
<h4>
|
||||||
|
{#if ch.challenge_type === 'pvp'}
|
||||||
|
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
|
||||||
|
{:else}
|
||||||
|
⚔️ The Deep challenges {playerName(ch.target_player_id)}!
|
||||||
|
{/if}
|
||||||
|
</h4>
|
||||||
|
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
|
||||||
|
<div class="challenge-actions">
|
||||||
|
<button class="btn btn-primary" on:click={() => resolveChallenge(ch.id)} disabled={ch.tax_state === 'requested'}>Resolve</button>
|
||||||
|
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if ch.stakes_success}
|
||||||
|
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>✅ On success:</strong> {ch.stakes_success}</p>
|
||||||
|
{/if}
|
||||||
|
{#if ch.stakes_failure}
|
||||||
|
<p class="info-text" style="margin: 0.25rem 0 0 0;"><strong>❌ On failure:</strong> {ch.stakes_failure}</p>
|
||||||
|
{/if}
|
||||||
|
{#if ch.stakes && !ch.stakes_success && !ch.stakes_failure}
|
||||||
|
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
|
||||||
|
{/if}
|
||||||
|
{#if ch.challenge_type === 'pvp'}
|
||||||
|
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||||
|
Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable, true) }}>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="challenge-obstacles">
|
||||||
|
{#each chObstacleIds as oid}
|
||||||
|
{@const obs = state.obstacles.find(o => o.id === oid)}
|
||||||
|
{#if obs}
|
||||||
|
<ObstacleItem {state} {obs} embedded />
|
||||||
|
{:else}
|
||||||
|
<p class="info-text" style="margin: 0;">An applied Obstacle was discarded.</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Tax: pending request aimed at me -->
|
||||||
|
{#if ch.tax_state === 'requested'}
|
||||||
|
{#if myTaxRequest && myTaxRequest.id === ch.id}
|
||||||
|
<div class="tax-callout">
|
||||||
|
<p><strong>{playerName(ch.target_player_id)}</strong> calls a <strong>{ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax</strong> on you: take this Challenge for them!</p>
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">Accept: you get one random card from their hand and attempt the Challenge. Refuse: you hand over your {ch.tax_type === 'gat' ? 'Gat' : 'Name'} — they keep it if they succeed!</p>
|
||||||
|
<div class="challenge-actions">
|
||||||
|
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
|
||||||
|
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="info-text gold-text" style="margin-top: 0.5rem;">⏳ Waiting for {playerName(ch.tax_target_id)} to answer the {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax...</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tax: option for the challenged player -->
|
||||||
|
{#if ch.challenge_type === 'deep' && ch.acting_player_id === state.player.id && !ch.tax_state && !state.player.tax_banned && taxType(state.player) && eligibleTaxTargets().length > 0}
|
||||||
|
<div class="challenge-actions" style="margin-top: 0.75rem;">
|
||||||
|
<span class="info-text" style="font-size: 0.9rem; margin: 0;">No {taxType(state.player)}? Make it someone else's problem:</span>
|
||||||
|
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
|
||||||
|
<option value="">Pick a crewmate...</option>
|
||||||
|
{#each eligibleTaxTargets() as p}
|
||||||
|
<option value={p.id}>{displayName(p)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- PvP: defender picks a card -->
|
||||||
|
{#if myPvpDefense && myPvpDefense.id === ch.id}
|
||||||
|
<div style="margin-top: 0.75rem;">
|
||||||
|
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
||||||
|
<div class="challenge-actions">
|
||||||
|
{#each hand.filter(c => !isJoker(c)) as card}
|
||||||
|
<button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable, true) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Deep controls: call a Challenge, and end the scene when none is active -->
|
||||||
|
<!-- The Deep calls Challenges and ends the scene only when none is active. -->
|
||||||
|
{#if isDeep && openChallenges.length === 0}
|
||||||
|
<div class="deep-challenge-controls">
|
||||||
|
<button class="btn {showCreate ? 'btn-secondary' : 'btn-primary'} btn-full" on:click={() => showCreate = !showCreate}>
|
||||||
|
{showCreate ? '✕ Cancel' : '⚔️ Call a Challenge'}
|
||||||
|
</button>
|
||||||
|
{#if showCreate}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
|
||||||
|
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<option value="">Challenge which Pi-Rat?</option>
|
||||||
|
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||||
|
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<div style="margin-bottom: 0.5rem;">
|
||||||
|
{#each state.obstacles as obs}
|
||||||
|
{@const taken = challengedObstacleIds.has(obs.id)}
|
||||||
|
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
|
||||||
|
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
|
||||||
|
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<input type="text" class="input-field" placeholder="On success (optional): what they win…" bind:value={stakesSuccess} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<input type="text" class="input-field" placeholder="On failure (optional): what it costs them…" bind:value={stakesFailure} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<button class="btn btn-primary btn-full" on:click={createChallenge}
|
||||||
|
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
|
||||||
|
Call Challenge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="scene-upkeep-controls text-center">
|
||||||
|
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
|
||||||
|
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
|
||||||
|
End Scene & Proceed to Ranking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
213
frontend/src/components/scene/CharacterSheet.svelte
Normal file
213
frontend/src/components/scene/CharacterSheet.svelte
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
import { getCardDisplay, isJoker, displayName, crewLabel, captainName, cardObstacleInfo, pvpObstacleInfo, obstacleTable } from '../../lib/cards';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
export let target; // the player whose sheet is being viewed
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
function close() { dispatch('close'); }
|
||||||
|
|
||||||
|
let error = '';
|
||||||
|
let pvpCard = '';
|
||||||
|
|
||||||
|
$: viewer = state.player;
|
||||||
|
$: isSelf = target.id === viewer.id;
|
||||||
|
$: isDeep = viewer.role === 'deep';
|
||||||
|
$: isCaptain = target.id === state.game.captain_player_id;
|
||||||
|
// The Deep manages captaincy and ticks personal objectives from a Pi-Rat's sheet
|
||||||
|
// (both moved here from the old Deep Control Panel).
|
||||||
|
$: canManageCaptain = isDeep && target.role === 'pirat' && !target.is_dead;
|
||||||
|
$: canManageObjectives = isDeep && target.role === 'pirat';
|
||||||
|
// The viewer throws one of their OWN cards in a duel.
|
||||||
|
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
|
||||||
|
$: canDuel = !isSelf
|
||||||
|
&& viewer.role === 'pirat' && !viewer.is_dead && !viewer.needs_reroll
|
||||||
|
&& target.role === 'pirat' && !target.is_dead;
|
||||||
|
// What the chosen duel card becomes as a temporary Obstacle for the defender
|
||||||
|
$: pvpCardObstacle = pvpObstacleInfo(pvpCard);
|
||||||
|
|
||||||
|
function getPlayerName(id) {
|
||||||
|
if (!id) return 'a crewmate';
|
||||||
|
const p = state.players.find(pl => pl.id === id);
|
||||||
|
return p ? displayName(p) : 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPvp() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/challenge/pvp/create`, 'POST', {
|
||||||
|
defender_id: target.id,
|
||||||
|
card_code: pvpCard
|
||||||
|
});
|
||||||
|
pvpCard = '';
|
||||||
|
close();
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setCaptain(makeCaptain) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/set-captain`, 'POST', {
|
||||||
|
target_player_id: makeCaptain ? target.id : ''
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleObjective(type) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${target.id}/objective/toggle`, 'POST', { type });
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||||
|
|
||||||
|
<div class="modal-backdrop" on:click|self={close}>
|
||||||
|
<div class="modal-box sheet-modal character-sheet-modal glass-panel">
|
||||||
|
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
{#if target.role === 'deep'}🌊{:else if target.is_ghost}👻{:else if target.is_dead}💀{:else}🐀{/if}
|
||||||
|
{target.id === state.game.captain_player_id ? captainName(target.name) : target.name}'s Character Sheet
|
||||||
|
</h3>
|
||||||
|
{#if target.player_name && target.player_name !== target.name}
|
||||||
|
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
|
||||||
|
Played by {target.player_name}{#if !target.completed_personal_2} — they'll go by their smell until they earn a Name!{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="character-sheet-details">
|
||||||
|
<div class="sheet-main-column">
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isSelf && state.player.needs_reroll}
|
||||||
|
<div class="prompt-box accent text-center">
|
||||||
|
<h4>🐀 Recruit Incoming!</h4>
|
||||||
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You don't have a Pi-Rat in this scene. Sit back, enjoy the chaos, and you'll create your recruit with the crew between scenes.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isSelf && state.player.is_dead}
|
||||||
|
<div class="prompt-box danger text-center">
|
||||||
|
<h4>💀 You have Died / Retired!</h4>
|
||||||
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">Your story arc is complete. You will choose to become a Ghost or roll a new character during the upkeep phase between scenes.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isSelf && state.player.is_ghost}
|
||||||
|
<div class="prompt-box ghostly text-center">
|
||||||
|
<h4>👻 Playing as a Ghost</h4>
|
||||||
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You are in the Ghost World (grayscale view). You cannot interact well with the living but you can still help them resolve challenges!</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isSelf && state.player.tax_banned}
|
||||||
|
<div class="prompt-box danger text-center">
|
||||||
|
<p class="info-text" style="margin: 0; font-size: 0.9rem;">🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sheet-group">
|
||||||
|
<h4>Description:</h4>
|
||||||
|
<p><strong>Look:</strong> {target.avatar_look}</p>
|
||||||
|
<p><strong>Smell:</strong> {target.avatar_smell}</p>
|
||||||
|
<p><strong>First Words:</strong> "{target.first_words}"</p>
|
||||||
|
{#if target.gat_description}
|
||||||
|
<p><strong>🔫 Gat:</strong> {target.gat_description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if target.other_like || target.other_hate}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4>What the Crew Thinks:</h4>
|
||||||
|
{#if target.other_like}
|
||||||
|
<p><strong>👍 Likes:</strong> "{target.other_like}" <span class="text-muted">— {getPlayerName(target.other_like_from_player_id)}</span></p>
|
||||||
|
{/if}
|
||||||
|
{#if target.other_hate}
|
||||||
|
<p><strong>👎 Hates:</strong> "{target.other_hate}" <span class="text-muted">— {getPlayerName(target.other_hate_from_player_id)}</span></p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Secret techniques are visible only on your own sheet -->
|
||||||
|
{#if isSelf}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
||||||
|
<ul class="techniques-list-sheet">
|
||||||
|
<li><strong>Jack (J):</strong> "{target.tech_jack}"</li>
|
||||||
|
<li><strong>Queen (Q):</strong> "{target.tech_queen}"</li>
|
||||||
|
<li><strong>King (K):</strong> "{target.tech_king}"</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Duel this Pi-Rat (target is fixed to this sheet) -->
|
||||||
|
{#if canDuel}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4>⚔️ Duel {crewLabel(target, state.game.captain_player_id)}</h4>
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">Challenge them (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
|
||||||
|
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<option value="">Throw which card?</option>
|
||||||
|
{#each hand.filter(c => !isJoker(c)) as card}
|
||||||
|
<option value={card} title={pvpObstacleInfo(card) ? `${pvpObstacleInfo(card).title} — ${pvpObstacleInfo(card).description}` : ''}>{getCardDisplay(card)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if pvpCardObstacle}
|
||||||
|
<p class="info-text" style="font-size: 0.8rem; margin: -0.25rem 0 0.5rem 0;">
|
||||||
|
As an Obstacle, {getCardDisplay(pvpCard)} is <strong>{pvpCardObstacle.title}</strong>: {pvpCardObstacle.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpCard}>Throw Down!</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canManageCaptain}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4>🏴☠️ Captaincy</h4>
|
||||||
|
{#if isCaptain}
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">{target.name} holds the Captaincy until they step down, lose a duel, die, or the ship is lost.</p>
|
||||||
|
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(false)}>Remove as Captain</button>
|
||||||
|
{:else}
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">Hand this Pi-Rat the Captaincy (e.g. after a leadership duel or resignation).</p>
|
||||||
|
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(true)}>Make Captain</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div> <!-- end sheet-main-column -->
|
||||||
|
|
||||||
|
<div class="sheet-side-column">
|
||||||
|
<div class="sheet-group">
|
||||||
|
<h4>Personal Objectives</h4>
|
||||||
|
{#if canManageObjectives}
|
||||||
|
<p class="info-text" style="font-size: 0.8rem;">As the Deep, tick these off in sequence (1 → 2 → 3).</p>
|
||||||
|
{/if}
|
||||||
|
<div class="objectives-checklist">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={target.completed_personal_1} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_1')}>
|
||||||
|
<span>1. Gat</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={target.completed_personal_2} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_2')}>
|
||||||
|
<span>2. Name</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={target.completed_personal_3} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_3')}>
|
||||||
|
<span>3. Die/Retire</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> <!-- end sheet-side-column -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
115
frontend/src/components/scene/CrewColumn.svelte
Normal file
115
frontend/src/components/scene/CrewColumn.svelte
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script>
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
import { crewLabel } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
|
import CharacterSheet from './CharacterSheet.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let openTargetId = null;
|
||||||
|
let showObjectives = false;
|
||||||
|
let objError = '';
|
||||||
|
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
|
||||||
|
// Track which Pi-Rats have faced a Deep Challenge this scene so the Deep can
|
||||||
|
// see who still needs one before ending the scene.
|
||||||
|
$: challengedIds = new Set(
|
||||||
|
(state.challenges || [])
|
||||||
|
.filter(c => c.challenge_type === 'deep')
|
||||||
|
.map(c => c.target_player_id)
|
||||||
|
);
|
||||||
|
function hasBeenChallenged(p) {
|
||||||
|
return p.role === 'pirat' && challengedIds.has(p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Deep ticks Crew Objectives off here (moved from the old Deep panel).
|
||||||
|
async function toggleCrew(type) {
|
||||||
|
objError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/objective/toggle`, 'POST', { type });
|
||||||
|
} catch (e) {
|
||||||
|
objError = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pi-Rats first, the Deep last; otherwise keep join order.
|
||||||
|
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
||||||
|
$: captainId = state.game.captain_player_id;
|
||||||
|
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
||||||
|
$: crewDone = [state.game.completed_crew_1, state.game.completed_crew_2, state.game.completed_crew_3].filter(Boolean).length;
|
||||||
|
|
||||||
|
function handSize(p) {
|
||||||
|
try { return JSON.parse(p.hand_cards || '[]').length; } catch { return 0; }
|
||||||
|
}
|
||||||
|
function roleIcon(p) {
|
||||||
|
if (p.role === 'deep') return '🌊';
|
||||||
|
if (p.is_ghost) return '👻';
|
||||||
|
if (p.is_dead) return '💀';
|
||||||
|
return '🐀';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crew-column">
|
||||||
|
<div class="crew-bubbles">
|
||||||
|
{#each crew as p (p.id)}
|
||||||
|
<button
|
||||||
|
class="crew-bubble"
|
||||||
|
class:is-you={p.id === state.player.id}
|
||||||
|
class:is-deep={p.role === 'deep'}
|
||||||
|
class:is-dead={p.is_dead}
|
||||||
|
class:is-ghost={p.is_ghost}
|
||||||
|
title="View {p.name}'s character sheet"
|
||||||
|
on:click={() => openTargetId = p.id}
|
||||||
|
>
|
||||||
|
{#if isDeep && p.role === 'pirat'}
|
||||||
|
<span
|
||||||
|
class="challenge-check"
|
||||||
|
class:done={hasBeenChallenged(p)}
|
||||||
|
use:tooltip={hasBeenChallenged(p)
|
||||||
|
? `${p.name} has faced a Challenge this scene.`
|
||||||
|
: `${p.name} hasn't faced a Challenge yet — the scene can't end until they do (or the Obstacle List is cleared).`}
|
||||||
|
>{hasBeenChallenged(p) ? '✓' : '○'}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="bubble-icon">{roleIcon(p)}</span>
|
||||||
|
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||||
|
<span class="bubble-meta">
|
||||||
|
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Crew Objectives sits at the end of the roster and slides open in place -->
|
||||||
|
<button
|
||||||
|
class="crew-objectives-toggle"
|
||||||
|
aria-expanded={showObjectives}
|
||||||
|
on:click={() => showObjectives = !showObjectives}
|
||||||
|
>
|
||||||
|
<span class="co-label">{showObjectives ? '▾' : '▸'} Crew Objectives</span>
|
||||||
|
<span class="co-count">{crewDone}/3</span>
|
||||||
|
</button>
|
||||||
|
{#if showObjectives}
|
||||||
|
<div class="crew-objectives-detail" transition:slide>
|
||||||
|
{#if objError}<div class="alert alert-danger">{objError}</div>{/if}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_1} disabled={!isDeep} on:change={() => toggleCrew('crew_1')}>
|
||||||
|
<span>Steal a Ship</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_2} disabled={!isDeep} on:change={() => toggleCrew('crew_2')}>
|
||||||
|
<span>Choose a Captain</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_3} disabled={!isDeep} on:change={() => toggleCrew('crew_3')}>
|
||||||
|
<span>Commit Piracy</span>
|
||||||
|
</label>
|
||||||
|
{#if isDeep}<p class="info-text" style="font-size: 0.75rem; margin: 0.25rem 0 0;">As the Deep, tick these off as the crew earns them.</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if openTarget}
|
||||||
|
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
||||||
|
{/if}
|
||||||
35
frontend/src/components/scene/ObstacleBoard.svelte
Normal file
35
frontend/src/components/scene/ObstacleBoard.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script>
|
||||||
|
import ObstacleItem from './ObstacleItem.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||||
|
// When a Challenge is active, the involved Obstacles move up into the Challenge
|
||||||
|
// panel; the rest of the list collapses so attention stays on the Challenge.
|
||||||
|
$: challengeActive = openChallenges.length > 0;
|
||||||
|
$: looseObstacles = state.obstacles.filter(o => !challengedObstacleIds.has(o.id));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if challengeActive}
|
||||||
|
{#if looseObstacles.length}
|
||||||
|
<details class="obstacle-collapse">
|
||||||
|
<summary>🌊 {looseObstacles.length} other Obstacle{looseObstacles.length === 1 ? '' : 's'} on the list — show</summary>
|
||||||
|
<div class="obstacles-container">
|
||||||
|
{#each looseObstacles as obs (obs.id)}
|
||||||
|
<ObstacleItem {state} {obs} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text">All Obstacles are tied up in the Challenge above.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="obstacles-container">
|
||||||
|
{#each state.obstacles as obs (obs.id)}
|
||||||
|
<ObstacleItem {state} {obs} />
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text">No active obstacles. The Deep can end the scene!</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
112
frontend/src/components/scene/ObstacleItem.svelte
Normal file
112
frontend/src/components/scene/ObstacleItem.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
import { isJoker, cardValue } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
|
import Card from '../Card.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
export let obs;
|
||||||
|
export let embedded = false; // rendered inside the Challenge panel — skip the "in challenge" flag
|
||||||
|
|
||||||
|
// Plain (text-presentation) suit glyphs so they inherit the title's suit color,
|
||||||
|
// unlike SUIT_EMOJI which renders as fixed-color emoji.
|
||||||
|
const SUIT_CHAR = { C: '♣', S: '♠', H: '♥', D: '♦' };
|
||||||
|
const VALUE_HINT = 'The number to beat. Set by the most recent card played on the column — or the original card '
|
||||||
|
+ 'until one is played. (Color stays fixed to the original.)';
|
||||||
|
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
$: column_cards = JSON.parse(obs.played_cards || '[]');
|
||||||
|
$: active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card;
|
||||||
|
$: success_count = column_cards.filter(c => c.success).length;
|
||||||
|
$: is_completed = success_count >= state.players.length;
|
||||||
|
$: in_challenge = openChallenges.some(c => JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
|
||||||
|
$: cardCodeLabel = `${cardValue(obs.original_card)}${SUIT_CHAR[obs.suit] || ''}`;
|
||||||
|
$: is_face_active = ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1));
|
||||||
|
|
||||||
|
// A face-card Obstacle is worth the challenged Rat's Rank. When this Obstacle is
|
||||||
|
// part of an open Challenge we know who that is — return their rank, else null.
|
||||||
|
$: challengedRank = (() => {
|
||||||
|
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
|
||||||
|
if (!ch) return null;
|
||||||
|
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function post(path, data) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(path, 'POST', data);
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playCard = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-card`, { obstacle_id: obs.id, card_code: cardCode });
|
||||||
|
const playJoker = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-joker`, { obstacle_id: obs.id, card_code: cardCode });
|
||||||
|
const clearObstacle = () => post(`/game/${state.game.id}/scene/obstacle/${obs.id}/clear`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="obstacle-item suit-{obs.suit.toLowerCase()}"
|
||||||
|
class:completed={is_completed}
|
||||||
|
class:in-challenge={in_challenge && !embedded}
|
||||||
|
data-obstacle-id={obs.id}
|
||||||
|
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
||||||
|
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
||||||
|
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
|
||||||
|
on:drop={(e) => {
|
||||||
|
if (is_completed) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove("drag-over");
|
||||||
|
const cardCode = e.dataTransfer.getData('text/plain');
|
||||||
|
if (cardCode) {
|
||||||
|
if (isJoker(cardCode)) playJoker(cardCode);
|
||||||
|
else playCard(cardCode);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger" style="grid-column: 1 / -1;">{error}</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Card column: original at the back, each play stacked in front of it, the
|
||||||
|
latest fully visible and earlier cards peeking their rank + suit. -->
|
||||||
|
<div class="obstacle-stack">
|
||||||
|
<div class="stack-card is-original" use:tooltip={'Original card — fixes the Obstacle’s color and never changes.'}>
|
||||||
|
<Card card={obs.original_card} size="medium" />
|
||||||
|
</div>
|
||||||
|
{#each column_cards as pc, i (i)}
|
||||||
|
<div class="stack-card {pc.success ? 'is-success' : 'is-failure'}"
|
||||||
|
class:is-latest={i === column_cards.length - 1}
|
||||||
|
use:tooltip={`${pc.player_name} played this — ${pc.success ? 'success' : 'no success'}.`}>
|
||||||
|
<Card card={pc.card} size="medium" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="obstacle-details">
|
||||||
|
<h4><span class="obs-card-code">{cardCodeLabel}</span> — {obs.title} {#if in_challenge && !embedded}<span class="in-challenge-tag">⚔️ In Challenge</span>{/if}</h4>
|
||||||
|
<p class="desc">{obs.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Value Display -->
|
||||||
|
<div class="obstacle-value-display text-center" use:tooltip={VALUE_HINT}>
|
||||||
|
<span class="val-label">Current Difficulty</span>
|
||||||
|
<span class="val-number">
|
||||||
|
{#if is_face_active}
|
||||||
|
{challengedRank !== null ? `${challengedRank} (Rat's Rank)` : "Challenged Rat's Rank"}
|
||||||
|
{:else}
|
||||||
|
{obs.current_value}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Successes Tracker -->
|
||||||
|
<div class="obstacle-successes-display text-center">
|
||||||
|
<span class="val-label">Successes</span>
|
||||||
|
<span class="val-number">{success_count} / {state.players.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if is_completed && state.player.role === 'deep'}
|
||||||
|
<div class="clear-obstacle-row text-center">
|
||||||
|
<button class="btn btn-success btn-full" on:click={clearObstacle}>Clear Obstacle</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<script>
|
|
||||||
let count = $state(0)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>
|
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
165
frontend/src/lib/cards.js
Normal file
165
frontend/src/lib/cards.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// Card-code helpers shared across components.
|
||||||
|
// Codes look like "10D", "KH", "Joker1" (black) / "Joker2" (red).
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { apiRequest } from './api';
|
||||||
|
|
||||||
|
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
|
||||||
|
|
||||||
|
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
|
||||||
|
const SUIT_THEMES = {
|
||||||
|
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
|
||||||
|
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
|
||||||
|
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
|
||||||
|
H: "♥ Hearts — Shoutin' and Singin': social skills, performance, or charisma",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The rulebook's obstacle tables (what each card represents when it surfaces
|
||||||
|
// as an Obstacle), served by the backend so there's a single source of truth.
|
||||||
|
// Loaded once at startup; until it arrives tooltips fall back to suit themes.
|
||||||
|
let OBSTACLE_TABLE = null;
|
||||||
|
export const obstacleTable = writable(null);
|
||||||
|
export async function loadObstacleTable(retries = 5) {
|
||||||
|
// Static rulebook data; retry on transient failures so a single blip on
|
||||||
|
// first load doesn't silently disable obstacle tooltips until a refresh.
|
||||||
|
while (!OBSTACLE_TABLE && retries-- > 0) {
|
||||||
|
try {
|
||||||
|
OBSTACLE_TABLE = (await apiRequest('/obstacles')).obstacles;
|
||||||
|
obstacleTable.set(OBSTACLE_TABLE);
|
||||||
|
} catch (e) {
|
||||||
|
if (retries <= 0) {
|
||||||
|
console.error('Failed to load obstacle table', e);
|
||||||
|
} else {
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OBSTACLE_TABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a card represents when it surfaces as an Obstacle, or null if unknown
|
||||||
|
// (Jokers / table not loaded). Pass the obstacleTable store value as `table`.
|
||||||
|
export function cardObstacleInfo(code, table = OBSTACLE_TABLE) {
|
||||||
|
if (!code || isJoker(code)) return null;
|
||||||
|
return table?.[cardSuit(code)]?.[cardValue(code)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default tooltip for a card: its suit theme when played, plus what it
|
||||||
|
// represents as an Obstacle (relevant when challenging another Pi-Rat).
|
||||||
|
// Pass the obstacleTable store's value as `table` to recompute reactively.
|
||||||
|
// Plain-string form, kept for aria-label / native fallbacks.
|
||||||
|
export function cardTooltip(code, table = OBSTACLE_TABLE) {
|
||||||
|
if (!code) return '';
|
||||||
|
if (isJoker(code)) {
|
||||||
|
return '🃏 Joker — played: discards an Obstacle (and its column) outright and draws a replacement.\n'
|
||||||
|
+ 'As an Obstacle: discarded, but future scenes permanently require one more Obstacle.';
|
||||||
|
}
|
||||||
|
const theme = SUIT_THEMES[cardSuit(code)] || '';
|
||||||
|
const obstacle = table?.[cardSuit(code)]?.[cardValue(code)];
|
||||||
|
if (!obstacle) return theme;
|
||||||
|
return `${theme}\nAs an Obstacle: ${obstacle.title} — ${obstacle.description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACE_VALUES = ['J', 'Q', 'K'];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rich (HTML) tooltip describing a card — what it does when *played*, and what
|
||||||
|
// it means when it surfaces as an Obstacle. Built only from static rulebook
|
||||||
|
// data (SUIT_THEMES + the obstacle table), so the HTML is trusted. Feed the
|
||||||
|
// result to the `tooltip` action as `{ html: cardTooltipHtml(...) }`.
|
||||||
|
export function cardTooltipHtml(code, table = OBSTACLE_TABLE, isPvp = false) {
|
||||||
|
if (!code) return '';
|
||||||
|
if (isJoker(code)) {
|
||||||
|
return '<div class="tt-head"><span class="tt-card tt-joker">🃏 Joker</span></div>'
|
||||||
|
+ '<div class="tt-row"><span class="tt-label">Played</span>Discard one Obstacle (and its column) outright, then draw a replacement.</div>'
|
||||||
|
+ '<div class="tt-row"><span class="tt-label">As an Obstacle</span>Discarded — but every following scene needs one more Obstacle.</div>';
|
||||||
|
}
|
||||||
|
const suit = cardSuit(code);
|
||||||
|
const val = cardValue(code);
|
||||||
|
const isRed = suit === 'H' || suit === 'D';
|
||||||
|
const colorClass = isRed ? 'tt-red' : 'tt-black';
|
||||||
|
let html = `<div class="tt-head"><span class="tt-card ${colorClass}">${esc(val)}${SUIT_EMOJI[suit] || esc(suit)}</span>`
|
||||||
|
+ `<span class="tt-color tt-color-${isRed ? 'red' : 'black'}">${isRed ? 'Red' : 'Black'}</span></div>`;
|
||||||
|
html += `<div class="tt-theme">${esc(SUIT_THEMES[suit] || '')}</div>`;
|
||||||
|
|
||||||
|
if (isPvp) {
|
||||||
|
const themeStr = SUIT_THEMES[suit] || '';
|
||||||
|
const splitIdx = themeStr.indexOf(': ');
|
||||||
|
const title = splitIdx !== -1 ? themeStr.substring(0, splitIdx) : themeStr;
|
||||||
|
const desc = splitIdx !== -1 ? themeStr.substring(splitIdx + 2) : '';
|
||||||
|
html += `<div class="tt-row"><span class="tt-label">PvP Obstacle</span><b>${esc(title)}</b> — ${esc(desc)}</div>`;
|
||||||
|
} else if (FACE_VALUES.includes(val)) {
|
||||||
|
html += '<div class="tt-row"><span class="tt-label">Played by a Pi-Rat</span>Triggers a Secret Technique — automatic success.</div>';
|
||||||
|
html += "<div class=\"tt-row\"><span class=\"tt-label\">As an Obstacle</span>Its value equals the challenged Pi-Rat's Rank.</div>";
|
||||||
|
} else {
|
||||||
|
const obstacle = table?.[suit]?.[val];
|
||||||
|
if (obstacle) {
|
||||||
|
html += `<div class="tt-row"><span class="tt-label">As an Obstacle</span><b>${esc(obstacle.title)}</b> — ${esc(obstacle.description)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pvpObstacleInfo(code) {
|
||||||
|
if (!code || isJoker(code)) return null;
|
||||||
|
const suit = cardSuit(code);
|
||||||
|
const themeStr = SUIT_THEMES[suit];
|
||||||
|
if (!themeStr) return null;
|
||||||
|
const splitIdx = themeStr.indexOf(': ');
|
||||||
|
if (splitIdx === -1) return { title: themeStr, description: '' };
|
||||||
|
return {
|
||||||
|
title: themeStr.substring(0, splitIdx),
|
||||||
|
description: themeStr.substring(splitIdx + 2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJoker(code) {
|
||||||
|
return !!code && code.startsWith('Joker');
|
||||||
|
}
|
||||||
|
|
||||||
|
// "10D" -> "10"
|
||||||
|
export function cardValue(code) {
|
||||||
|
return code.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "10D" -> "D"
|
||||||
|
export function cardSuit(code) {
|
||||||
|
return code.slice(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCardDisplay(code) {
|
||||||
|
if (!code) return '';
|
||||||
|
if (isJoker(code)) return '🃏 JOKER';
|
||||||
|
return `${cardValue(code)}${SUIT_EMOJI[cardSuit(code)] || cardSuit(code)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Recruit Cheese (Tim)" — the Pi-Rat's identity with the human player's lobby
|
||||||
|
// name in parentheses (omitted while the two are still identical, e.g. in the lobby).
|
||||||
|
export function displayName(p) {
|
||||||
|
if (!p) return '???';
|
||||||
|
if (p.player_name && p.player_name !== p.name) return `${p.name} (${p.player_name})`;
|
||||||
|
return p.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playerName(players, id) {
|
||||||
|
return displayName(players.find(p => p.id === id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Pi-Rat's name with the "Recruit" honorific swapped for "Captain" (or
|
||||||
|
// "Captain" prepended once they've earned a real Name). Used to mark the Captain
|
||||||
|
// in the scene roster without a separate icon.
|
||||||
|
export function captainName(name) {
|
||||||
|
if (!name) return 'Captain';
|
||||||
|
if (name.startsWith('Recruit ')) return 'Captain ' + name.slice('Recruit '.length);
|
||||||
|
return 'Captain ' + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayName, but the Captain is shown as "Captain <name> (player)".
|
||||||
|
export function crewLabel(p, captainId) {
|
||||||
|
if (!p) return '???';
|
||||||
|
const rat = p.id === captainId ? captainName(p.name) : p.name;
|
||||||
|
if (p.player_name && p.player_name !== p.name) return `${rat} (${p.player_name})`;
|
||||||
|
return rat;
|
||||||
|
}
|
||||||
160
frontend/src/lib/changelog.js
Normal file
160
frontend/src/lib/changelog.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// App version and player-facing changelog, surfaced in the ☰ menu → About
|
||||||
|
// (rendered by AboutModal.svelte).
|
||||||
|
//
|
||||||
|
// Maintenance (see AGENTS.md "Versioning & changelog"):
|
||||||
|
// - Bump VERSION by one on every commit.
|
||||||
|
// - Add a CHANGELOG entry only when a commit changes something players can
|
||||||
|
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
||||||
|
|
||||||
|
export const VERSION = 25;
|
||||||
|
|
||||||
|
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
||||||
|
export const CHANGELOG = [
|
||||||
|
{
|
||||||
|
version: 25,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Event Log now uses the same pinned column and fixed close/reopen control in every game phase.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 24,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The crew roster now stays in the same left-hand column throughout every phase of the game.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 23,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Admins can now click the join code to copy it, and the code is available directly from the lobby.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 21,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Player lists now use the same easy-to-scan vertical layout in every phase and at every screen size.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 20,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Admin Panel now gives the game’s join code a large, easy-to-share display.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 19,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Games now have short join codes that can be entered on the home page and shared from the Admin Panel.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 18,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Expired rejoin entries are now removed automatically when a game has ended or a player is no longer in the crew, with a clear explanation.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 17,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Join forms now remember your player name for the next crew while still letting you edit it.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 16,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Open and Copy controls in the Admin Panel now have matching sizes.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 15,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Admin Panel now clearly marks Pi-Rats whose character identity has not been chosen yet.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 14,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The corner Event Log button now stays put when the log is open, so it can be closed again without moving your mouse.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 13,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Character sheets now make better use of the available space on wide screens.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 8,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Crew roster bubbles are now legible and stand out from the background in both light and dark mode.',
|
||||||
|
'A scene can no longer be ended until every Pi-Rat has faced a Challenge (or the Obstacle List is empty). The Deep sees a ✓/○ marker on each Pi-Rat showing who still needs one.',
|
||||||
|
'Decluttered your Hand panel — dropped the how-to-play blurb for a low-profile label.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 7,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'During a scene, the Event Log can be collapsed to a corner button to declutter your screen, and reopened from that button.',
|
||||||
|
'In the three-column layout, the Event Log now stays pinned within the screen and scrolls internally, so its newest entries are always visible.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 6,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'PvP challenge cards now display the general suit theme instead of specific obstacles in tooltips.',
|
||||||
|
'Secret Technique tooltips now show the full technique description when played.',
|
||||||
|
'Personal Objectives on the Character Sheet are now positioned in a side column on wider screens.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 5,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Completing a Crew Objective is now announced in the Event Log.',
|
||||||
|
'Fixed a UI quirk where mousing over personal objectives incorrectly showed an interactive pointer for Pi-Rats who cannot change them.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 4,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Tidied the Challenge panel: dropped the redundant “Plays” list and the how-to-play blurb. Each Obstacle now leads with its card (e.g. “K♦”) in the title, colored by suit.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'The rulebook’s Example of Play now has card diagrams at each step, showing how each Obstacle’s column grows as the scene unfolds.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Obstacle cards now stack as a single overlapping column — the latest play sits in full view with earlier cards peeking out behind it, matching the rulebook’s card layout.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'First tracked version — the app now reports its version here in the ☰ menu → About, with a log of player-facing changes.',
|
||||||
|
'Home screen lists crews you can rejoin above “Muster a New Crew”.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
6
frontend/src/lib/menu.js
Normal file
6
frontend/src/lib/menu.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// Extra entries for the corner menu, set by pages that have context-specific
|
||||||
|
// links (e.g. Dashboard adds the creator-only Admin link). Each entry is
|
||||||
|
// { label, href, external?, title? }.
|
||||||
|
export const extraMenuLinks = writable([]);
|
||||||
46
frontend/src/lib/sessions.js
Normal file
46
frontend/src/lib/sessions.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Tracks the games this browser has joined so the home page can offer a
|
||||||
|
// "rejoin" menu. Stored in localStorage only — deleting an entry never touches
|
||||||
|
// the backend. Each entry is keyed by gameId + playerId:
|
||||||
|
// { gameId, playerId, crewName, playerName, ratName, updatedAt }
|
||||||
|
const KEY = 'pirats-sessions';
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(KEY));
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(list) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSession(a, gameId, playerId) {
|
||||||
|
return a.gameId === gameId && a.playerId === playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most-recently-updated first.
|
||||||
|
export function getSessions() {
|
||||||
|
return load().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert or merge a session. Only defined fields overwrite existing values, so
|
||||||
|
// a sparse update (e.g. just a playerName at join time) won't clobber names
|
||||||
|
// filled in later from the full game state.
|
||||||
|
export function saveSession(fields) {
|
||||||
|
const { gameId, playerId } = fields;
|
||||||
|
if (!gameId || !playerId) return;
|
||||||
|
const list = load();
|
||||||
|
const idx = list.findIndex(s => sameSession(s, gameId, playerId));
|
||||||
|
const incoming = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && v !== null));
|
||||||
|
const merged = { ...(idx >= 0 ? list[idx] : {}), ...incoming, updatedAt: Date.now() };
|
||||||
|
if (idx >= 0) list[idx] = merged;
|
||||||
|
else list.push(merged);
|
||||||
|
persist(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSession(gameId, playerId) {
|
||||||
|
persist(load().filter(s => !sameSession(s, gameId, playerId)));
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { apiRequest } from './api';
|
||||||
|
|
||||||
// Auto-generated math-pirate suggestions pool for character creation
|
// Auto-generated math-pirate suggestions pool for character creation
|
||||||
const SUGGESTIONS = {
|
const SUGGESTIONS = {
|
||||||
"look": [
|
"look": [
|
||||||
@@ -1211,227 +1213,32 @@ const SUGGESTIONS = {
|
|||||||
"They stubbornly argues about the slide rule and has the abacus tattoos to prove it.",
|
"They stubbornly argues about the slide rule and has the abacus tattoos to prove it.",
|
||||||
"They accidently misplaces the dry gunpowder bags and has a certificate from the Stowaway Academy.",
|
"They accidently misplaces the dry gunpowder bags and has a certificate from the Stowaway Academy.",
|
||||||
"They frequently drops the slide rule but gets confused by negative numbers."
|
"They frequently drops the slide rule but gets confused by negative numbers."
|
||||||
],
|
|
||||||
"techniques": [
|
|
||||||
"Geometric Drift of the Cheese Reef",
|
|
||||||
"Parabolic boarding leap",
|
|
||||||
"Fibonacci Drift of the Stormy Sea",
|
|
||||||
"Geometric Leap of Euler",
|
|
||||||
"Trigonometric Flurry of the Abacus",
|
|
||||||
"Prime Number Slash of the Gat",
|
|
||||||
"Logarithmic Dodge of Carlos",
|
|
||||||
"Parabolic Flurry of the Gat",
|
|
||||||
"Logarithmic Slash of Euler",
|
|
||||||
"Logarithmic Drift of the Gat",
|
|
||||||
"Hypotenuse Shield of the Coordinate Plane",
|
|
||||||
"Algebraic Parry of the Stormy Sea",
|
|
||||||
"Hypotenuse Parry of the Deep",
|
|
||||||
"Hypotenuse Drift of Shrapnel",
|
|
||||||
"Binary Vortex of Gauss",
|
|
||||||
"Geometric Leap of the Deep",
|
|
||||||
"Trigonometric Drift of Shrapnel",
|
|
||||||
"Algebraic Slam of the Abacus",
|
|
||||||
"Hypotenuse Slash of Euler",
|
|
||||||
"Geometric Vortex of Euler",
|
|
||||||
"Hypotenuse Leap of the Gat",
|
|
||||||
"Trigonometric Slam of the Deep",
|
|
||||||
"Parabolic Shield of the Deep",
|
|
||||||
"Geometric Slash of the Gat",
|
|
||||||
"Geometric Slash of Gauss",
|
|
||||||
"Binary Barrage of the Gat",
|
|
||||||
"Fibonacci Vortex of Carlos",
|
|
||||||
"Geometric Slam of the Coordinate Plane",
|
|
||||||
"Prime Number Slash of the Deep",
|
|
||||||
"Prime Number Flurry of the Abacus",
|
|
||||||
"Logarithmic Flurry of Shrapnel",
|
|
||||||
"Vector Leap of Euler",
|
|
||||||
"Binary Drift of the Cheese Reef",
|
|
||||||
"Algebraic Leap of Shrapnel",
|
|
||||||
"Fibonacci Dodge of the Abacus",
|
|
||||||
"Vector Leap of the Coordinate Plane",
|
|
||||||
"Algebraic Leap of Gauss",
|
|
||||||
"Prime Number Leap of Euler",
|
|
||||||
"Trigonometric Dodge of the Cheese Reef",
|
|
||||||
"Fibonacci Barrage of Carlos",
|
|
||||||
"Geometric Parry of the Gat",
|
|
||||||
"Binary Shield of the Gat",
|
|
||||||
"Fibonacci Slash of Euler",
|
|
||||||
"Trigonometric Flurry of the Coordinate Plane",
|
|
||||||
"Cheese smell smoke screen",
|
|
||||||
"Parabolic Barrage of Shrapnel",
|
|
||||||
"Geometric Dodge of Gauss",
|
|
||||||
"Trigonometric Drift of the Deep",
|
|
||||||
"Algebraic Parry of Shrapnel",
|
|
||||||
"Vector Dodge of the Abacus",
|
|
||||||
"Algebraic Vortex of Carlos",
|
|
||||||
"Binary Flurry of the Abacus",
|
|
||||||
"Algebraic Slam of the Coordinate Plane",
|
|
||||||
"Algebraic Slam of the Deep",
|
|
||||||
"Logarithmic Barrage of the Deep",
|
|
||||||
"Binary Drift of Carlos",
|
|
||||||
"Logarithmic Flurry of the Abacus",
|
|
||||||
"Logarithmic Slam of Carlos",
|
|
||||||
"Parabolic Slash of the Stormy Sea",
|
|
||||||
"Parabolic Barrage of Carlos",
|
|
||||||
"Logarithmic Dodge of the Abacus",
|
|
||||||
"Fibonacci Slam of Gauss",
|
|
||||||
"Geometric Shield of the Coordinate Plane",
|
|
||||||
"Trigonometric Barrage of the Abacus",
|
|
||||||
"Algebraic Flurry of the Coordinate Plane",
|
|
||||||
"Hypotenuse Barrage of the Gat",
|
|
||||||
"Vector Vortex of the Gat",
|
|
||||||
"Geometric Parry of the Cheese Reef",
|
|
||||||
"Vector Drift of Euler",
|
|
||||||
"Vector Slash of the Stormy Sea",
|
|
||||||
"Binary Shield of Euler",
|
|
||||||
"Hypotenuse Parry of the Abacus",
|
|
||||||
"Trigonometric Drift of Euler",
|
|
||||||
"Fibonacci Parry of the Gat",
|
|
||||||
"Vector Parry of Gauss",
|
|
||||||
"Hypotenuse Shield of the Deep",
|
|
||||||
"Hypotenuse Slam of the Gat",
|
|
||||||
"Trigonometric Dodge of the Gat",
|
|
||||||
"Fibonacci Barrage of the Deep",
|
|
||||||
"Vector Dodge of Carlos",
|
|
||||||
"Algebraic Slam of the Stormy Sea",
|
|
||||||
"Prime Number Dodge of Shrapnel",
|
|
||||||
"Vector Slash of the Gat",
|
|
||||||
"Hypotenuse Flurry of the Gat",
|
|
||||||
"Fibonacci Flurry of the Gat",
|
|
||||||
"Logarithmic Leap of the Abacus",
|
|
||||||
"Fibonacci Slash of Gauss",
|
|
||||||
"Hypotenuse Parry of Shrapnel",
|
|
||||||
"Trigonometric Barrage of Carlos",
|
|
||||||
"Hypotenuse Parry of the Coordinate Plane",
|
|
||||||
"Trigonometric Shield of the Gat",
|
|
||||||
"Prime Number Parry of Euler",
|
|
||||||
"Vector Barrage of Shrapnel",
|
|
||||||
"Vector Slam of the Gat",
|
|
||||||
"Vector Flurry of the Abacus",
|
|
||||||
"Binary Drift of Gauss",
|
|
||||||
"Prime Number Slam of Euler",
|
|
||||||
"Prime Number Leap of Carlos",
|
|
||||||
"Algebraic Barrage of the Stormy Sea",
|
|
||||||
"Geometric Barrage of the Gat",
|
|
||||||
"Logarithmic Leap of the Deep",
|
|
||||||
"Logarithmic Barrage of the Gat",
|
|
||||||
"Angle of attack swipe",
|
|
||||||
"Trigonometric Parry of the Cheese Reef",
|
|
||||||
"Fibonacci Slash of the Coordinate Plane",
|
|
||||||
"Hypotenuse Shield of the Gat",
|
|
||||||
"Vector Shield of Gauss",
|
|
||||||
"Trigonometric Shield of Gauss",
|
|
||||||
"Vector Slash of the Abacus",
|
|
||||||
"Binary Shield of the Cheese Reef",
|
|
||||||
"Parabolic Parry of the Deep",
|
|
||||||
"Geometric Leap of Gauss",
|
|
||||||
"Logarithmic Shield of the Deep",
|
|
||||||
"Logarithmic Drift of Shrapnel",
|
|
||||||
"Trigonometric Flurry of Carlos",
|
|
||||||
"Logarithmic Slash of the Cheese Reef",
|
|
||||||
"Fibonacci Slam of the Abacus",
|
|
||||||
"Vector Parry of the Gat",
|
|
||||||
"Geometric Shield of the Deep",
|
|
||||||
"Logarithmic Flurry of Gauss",
|
|
||||||
"Hypotenuse Barrage of Euler",
|
|
||||||
"Hypotenuse Vortex of the Cheese Reef",
|
|
||||||
"Hypotenuse Slash of Gauss",
|
|
||||||
"Vector Parry of the Stormy Sea",
|
|
||||||
"Logarithmic Slam of the Stormy Sea",
|
|
||||||
"Hypotenuse Dodge of the Abacus",
|
|
||||||
"Geometric Leap of the Stormy Sea",
|
|
||||||
"Prime Number Shield of the Coordinate Plane",
|
|
||||||
"Binary Barrage of Shrapnel",
|
|
||||||
"Hypotenuse Shield of Euler",
|
|
||||||
"Geometric Drift of the Abacus",
|
|
||||||
"Prime Number Drift of Shrapnel",
|
|
||||||
"Logarithmic Vortex of the Abacus",
|
|
||||||
"Vector Slam of the Abacus",
|
|
||||||
"Hypotenuse Flurry of the Abacus",
|
|
||||||
"Trigonometric Slam of Euler",
|
|
||||||
"Parabolic Slam of Euler",
|
|
||||||
"Vector Shield of Carlos",
|
|
||||||
"Trigonometric Dodge of the Deep",
|
|
||||||
"Fibonacci Vortex of the Abacus",
|
|
||||||
"Geometric Shield of the Cheese Reef",
|
|
||||||
"Algebraic Drift of Gauss",
|
|
||||||
"Trigonometric Slash of the Cheese Reef",
|
|
||||||
"Parabolic Shield of Shrapnel",
|
|
||||||
"Fibonacci Shield of Shrapnel",
|
|
||||||
"Hypotenuse Dodge of Shrapnel",
|
|
||||||
"Vector Slash of the Coordinate Plane",
|
|
||||||
"Binary Vortex of the Cheese Reef",
|
|
||||||
"Geometric Slash of Shrapnel",
|
|
||||||
"Fibonacci Slash of the Cheese Reef",
|
|
||||||
"Logarithmic Vortex of the Gat",
|
|
||||||
"Algebraic Flurry of Euler",
|
|
||||||
"Logarithmic Barrage of the Abacus",
|
|
||||||
"Logarithmic Slash of the Stormy Sea",
|
|
||||||
"Vector Drift of the Gat",
|
|
||||||
"Trigonometric Shield of Euler",
|
|
||||||
"Trigonometric Shield of the Deep",
|
|
||||||
"Trigonometric Leap of Gauss",
|
|
||||||
"Prime Number Shield of the Deep",
|
|
||||||
"Algebraic Barrage of the Coordinate Plane",
|
|
||||||
"Vector Flurry of the Gat",
|
|
||||||
"Hypotenuse Slam of the Coordinate Plane",
|
|
||||||
"Geometric Vortex of the Gat",
|
|
||||||
"Binary Slam of the Gat",
|
|
||||||
"Binary Slam of the Cheese Reef",
|
|
||||||
"Parabolic Drift of Shrapnel",
|
|
||||||
"Prime Number Drift of the Stormy Sea",
|
|
||||||
"Logarithmic Parry of the Cheese Reef",
|
|
||||||
"Parabolic Leap of the Abacus",
|
|
||||||
"Binary Dodge of Shrapnel",
|
|
||||||
"Parabolic Barrage of the Abacus",
|
|
||||||
"Parabolic Vortex of the Coordinate Plane",
|
|
||||||
"Prime Number Dodge of Carlos",
|
|
||||||
"Fibonacci Barrage of the Abacus",
|
|
||||||
"Vector Barrage of the Coordinate Plane",
|
|
||||||
"Prime Number Drift of the Abacus",
|
|
||||||
"Algebraic Slash of the Gat",
|
|
||||||
"Binary Drift of the Coordinate Plane",
|
|
||||||
"Hypotenuse Flurry of the Stormy Sea",
|
|
||||||
"Trigonometric Slam of the Stormy Sea",
|
|
||||||
"Hypotenuse Vortex of Euler",
|
|
||||||
"Fibonacci Leap of the Cheese Reef",
|
|
||||||
"Fibonacci Parry of the Abacus",
|
|
||||||
"Trigonometric Slash of the Coordinate Plane",
|
|
||||||
"Parabolic Shield of Carlos",
|
|
||||||
"Trigonometric Leap of the Coordinate Plane",
|
|
||||||
"Trigonometric Leap of Carlos",
|
|
||||||
"Vector Parry of Euler",
|
|
||||||
"Hypotenuse Barrage of the Coordinate Plane",
|
|
||||||
"Binary Parry of Euler",
|
|
||||||
"Geometric Flurry of Euler",
|
|
||||||
"Parabolic Dodge of the Cheese Reef",
|
|
||||||
"Geometric Leap of the Abacus",
|
|
||||||
"Binary Leap of the Gat",
|
|
||||||
"Binary Barrage of the Deep",
|
|
||||||
"Fibonacci Shield of the Coordinate Plane",
|
|
||||||
"Fibonacci Drift of the Deep",
|
|
||||||
"Trigonometric Shield of Shrapnel",
|
|
||||||
"Parabolic Slash of Euler",
|
|
||||||
"Hypotenuse Parry of the Gat"
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSuggestion(type, exclude = []) {
|
function pickFrom(arr, exclude = []) {
|
||||||
let arr = SUGGESTIONS[type];
|
|
||||||
if (!arr) return "";
|
|
||||||
const taken = new Set(exclude.map(s => s.trim().toLowerCase()));
|
const taken = new Set(exclude.map(s => s.trim().toLowerCase()));
|
||||||
const available = arr.filter(s => !taken.has(s.trim().toLowerCase()));
|
const available = arr.filter(s => !taken.has(s.trim().toLowerCase()));
|
||||||
if (available.length > 0) arr = available;
|
const pool = available.length > 0 ? available : arr;
|
||||||
return arr[Math.floor(Math.random() * arr.length)];
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draws `count` distinct suggestions from a pool.
|
export function getSuggestion(type, exclude = []) {
|
||||||
export function getSuggestions(type, count) {
|
const arr = SUGGESTIONS[type];
|
||||||
const picked = [];
|
if (!arr) return "";
|
||||||
for (let i = 0; i < count; i++) {
|
return pickFrom(arr, exclude);
|
||||||
const s = getSuggestion(type, picked);
|
}
|
||||||
if (!s) break;
|
|
||||||
picked.push(s);
|
// Secret Pirate Technique names live in the backend (it also uses them to equip
|
||||||
}
|
// re-rolled recruits); fetch the pool once and pick from it client-side.
|
||||||
return picked;
|
let techniquePoolPromise = null;
|
||||||
|
function loadTechniquePool() {
|
||||||
|
if (!techniquePoolPromise) {
|
||||||
|
techniquePoolPromise = apiRequest('/suggestions/techniques').then(d => d.techniques);
|
||||||
|
}
|
||||||
|
return techniquePoolPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTechniqueSuggestion(exclude = []) {
|
||||||
|
return pickFrom(await loadTechniquePool(), exclude);
|
||||||
}
|
}
|
||||||
|
|||||||
23
frontend/src/lib/theme.js
Normal file
23
frontend/src/lib/theme.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// Light/dark theme preference, shared across games via localStorage (the
|
||||||
|
// rulebook page reads the same key). index.html applies the saved theme
|
||||||
|
// before first paint; this store keeps the <html> attribute and storage in
|
||||||
|
// sync when the user toggles from the corner menu.
|
||||||
|
const STORAGE_KEY = 'pirats-theme';
|
||||||
|
|
||||||
|
function savedTheme() {
|
||||||
|
const value = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return value === 'dark' ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme = writable(savedTheme());
|
||||||
|
|
||||||
|
theme.subscribe((value) => {
|
||||||
|
document.documentElement.dataset.theme = value;
|
||||||
|
localStorage.setItem(STORAGE_KEY, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function toggleTheme() {
|
||||||
|
theme.update((value) => (value === 'dark' ? 'light' : 'dark'));
|
||||||
|
}
|
||||||
9
frontend/src/lib/title.js
Normal file
9
frontend/src/lib/title.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Sets document.title to a " · "-joined set of page specifics followed by the
|
||||||
|
// app name, so tabs and browser history entries are easy to tell apart.
|
||||||
|
// Falsy parts are dropped; with no parts the title is just the app name.
|
||||||
|
const BASE = 'Rats with Gats';
|
||||||
|
|
||||||
|
export function setPageTitle(...parts) {
|
||||||
|
const prefix = parts.filter(Boolean).join(' · ');
|
||||||
|
document.title = prefix ? `${prefix} — ${BASE}` : BASE;
|
||||||
|
}
|
||||||
84
frontend/src/lib/tooltip.js
Normal file
84
frontend/src/lib/tooltip.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// A styled hover/focus tooltip that replaces the native `title` attribute.
|
||||||
|
//
|
||||||
|
// use:tooltip={"plain text"} -> rendered as text
|
||||||
|
// use:tooltip={{ html: "<b>rich</b>" }} -> rendered as HTML (trusted, static only)
|
||||||
|
//
|
||||||
|
// Only ever pass trusted, static HTML (e.g. built from the rulebook tables in
|
||||||
|
// lib/cards.js); never interpolate raw player input into the `html` form.
|
||||||
|
//
|
||||||
|
// The floating element is appended to <body> and positioned with fixed
|
||||||
|
// coordinates so it escapes any overflow:hidden / transformed ancestors
|
||||||
|
// (cards, modals, scrolling columns).
|
||||||
|
|
||||||
|
function contentEmpty(content) {
|
||||||
|
if (!content) return true;
|
||||||
|
if (typeof content === 'object') return !content.html;
|
||||||
|
return String(content).trim() === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tooltip(node, content) {
|
||||||
|
let current = content;
|
||||||
|
let tip = null;
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
if (tip || contentEmpty(current)) return;
|
||||||
|
tip = document.createElement('div');
|
||||||
|
tip.className = 'tooltip-pop';
|
||||||
|
tip.setAttribute('role', 'tooltip');
|
||||||
|
if (typeof current === 'object' && current.html != null) {
|
||||||
|
tip.innerHTML = current.html;
|
||||||
|
} else {
|
||||||
|
tip.textContent = String(current);
|
||||||
|
}
|
||||||
|
document.body.appendChild(tip);
|
||||||
|
position();
|
||||||
|
requestAnimationFrame(() => tip && tip.classList.add('visible'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function position() {
|
||||||
|
if (!tip) return;
|
||||||
|
const r = node.getBoundingClientRect();
|
||||||
|
const t = tip.getBoundingClientRect();
|
||||||
|
const margin = 8;
|
||||||
|
// Prefer above the target; flip below when there isn't room.
|
||||||
|
let top = r.top - t.height - margin;
|
||||||
|
if (top < margin) top = r.bottom + margin;
|
||||||
|
if (top + t.height > window.innerHeight - margin) {
|
||||||
|
top = Math.max(margin, window.innerHeight - t.height - margin);
|
||||||
|
}
|
||||||
|
let left = r.left + r.width / 2 - t.width / 2;
|
||||||
|
left = Math.max(margin, Math.min(left, window.innerWidth - t.width - margin));
|
||||||
|
tip.style.top = `${top}px`;
|
||||||
|
tip.style.left = `${left}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
if (tip) {
|
||||||
|
tip.remove();
|
||||||
|
tip = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('mouseenter', show);
|
||||||
|
node.addEventListener('mouseleave', hide);
|
||||||
|
node.addEventListener('focus', show);
|
||||||
|
node.addEventListener('blur', hide);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newContent) {
|
||||||
|
current = newContent;
|
||||||
|
if (tip) {
|
||||||
|
// Refresh the content of an already-visible tip in place.
|
||||||
|
hide();
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
hide();
|
||||||
|
node.removeEventListener('mouseenter', show);
|
||||||
|
node.removeEventListener('mouseleave', hide);
|
||||||
|
node.removeEventListener('focus', show);
|
||||||
|
node.removeEventListener('blur', hide);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,45 +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">Name</th>
|
<th>Pi-Rat</th>
|
||||||
<th class="p-3">Role</th>
|
<th>Player</th>
|
||||||
<th class="p-3">Status</th>
|
<th>Role</th>
|
||||||
<th class="p-3">Rank</th>
|
<th>Status</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">
|
{#if p.name && p.name !== p.player_name}
|
||||||
|
{p.name}
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted">Not chosen yet</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>{p.player_name || p.name}</td>
|
||||||
|
<td>
|
||||||
{#if p.role === 'deep'} 🌊 Deep
|
{#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,13 +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 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;
|
||||||
@@ -16,52 +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>
|
||||||
|
|
||||||
|
{#if state.game.phase !== 'scene'}
|
||||||
|
<button
|
||||||
|
class="log-reopen-btn"
|
||||||
|
title={phaseLogOpen ? 'Hide the event log' : 'Show the event log'}
|
||||||
|
on:click={() => (phaseLogOpen = !phaseLogOpen)}
|
||||||
|
>
|
||||||
|
{phaseLogOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<NameModal {state} />
|
||||||
|
<GatModal {state} />
|
||||||
|
<RankBonusModal {state} />
|
||||||
{:else if !error}
|
{:else if !error}
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<p>Loading game state...</p>
|
<p>Loading game state...</p>
|
||||||
|
|||||||
@@ -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,12 +41,17 @@
|
|||||||
</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 Pi-Rat name</h2>
|
<h2>Enter your name</h2>
|
||||||
|
<p class="info-text" style="margin-bottom: 1rem;">
|
||||||
|
This is <strong>your</strong> name as a player, not your Pi-Rat's. Your Pi-Rat will be known
|
||||||
|
by their smell (e.g. "Recruit Gunpowder") until they earn a proper Name in play.
|
||||||
|
</p>
|
||||||
<form on:submit|preventDefault={joinGame}>
|
<form on:submit|preventDefault={joinGame}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="playerName">Your Name</label>
|
<label for="playerName">Your Name</label>
|
||||||
@@ -40,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
|
||||||
/>
|
/>
|
||||||
@@ -50,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"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import random
|
import random
|
||||||
from typing import Dict, Any, List, Tuple
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
SUITS = {
|
SUITS = {
|
||||||
"C": {"symbol": "♣", "name": "Clubs", "color": "black", "narration": "Shootin' and Stabbin' (violence/combat)"},
|
"C": {"symbol": "♣", "name": "Clubs", "color": "black", "narration": "Shootin' and Stabbin' (violence/combat)"},
|
||||||
@@ -74,50 +74,209 @@ OBSTACLES_DATA = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Secret Pirate Technique suggestions for when players need ideas: 20 handcrafted
|
# Secret Pirate Technique suggestions: served to the frontend's Suggest buttons
|
||||||
# names plus the same "{math word} {move} of {target}" combinations the frontend
|
# via GET /api/suggestions/techniques and used to equip re-rolled recruits.
|
||||||
# suggestion pool (frontend/src/lib/suggestions.js) is built from.
|
TECHNIQUE_SUGGESTIONS = [
|
||||||
_HANDCRAFTED_TECHNIQUES = [
|
"I meant to do that",
|
||||||
"I missed on purpose",
|
"The floor was always a trap",
|
||||||
"Carlos will figure it out",
|
"Carlos owes me one",
|
||||||
"Never trust a hatless captain",
|
"Barry already disarmed it",
|
||||||
"I'm not left handed",
|
"Jaspar took the blame",
|
||||||
"The Albatross is always watching",
|
"Greg always comes back",
|
||||||
"I've got a bomb",
|
"We left Greg on purpose",
|
||||||
"Divide by Pi",
|
"The seagulls work for me",
|
||||||
"Cheese-scented smoke screen",
|
"My whiskers told me so",
|
||||||
"Look behind you, a three-headed parrot!",
|
"I was behind you the whole time",
|
||||||
"Parabolic boarding leap",
|
"I am wearing two eyepatches",
|
||||||
"The Pocket Cannon",
|
"Nobody suspects the small one",
|
||||||
"Aggressive Math-whining",
|
"I licked it, so it's mine",
|
||||||
"Unspeakable Tail Swish",
|
"The grog made me do it",
|
||||||
"Bandana Whip",
|
"I know a guy",
|
||||||
"Double-barrelled cheese blaster",
|
"The guy knows another guy",
|
||||||
"Mathematical Scurvy cure",
|
"I greased the entire ship",
|
||||||
"I was hiding under the carpet",
|
"I hid the cannonball in my cheeks",
|
||||||
"Batten down my feelings",
|
"I taught the parrot everything it knows",
|
||||||
"Wait, is that cheese?",
|
"Hold my grog",
|
||||||
"Sudden pirate flip"
|
"Watch this",
|
||||||
]
|
"I counted to Pi",
|
||||||
|
"Pi is exactly three when I'm in a hurry",
|
||||||
_TECHNIQUE_MATH_WORDS = [
|
"I cast Pie, again",
|
||||||
"Geometric", "Parabolic", "Fibonacci", "Trigonometric", "Prime Number",
|
"I memorized the wrong map on purpose",
|
||||||
"Logarithmic", "Hypotenuse", "Algebraic", "Binary", "Vector"
|
"I sneezed at exactly the right moment",
|
||||||
]
|
"I read about this in a book I ate",
|
||||||
_TECHNIQUE_MOVES = [
|
"I learned this from a ghost",
|
||||||
"Drift", "Leap", "Flurry", "Slash", "Parry",
|
"The barrel was empty on purpose",
|
||||||
"Slam", "Dodge", "Vortex", "Shield", "Barrage"
|
"This is my emotional support cannon",
|
||||||
]
|
"That wasn't even my final form",
|
||||||
_TECHNIQUE_TARGETS = [
|
"I've been holding my breath since Tuesday",
|
||||||
"the Cheese Reef", "the Stormy Sea", "Euler", "the Abacus", "the Gat",
|
"It's a rental",
|
||||||
"Carlos", "the Deep", "Gauss", "the Coordinate Plane", "Shrapnel"
|
"The ship likes me better",
|
||||||
]
|
"My cousin is a cannonball",
|
||||||
|
"My scars spell out a map",
|
||||||
TECHNIQUE_SUGGESTIONS = _HANDCRAFTED_TECHNIQUES + [
|
"My other gat is a crossbow",
|
||||||
f"{math_word} {move} of {target}"
|
"I prepared this speech in advance",
|
||||||
for math_word in _TECHNIQUE_MATH_WORDS
|
"I read the rulebook upside down",
|
||||||
for move in _TECHNIQUE_MOVES
|
"Technically, that's legal",
|
||||||
for target in _TECHNIQUE_TARGETS
|
"Objection!",
|
||||||
|
"It's called a heist now",
|
||||||
|
"You wouldn't hit a rat with glasses",
|
||||||
|
"These aren't even my real glasses",
|
||||||
|
"I have the high ground",
|
||||||
|
"You activated my trap card",
|
||||||
|
"It was me all along",
|
||||||
|
"Behold, a distraction",
|
||||||
|
"We meet again, stairs",
|
||||||
|
"I've seen this in a dream",
|
||||||
|
"The prophecy said nothing about Tuesdays",
|
||||||
|
"Catch!",
|
||||||
|
"Think fast",
|
||||||
|
"Look behind you",
|
||||||
|
"No, seriously, look behind you",
|
||||||
|
"This one goes to eleven",
|
||||||
|
"Critical squeak",
|
||||||
|
"Nothing up my sleeves but more sleeves",
|
||||||
|
"The anchor was a decoy",
|
||||||
|
"Six rats in a coat",
|
||||||
|
"The Deep blinked first",
|
||||||
|
"The Squid owed me a favor",
|
||||||
|
"The Albatross vouches for me",
|
||||||
|
"The Mermaid pinky-swore",
|
||||||
|
"It worked in the bathtub",
|
||||||
|
"We rehearsed this exactly once",
|
||||||
|
"The smell did the negotiating",
|
||||||
|
"Carry me, I'm dramatic",
|
||||||
|
"It's only stealing if you get caught",
|
||||||
|
"Borrowed permanently",
|
||||||
|
"Never trust a dry deck",
|
||||||
|
"Never trust a barefoot vampire",
|
||||||
|
"Never duel before lunch",
|
||||||
|
"Never sing the second verse",
|
||||||
|
"Never play cards with a Goblin",
|
||||||
|
"Never wake a sleeping Bean Whale",
|
||||||
|
"Never bite the hand that feeds you cheese",
|
||||||
|
"Never bring a sword to a math fight",
|
||||||
|
"A rat always has an exit",
|
||||||
|
"Two exits, actually",
|
||||||
|
"A wet rat fears no rain",
|
||||||
|
"A captain never looks up",
|
||||||
|
"A good plan smells like cheese",
|
||||||
|
"The loudest rat is rarely the problem",
|
||||||
|
"Dead men file no complaints",
|
||||||
|
"The best sword is a longer sword",
|
||||||
|
"If you can't tie a knot, tie a lot",
|
||||||
|
"When in doubt, scream and leap",
|
||||||
|
"Every storm is someone's fault",
|
||||||
|
"All maps lead to treasure if you're stubborn enough",
|
||||||
|
"The sea forgives nothing but forgets everything",
|
||||||
|
"Steal the boat first, the hearts will follow",
|
||||||
|
"You miss every shot you don't blame on the wind",
|
||||||
|
"There's no such thing as too much rope",
|
||||||
|
"It is always squid o'clock somewhere",
|
||||||
|
"Fortune favors the furry",
|
||||||
|
"Don't count your doubloons until they're stolen",
|
||||||
|
"Cheese before glory",
|
||||||
|
"Glory before breakfast",
|
||||||
|
"Gravity is a suggestion",
|
||||||
|
"Rule one: there are no rules",
|
||||||
|
"Rule two: see rule one",
|
||||||
|
"The knot unties itself if you believe",
|
||||||
|
"Smile until they get nervous",
|
||||||
|
"Just keep nodding",
|
||||||
|
"Pretend it's Tuesday",
|
||||||
|
"Speak softly and carry a loud gat",
|
||||||
|
"Improvise, adapt, overboard",
|
||||||
|
"Cry first, ask questions later",
|
||||||
|
"The tide pays its debts",
|
||||||
|
"Apologize mid-swing",
|
||||||
|
"Bite first, monologue later",
|
||||||
|
"Lose dramatically, win quietly",
|
||||||
|
"Fight like the ghosts are watching",
|
||||||
|
"Trip over the right thing",
|
||||||
|
"Be the cannonball",
|
||||||
|
"Always fall on the soft pirate",
|
||||||
|
"Flee now, gloat later",
|
||||||
|
"Dramatic timing is a weapon",
|
||||||
|
"Cheese knows the way home",
|
||||||
|
"Trust the mustache",
|
||||||
|
"The plank is a state of mind",
|
||||||
|
"Every plank is a trapdoor if you stomp hard enough",
|
||||||
|
"The pointy end goes in the other guy",
|
||||||
|
"An apology and a gat beats an apology",
|
||||||
|
"It's not a bribe, it's a gift",
|
||||||
|
"The fourth wall is load-bearing",
|
||||||
|
"Vampires can't resist counting spilled rice",
|
||||||
|
"Distract them with interpretive dance",
|
||||||
|
"Sing the shanty of unreasonable confidence",
|
||||||
|
"The Cheddar Gambit",
|
||||||
|
"The Gouda Guillotine",
|
||||||
|
"The Brie Breach",
|
||||||
|
"The Camembert Cannonade",
|
||||||
|
"The Reverse Plank Walk",
|
||||||
|
"The Drunken Compass Defense",
|
||||||
|
"The Whisker Feint",
|
||||||
|
"The Triple Barrel Roll",
|
||||||
|
"The Stowaway Shuffle",
|
||||||
|
"The Bilge Rat Backflip",
|
||||||
|
"The Captain's Distraction Waltz",
|
||||||
|
"The Forbidden Knot",
|
||||||
|
"The Unforgivable Cannonball",
|
||||||
|
"The Polite Mutiny",
|
||||||
|
"The Reverse Mutiny",
|
||||||
|
"The Squeak of a Thousand Regrets",
|
||||||
|
"The Tail-First Retreat",
|
||||||
|
"The Upside-Down Boarding Party",
|
||||||
|
"The Invisible Rope Trick",
|
||||||
|
"The Two-Hat Bamboozle",
|
||||||
|
"The Ghost Pepper Defense",
|
||||||
|
"The Barnacle Embrace",
|
||||||
|
"The Crow's Nest Cannonball",
|
||||||
|
"The Rigging Tango",
|
||||||
|
"The Anchor Yo-Yo",
|
||||||
|
"The Powder Keg Lullaby",
|
||||||
|
"The Mermaid's IOU",
|
||||||
|
"The Goblin Refund Policy",
|
||||||
|
"The Fifth Ace",
|
||||||
|
"The Sixth Ace",
|
||||||
|
"The Emergency Wedding",
|
||||||
|
"The Slow-Motion Walk Away",
|
||||||
|
"The Borrowed Thunder",
|
||||||
|
"The Cheese Wheel of Fortune",
|
||||||
|
"The Last Rat Standing",
|
||||||
|
"The First Rat Running",
|
||||||
|
"The Old Switcheroo",
|
||||||
|
"The Old Double Switcheroo",
|
||||||
|
"The Long Con (Short Version)",
|
||||||
|
"The Accidental Masterpiece",
|
||||||
|
"The Sympathy Limp",
|
||||||
|
"The Decoy Funeral",
|
||||||
|
"The Surprise Encore",
|
||||||
|
"The Overly Specific Alibi",
|
||||||
|
"The Group Hug Ambush",
|
||||||
|
"The Cannonball Curveball",
|
||||||
|
"The Soup of Truth",
|
||||||
|
"The Hat Trick (With Actual Hats)",
|
||||||
|
"The Thousand-Yard Squint",
|
||||||
|
"The Completely Legal Salvage",
|
||||||
|
"Carry the one",
|
||||||
|
"Round down, shoot up",
|
||||||
|
"Divide and run away",
|
||||||
|
"Subtract yourself from the situation",
|
||||||
|
"Multiply by chaos",
|
||||||
|
"Add insult to injury",
|
||||||
|
"The remainder goes in my pocket",
|
||||||
|
"Long division, short fuse",
|
||||||
|
"Triangulate, then detonate",
|
||||||
|
"Roll for cheese",
|
||||||
|
"Yo ho ho and a barrel of math",
|
||||||
|
"Ask the cook, he's seen worse",
|
||||||
|
"Barry has a boat for this",
|
||||||
|
"Tell Jaspar I said hello",
|
||||||
|
"The first mate signs my alibis",
|
||||||
|
"Piss Whiskers takes the watch",
|
||||||
|
"We voted, you lost",
|
||||||
|
"The cheese was a lie",
|
||||||
|
"One more verse won't hurt",
|
||||||
|
"Abandon ship, but stylishly"
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_fresh_deck() -> List[str]:
|
def get_fresh_deck() -> List[str]:
|
||||||
|
|||||||
@@ -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,16 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session, select
|
||||||
from .models import Game, Player, Obstacle, 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")
|
||||||
|
|
||||||
# --- Card and Hand Utilities ---
|
# --- Card and Hand Utilities ---
|
||||||
|
|
||||||
def get_player_hand(player: Player) -> List[str]:
|
def get_player_hand(player: Player) -> List[str]:
|
||||||
@@ -19,6 +25,64 @@ def get_game_deck(game: Game) -> List[str]:
|
|||||||
def set_game_deck(game: Game, deck: List[str]):
|
def set_game_deck(game: Game, deck: List[str]):
|
||||||
game.deck_cards = json.dumps(deck)
|
game.deck_cards = json.dumps(deck)
|
||||||
|
|
||||||
|
def technique_for_face_card(player: Player, value: str) -> str:
|
||||||
|
"""The Secret Pirate Technique a player has assigned to a face card value (J/Q/K)."""
|
||||||
|
return {
|
||||||
|
"J": player.tech_jack or "Jack Secret Technique",
|
||||||
|
"Q": player.tech_queen or "Queen Secret Technique",
|
||||||
|
"K": player.tech_king or "King Secret Technique",
|
||||||
|
}[value]
|
||||||
|
|
||||||
|
def evaluate_card_play(
|
||||||
|
player: Player,
|
||||||
|
card_code: str,
|
||||||
|
obstacle_card_code: str,
|
||||||
|
obstacle_value: int,
|
||||||
|
acting_rank: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Shared Challenge resolution rules for one (non-Joker) card played by a Pi-Rat
|
||||||
|
(callers must have already rejected Jokers and Deep players):
|
||||||
|
- J/Q/K triggers the player's assigned Secret Pirate Technique: automatic success.
|
||||||
|
- A face card acting as the Obstacle is worth the challenged Pi-Rat's Rank
|
||||||
|
(acting_rank); any other Obstacle card is worth obstacle_value.
|
||||||
|
- Gat privilege: Black cards +1. Name privilege: Red cards +1.
|
||||||
|
Returns {"success", "details", "is_technique", "tech_name"}.
|
||||||
|
"""
|
||||||
|
played = cards.parse_card(card_code)
|
||||||
|
|
||||||
|
if played["value"] in FACE_VALUES:
|
||||||
|
tech_name = technique_for_face_card(player, played["value"])
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"details": f"Used Secret Pirate Technique: '{tech_name}'!",
|
||||||
|
"is_technique": True,
|
||||||
|
"tech_name": tech_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cards.parse_card(obstacle_card_code)["value"] in FACE_VALUES:
|
||||||
|
target_value = acting_rank
|
||||||
|
obs_detail = f"Rank {acting_rank} (Face Card Obstacle)"
|
||||||
|
else:
|
||||||
|
target_value = obstacle_value
|
||||||
|
obs_detail = str(target_value)
|
||||||
|
|
||||||
|
privilege_bonus = 0
|
||||||
|
if player.completed_personal_1 and played["suit"] in ("C", "S"):
|
||||||
|
privilege_bonus += 1
|
||||||
|
if player.completed_personal_2 and played["suit"] in ("H", "D"):
|
||||||
|
privilege_bonus += 1
|
||||||
|
final_value = played["numeric_value"] + privilege_bonus
|
||||||
|
|
||||||
|
success = final_value > target_value
|
||||||
|
outcome = "Success" if success else "Failure"
|
||||||
|
return {
|
||||||
|
"success": success,
|
||||||
|
"details": f"{outcome}! Played {played['display']} ({final_value}) vs Obstacle value {obs_detail}.",
|
||||||
|
"is_technique": False,
|
||||||
|
"tech_name": "",
|
||||||
|
}
|
||||||
|
|
||||||
def calculate_max_hand_size(player: Player, players_in_scene: List[Player], captain_id: Optional[str] = None) -> int:
|
def calculate_max_hand_size(player: Player, players_in_scene: List[Player], captain_id: Optional[str] = None) -> int:
|
||||||
"""
|
"""
|
||||||
Calculates a player's maximum hand size based on their Rank relative to others,
|
Calculates a player's maximum hand size based on their Rank relative to others,
|
||||||
@@ -50,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}
|
||||||
@@ -78,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:
|
||||||
@@ -93,9 +165,9 @@ def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str =
|
|||||||
msg = f"{new_captain.name} is now the Captain!"
|
msg = f"{new_captain.name} is now the Captain!"
|
||||||
if reason:
|
if reason:
|
||||||
msg += f" ({reason})"
|
msg += f" ({reason})"
|
||||||
add_game_event(db, game.id, msg)
|
add_game_event(db, game.id, msg, kind="captain")
|
||||||
elif reason:
|
elif reason:
|
||||||
add_game_event(db, game.id, f"The Captain's position is vacant. ({reason})")
|
add_game_event(db, game.id, f"The Captain's position is vacant. ({reason})", kind="captain")
|
||||||
|
|
||||||
def reshuffle_discard_pile(db: Session, game: Game):
|
def reshuffle_discard_pile(db: Session, game: Game):
|
||||||
"""
|
"""
|
||||||
@@ -115,6 +187,10 @@ def reshuffle_discard_pile(db: Session, game: Game):
|
|||||||
played = json.loads(obs.played_cards)
|
played = json.loads(obs.played_cards)
|
||||||
for entry in played:
|
for entry in played:
|
||||||
active_table_cards.add(entry["card"])
|
active_table_cards.add(entry["card"])
|
||||||
|
# A PvP challenger's temporary Obstacle is on the table until the duel resolves
|
||||||
|
for challenge in game.challenges:
|
||||||
|
if challenge.status == "open" and challenge.temp_card:
|
||||||
|
active_table_cards.add(challenge.temp_card)
|
||||||
|
|
||||||
# 3. The full deck of 54 cards
|
# 3. The full deck of 54 cards
|
||||||
full_deck = []
|
full_deck = []
|
||||||
@@ -158,50 +234,114 @@ 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,
|
name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
|
||||||
|
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)
|
||||||
|
|
||||||
add_game_event(db, game_id, f"{name} joined the crew!")
|
if game:
|
||||||
|
db.refresh(game)
|
||||||
|
if game.phase == "character_creation":
|
||||||
|
# Late joiner to creation: hand out their Like/Hate questions
|
||||||
|
from .crud_character import auto_delegate_all
|
||||||
|
auto_delegate_all(db, game)
|
||||||
|
elif game.phase == "recruit_creation":
|
||||||
|
# Recruit creation is already underway; join it immediately
|
||||||
|
from .crud_character import activate_recruit
|
||||||
|
activate_recruit(db, game, player)
|
||||||
|
|
||||||
|
msg = f"{name} joined the crew!"
|
||||||
|
if needs_reroll and game and game.phase != "recruit_creation":
|
||||||
|
msg += " They'll create their Pi-Rat with the crew between scenes."
|
||||||
|
add_game_event(db, game_id, msg, kind="join")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Player %s (%r) joined game %s (phase=%s, creator=%s)",
|
||||||
|
player.id, name, game_id, game.phase if game else "?", is_creator,
|
||||||
|
)
|
||||||
return player
|
return player
|
||||||
|
|
||||||
def add_game_event(db: Session, game_id: str, message: str):
|
def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
|
||||||
event = GameEvent(game_id=game_id, message=message)
|
event = GameEvent(game_id=game_id, message=message, kind=kind)
|
||||||
db.add(event)
|
db.add(event)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
def get_events_page(db: Session, game_id: str, before: Optional[float] = None, limit: int = 50):
|
||||||
|
"""Returns the newest `limit` events older than `before` (newest overall if None),
|
||||||
|
in ascending timestamp order, plus a flag for whether older events remain."""
|
||||||
|
query = select(GameEvent).where(GameEvent.game_id == game_id)
|
||||||
|
if before is not None:
|
||||||
|
query = query.where(GameEvent.timestamp < before)
|
||||||
|
query = query.order_by(GameEvent.timestamp.desc()).limit(limit + 1)
|
||||||
|
events = db.exec(query).all()
|
||||||
|
has_more = len(events) > limit
|
||||||
|
return list(reversed(events[:limit])), has_more
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ from .models import Game, Player, Obstacle, Challenge
|
|||||||
from . import cards
|
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,
|
||||||
draw_cards_for_player, add_game_event, change_player_rank
|
draw_cards_for_player, add_game_event, change_player_rank, evaluate_card_play
|
||||||
)
|
)
|
||||||
from .crud_scene import resolve_card_against_obstacle
|
from .crud_scene import resolve_card_against_obstacle
|
||||||
|
from .crud_character import recruit_name
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ def _apply_captain_tax(db: Session, game: Game, acting_player: Player):
|
|||||||
"""Captains lose 1 Rank each time they personally fail a Challenge."""
|
"""Captains lose 1 Rank each time they personally fail a Challenge."""
|
||||||
if game.captain_player_id == acting_player.id and acting_player.rank > 1:
|
if game.captain_player_id == acting_player.id and acting_player.rank > 1:
|
||||||
change_player_rank(db, game, acting_player, -1)
|
change_player_rank(db, game, acting_player, -1)
|
||||||
add_game_event(db, game.id, f"Captain Tax! {acting_player.name} failed a Challenge and drops to Rank {acting_player.rank}.")
|
add_game_event(db, game.id, f"Captain Tax! {acting_player.name} failed a Challenge and drops to Rank {acting_player.rank}.", kind="rank")
|
||||||
|
|
||||||
# --- Deep Challenges ---
|
# --- Deep Challenges ---
|
||||||
|
|
||||||
@@ -35,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)
|
||||||
@@ -49,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."
|
||||||
|
|
||||||
@@ -71,16 +75,21 @@ 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()
|
||||||
add_game_event(db, game.id, msg)
|
if success_text:
|
||||||
|
msg += f" On success: {success_text}"
|
||||||
|
if failure_text:
|
||||||
|
msg += f" On failure: {failure_text}"
|
||||||
|
add_game_event(db, game.id, msg, kind="challenge")
|
||||||
return True, "Challenge called!"
|
return True, "Challenge called!"
|
||||||
|
|
||||||
def play_challenge_card(
|
def play_challenge_card(
|
||||||
@@ -156,7 +165,7 @@ def play_challenge_card(
|
|||||||
result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)"
|
result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)"
|
||||||
|
|
||||||
role_note = "" if player.id == challenge.acting_player_id else " (assist)"
|
role_note = "" if player.id == challenge.acting_player_id else " (assist)"
|
||||||
add_game_event(db, game.id, f"{player.name} played {cards.parse_card(card_code)['display']} on '{obstacle.title}'{role_note}. {result['details']}")
|
add_game_event(db, game.id, f"{player.name} played {cards.parse_card(card_code)['display']} on '{obstacle.title}'{role_note}. {result['details']}", kind="card")
|
||||||
|
|
||||||
result["drew_card"] = drew_card
|
result["drew_card"] = drew_card
|
||||||
result["is_joker"] = False
|
result["is_joker"] = False
|
||||||
@@ -191,7 +200,7 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
outcome = "succeeded" if success else "failed"
|
outcome = "succeeded" if success else "failed"
|
||||||
add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!")
|
add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!", kind="challenge")
|
||||||
|
|
||||||
# Settle a refused Gat/Name Tax: keep the prize on success, return it on failure.
|
# Settle a refused Gat/Name Tax: keep the prize on success, return it on failure.
|
||||||
if challenge.tax_state == "refused" and challenge.tax_target_id:
|
if challenge.tax_state == "refused" and challenge.tax_target_id:
|
||||||
@@ -199,21 +208,27 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
|
|||||||
thing = "Gat" if challenge.tax_type == "gat" else "Name"
|
thing = "Gat" if challenge.tax_type == "gat" else "Name"
|
||||||
if refuser:
|
if refuser:
|
||||||
if success:
|
if success:
|
||||||
add_game_event(db, game.id, f"{acting.name} succeeded and keeps {refuser.name}'s {thing}! {refuser.name} must find a new one.")
|
add_game_event(db, game.id, f"{acting.name} succeeded and keeps the stolen {thing}! {refuser.name} must find a new one.", kind="tax")
|
||||||
else:
|
else:
|
||||||
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
|
||||||
|
# their smell-based recruit identity.
|
||||||
acting.completed_personal_2 = False
|
acting.completed_personal_2 = False
|
||||||
acting.needs_name = False
|
|
||||||
refuser.completed_personal_2 = True
|
refuser.completed_personal_2 = True
|
||||||
|
refuser.name = acting.name
|
||||||
|
acting.name = recruit_name(acting)
|
||||||
acting.tax_banned = True
|
acting.tax_banned = True
|
||||||
db.add(refuser)
|
db.add(refuser)
|
||||||
db.add(acting)
|
db.add(acting)
|
||||||
db.commit()
|
db.commit()
|
||||||
change_player_rank(db, game, acting, -1)
|
change_player_rank(db, game, acting, -1)
|
||||||
add_game_event(db, game.id, f"{acting.name} failed and returns the {thing} to {refuser.name}. No more Gat/Name Taxes for {acting.name} this scene!")
|
add_game_event(db, game.id, f"{acting.name} failed and returns the {thing} to {refuser.name}. No more Gat/Name Taxes for {acting.name} this scene!", kind="tax")
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
_apply_captain_tax(db, game, acting)
|
_apply_captain_tax(db, game, acting)
|
||||||
@@ -235,7 +250,7 @@ def cancel_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple[
|
|||||||
db.delete(challenge)
|
db.delete(challenge)
|
||||||
db.commit()
|
db.commit()
|
||||||
if game and target:
|
if game and target:
|
||||||
add_game_event(db, game.id, f"The Deep withdrew the Challenge against {target.name}.")
|
add_game_event(db, game.id, f"The Deep withdrew the Challenge against {target.name}.", kind="challenge")
|
||||||
return True, "Challenge withdrawn."
|
return True, "Challenge withdrawn."
|
||||||
|
|
||||||
# --- Gat Tax & Name Tax ---
|
# --- Gat Tax & Name Tax ---
|
||||||
@@ -258,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:
|
||||||
@@ -280,7 +295,7 @@ def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id
|
|||||||
|
|
||||||
game = get_game(db, challenge.game_id)
|
game = get_game(db, challenge.game_id)
|
||||||
thing = "Gat" if tax_type == "gat" else "Name"
|
thing = "Gat" if tax_type == "gat" else "Name"
|
||||||
add_game_event(db, game.id, f"{requester.name} calls a {thing} Tax on {target.name}: 'Take this Challenge for me!'")
|
add_game_event(db, game.id, f"{requester.name} calls a {thing} Tax on {target.name}: 'Take this Challenge for me!'", kind="tax")
|
||||||
return True, f"{thing} Tax called on {target.name}."
|
return True, f"{thing} Tax called on {target.name}."
|
||||||
|
|
||||||
def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool) -> Tuple[bool, str]:
|
def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool) -> Tuple[bool, str]:
|
||||||
@@ -324,7 +339,7 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
|
|||||||
challenge.tax_state = "accepted"
|
challenge.tax_state = "accepted"
|
||||||
db.add(challenge)
|
db.add(challenge)
|
||||||
db.commit()
|
db.commit()
|
||||||
add_game_event(db, game.id, f"{responder.name} accepted the {thing} Tax and takes over the Challenge!{card_note}")
|
add_game_event(db, game.id, f"{responder.name} accepted the {thing} Tax and takes over the Challenge!{card_note}", kind="tax")
|
||||||
return True, "Tax accepted."
|
return True, "Tax accepted."
|
||||||
|
|
||||||
# Refused: hand over the Gat/Name. The requester attempts the Challenge themselves;
|
# Refused: hand over the Gat/Name. The requester attempts the Challenge themselves;
|
||||||
@@ -332,17 +347,27 @@ 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!"
|
||||||
else:
|
else:
|
||||||
|
# The Name itself is stolen: the requester literally takes the responder's
|
||||||
|
# name string, demoting the responder back to their smell-based identity.
|
||||||
|
stolen_name = responder.name
|
||||||
|
old_requester_name = requester.name
|
||||||
requester.completed_personal_2 = True
|
requester.completed_personal_2 = True
|
||||||
requester.needs_name = True
|
|
||||||
responder.completed_personal_2 = False
|
responder.completed_personal_2 = False
|
||||||
|
responder.name = recruit_name(responder)
|
||||||
|
requester.name = stolen_name
|
||||||
|
event_msg = f"{stolen_name} refused the Name Tax and is demoted to {responder.name}! {old_requester_name} steals the name '{stolen_name}' and attempts the Challenge — succeed to keep it!"
|
||||||
challenge.tax_state = "refused"
|
challenge.tax_state = "refused"
|
||||||
db.add(requester)
|
db.add(requester)
|
||||||
db.add(responder)
|
db.add(responder)
|
||||||
db.add(challenge)
|
db.add(challenge)
|
||||||
db.commit()
|
db.commit()
|
||||||
change_player_rank(db, game, requester, 1)
|
change_player_rank(db, game, requester, 1)
|
||||||
add_game_event(db, game.id, f"{responder.name} refused the {thing} Tax and must hand over their {thing}! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!")
|
add_game_event(db, game.id, event_msg, kind="tax")
|
||||||
return True, "Tax refused. The prize is on the line!"
|
return True, "Tax refused. The prize is on the line!"
|
||||||
|
|
||||||
# --- Pi-Rat vs Pi-Rat Challenges ---
|
# --- Pi-Rat vs Pi-Rat Challenges ---
|
||||||
@@ -369,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):
|
||||||
@@ -397,7 +424,7 @@ def create_pvp_challenge(
|
|||||||
|
|
||||||
parsed = cards.parse_card(card_code)
|
parsed = cards.parse_card(card_code)
|
||||||
narration = cards.SUITS[parsed["suit"]]["narration"]
|
narration = cards.SUITS[parsed["suit"]]["narration"]
|
||||||
add_game_event(db, game.id, f"{challenger.name} challenges {defender.name}, playing {parsed['display']} as a temporary Obstacle — {narration}!")
|
add_game_event(db, game.id, f"{challenger.name} challenges {defender.name}, playing {parsed['display']} as a temporary Obstacle — {narration}!", kind="challenge")
|
||||||
return True, "Duel called!"
|
return True, "Duel called!"
|
||||||
|
|
||||||
def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code: str) -> Tuple[bool, str, Dict[str, Any]]:
|
def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code: str) -> Tuple[bool, str, Dict[str, Any]]:
|
||||||
@@ -430,43 +457,10 @@ def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code
|
|||||||
set_player_hand(defender, hand)
|
set_player_hand(defender, hand)
|
||||||
db.add(defender)
|
db.add(defender)
|
||||||
|
|
||||||
temp_parsed = cards.parse_card(challenge.temp_card)
|
# The temp card is the Obstacle: both its value source and its face-card check.
|
||||||
|
temp_value = cards.parse_card(challenge.temp_card)["numeric_value"]
|
||||||
success = False
|
result = evaluate_card_play(defender, card_code, challenge.temp_card, temp_value, defender.rank)
|
||||||
details = ""
|
success = result["success"]
|
||||||
is_technique = False
|
|
||||||
tech_name = ""
|
|
||||||
|
|
||||||
if played_parsed["value"] in ["J", "Q", "K"]:
|
|
||||||
success = True
|
|
||||||
is_technique = True
|
|
||||||
tech_name = {
|
|
||||||
"J": defender.tech_jack or "Jack Secret Technique",
|
|
||||||
"Q": defender.tech_queen or "Queen Secret Technique",
|
|
||||||
"K": defender.tech_king or "King Secret Technique",
|
|
||||||
}[played_parsed["value"]]
|
|
||||||
details = f"Used Secret Pirate Technique: '{tech_name}'!"
|
|
||||||
else:
|
|
||||||
# A face card acting as an Obstacle is worth the challenged Pi-Rat's Rank
|
|
||||||
if temp_parsed["value"] in ["J", "Q", "K"]:
|
|
||||||
target_value = defender.rank
|
|
||||||
obs_detail = f"Rank {defender.rank} (Face Card Obstacle)"
|
|
||||||
else:
|
|
||||||
target_value = temp_parsed["numeric_value"]
|
|
||||||
obs_detail = str(target_value)
|
|
||||||
|
|
||||||
privilege_bonus = 0
|
|
||||||
if defender.completed_personal_1 and played_parsed["suit"] in ["C", "S"]:
|
|
||||||
privilege_bonus = 1
|
|
||||||
if defender.completed_personal_2 and played_parsed["suit"] in ["H", "D"]:
|
|
||||||
privilege_bonus = 1
|
|
||||||
final_value = played_parsed["numeric_value"] + privilege_bonus
|
|
||||||
|
|
||||||
if final_value > target_value:
|
|
||||||
success = True
|
|
||||||
details = f"Success! Played {played_parsed['display']} ({final_value}) vs {temp_parsed['display']} ({obs_detail})."
|
|
||||||
else:
|
|
||||||
details = f"Failure! Played {played_parsed['display']} ({final_value}) vs {temp_parsed['display']} ({obs_detail})."
|
|
||||||
|
|
||||||
plays = json.loads(challenge.plays)
|
plays = json.loads(challenge.plays)
|
||||||
plays.append({
|
plays.append({
|
||||||
@@ -475,7 +469,7 @@ def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code
|
|||||||
"player_id": defender.id,
|
"player_id": defender.id,
|
||||||
"player_name": defender.name,
|
"player_name": defender.name,
|
||||||
"success": success,
|
"success": success,
|
||||||
"details": details
|
"details": result["details"]
|
||||||
})
|
})
|
||||||
challenge.plays = json.dumps(plays)
|
challenge.plays = json.dumps(plays)
|
||||||
challenge.status = "succeeded" if success else "failed"
|
challenge.status = "succeeded" if success else "failed"
|
||||||
@@ -487,19 +481,14 @@ def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code
|
|||||||
drawn = draw_cards_for_player(db, game, defender, 1)
|
drawn = draw_cards_for_player(db, game, defender, 1)
|
||||||
if drawn:
|
if drawn:
|
||||||
drew_card = drawn[0]
|
drew_card = drawn[0]
|
||||||
details += " (Drew a card back due to matching suit colors!)"
|
result["details"] += " (Drew a card back due to matching suit colors!)"
|
||||||
|
|
||||||
outcome = "wins" if success else "loses"
|
outcome = "wins" if success else "loses"
|
||||||
add_game_event(db, game.id, f"{defender.name} defends against {challenger.name} and {outcome}! {details}")
|
add_game_event(db, game.id, f"{defender.name} defends against {challenger.name} and {outcome}! {result['details']}", kind="challenge")
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
_apply_captain_tax(db, game, defender)
|
_apply_captain_tax(db, game, defender)
|
||||||
|
|
||||||
return True, "Duel resolved.", {
|
result["drew_card"] = drew_card
|
||||||
"success": success,
|
result["is_joker"] = False
|
||||||
"details": details,
|
return True, "Duel resolved.", result
|
||||||
"is_technique": is_technique,
|
|
||||||
"tech_name": tech_name,
|
|
||||||
"drew_card": drew_card,
|
|
||||||
"is_joker": False
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .models import Game, Player
|
from .models import Game
|
||||||
from .crud_base import get_player, get_game, add_game_event
|
from .crud_base import get_player, get_game, add_game_event
|
||||||
|
|
||||||
# --- Character Creation Operations ---
|
# --- Character Creation Operations ---
|
||||||
|
|
||||||
|
def recruit_name_from_smell(avatar_smell: str, fallback: str = "") -> str:
|
||||||
|
"""
|
||||||
|
A nameless Pi-Rat goes by "Recruit {Smell}". Strips lead-ins like "smells like"
|
||||||
|
or "faintly of" (the suggestion pool uses these) so the name reads naturally.
|
||||||
|
"""
|
||||||
|
clean_smell = avatar_smell.strip()
|
||||||
|
if not clean_smell:
|
||||||
|
return f"Recruit {fallback}".strip() if fallback else "Recruit"
|
||||||
|
lead_ins = [
|
||||||
|
"suspiciously smelling of", "pleasantly smelling of", "reminiscent of",
|
||||||
|
"smells like", "a mix of", "like a mix of", "strongly of", "faintly of",
|
||||||
|
"like", "of",
|
||||||
|
]
|
||||||
|
stripped = True
|
||||||
|
while stripped:
|
||||||
|
stripped = False
|
||||||
|
for lead in lead_ins:
|
||||||
|
if clean_smell.lower().startswith(lead + " "):
|
||||||
|
clean_smell = clean_smell[len(lead) + 1:]
|
||||||
|
stripped = True
|
||||||
|
clean_smell = clean_smell[:1].upper() + clean_smell[1:]
|
||||||
|
return f"Recruit {clean_smell}"
|
||||||
|
|
||||||
|
def recruit_name(player) -> str:
|
||||||
|
"""The smell-based temporary identity for a player's Pi-Rat."""
|
||||||
|
return recruit_name_from_smell(player.avatar_smell, fallback=player.player_name or player.name)
|
||||||
|
|
||||||
def auto_delegate_all(db: Session, game: Game):
|
def auto_delegate_all(db: Session, game: Game):
|
||||||
"""
|
"""
|
||||||
Assigns the Like/Hate delegated questions for every player at once.
|
Assigns the Like/Hate delegated questions for every player at once.
|
||||||
@@ -86,265 +113,714 @@ 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:
|
||||||
p.created_techniques = "[]"
|
p.created_techniques = "[]"
|
||||||
db.add(p)
|
db.add(p)
|
||||||
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.")
|
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")
|
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")
|
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")
|
|
||||||
|
|
||||||
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
|
|
||||||
player.avatar_look = avatar_look.strip()
|
|
||||||
player.avatar_smell = avatar_smell.strip()
|
|
||||||
player.first_words = first_words.strip()
|
|
||||||
player.good_at_math = good_at_math.strip()
|
|
||||||
|
|
||||||
# Scent/Name is "Recruit [Smell]" — strip lead-ins like "smells like" or
|
|
||||||
# "faintly of" (the suggestion pool uses these) so the name reads naturally.
|
|
||||||
clean_smell = avatar_smell.strip()
|
|
||||||
lead_ins = [
|
|
||||||
"suspiciously smelling of", "pleasantly smelling of", "reminiscent of",
|
|
||||||
"smells like", "a mix of", "like a mix of", "strongly of", "faintly of",
|
|
||||||
"like", "of",
|
|
||||||
]
|
|
||||||
stripped = True
|
|
||||||
while stripped:
|
|
||||||
stripped = False
|
|
||||||
for lead in lead_ins:
|
|
||||||
if clean_smell.lower().startswith(lead + " "):
|
|
||||||
clean_smell = clean_smell[len(lead) + 1:]
|
|
||||||
stripped = True
|
|
||||||
clean_smell = clean_smell[:1].upper() + clean_smell[1:]
|
|
||||||
player.name = f"Recruit {clean_smell}"
|
|
||||||
|
|
||||||
# 3. Randomly assign 3 new secret techniques, avoiding any technique already
|
# New recruits always start at Rank 1 with a blank sheet
|
||||||
# in play on another player's sheet (collisions make for boring recruits).
|
player.rank = 1
|
||||||
from .cards import TECHNIQUE_SUGGESTIONS
|
player.role = None
|
||||||
in_use = set()
|
player.is_ready = False
|
||||||
for p in game.players:
|
player.is_dead = False
|
||||||
if p.id == player.id:
|
player.is_ghost = False
|
||||||
continue
|
player.tax_banned = False
|
||||||
for t in [p.tech_jack, p.tech_queen, p.tech_king] + json.loads(p.created_techniques) + json.loads(p.swapped_techniques):
|
player.needs_name = False
|
||||||
if t:
|
player.needs_rank_3_bonus = False
|
||||||
in_use.add(t.strip().lower())
|
player.completed_personal_1 = False
|
||||||
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use]
|
player.completed_personal_2 = False
|
||||||
if len(available) < 3:
|
player.completed_personal_3 = False
|
||||||
available = TECHNIQUE_SUGGESTIONS
|
player.avatar_look = ""
|
||||||
chosen_techs = random.sample(available, 3)
|
player.avatar_smell = ""
|
||||||
player.created_techniques = json.dumps(chosen_techs)
|
player.first_words = ""
|
||||||
player.swapped_techniques = json.dumps(chosen_techs)
|
player.good_at_math = ""
|
||||||
player.tech_jack = chosen_techs[0]
|
player.created_techniques = "[]"
|
||||||
player.tech_queen = chosen_techs[1]
|
player.held_techniques = "[]"
|
||||||
player.tech_king = chosen_techs[2]
|
player.swapped_techniques = "[]"
|
||||||
|
player.swap_offer_to_id = None
|
||||||
# 4. Auto-delegate Like/Hate questions to other players
|
player.swap_offer_technique = ""
|
||||||
other_players = [p for p in game.players if p.id != player.id]
|
player.tech_jack = ""
|
||||||
if other_players:
|
player.tech_queen = ""
|
||||||
like_target = random.choice(other_players)
|
player.tech_king = ""
|
||||||
player.other_like_from_player_id = like_target.id
|
player.name = recruit_name(player)
|
||||||
|
|
||||||
|
# Crewmates fill in the social questions and write the recruit's techniques.
|
||||||
|
# Prefer helpers who aren't busy creating their own recruit.
|
||||||
|
others = [p for p in game.players if p.id != player.id]
|
||||||
|
helpers = [p for p in others if not p.needs_reroll] or others
|
||||||
|
if helpers:
|
||||||
|
random.shuffle(helpers)
|
||||||
|
player.other_like_from_player_id = helpers[0].id
|
||||||
player.other_like = ""
|
player.other_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!")
|
|
||||||
|
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,19 +1,27 @@
|
|||||||
import json
|
import json
|
||||||
import random
|
import logging
|
||||||
from typing import Tuple, Dict, Any, Optional
|
from typing import Tuple, Dict, Any
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .models import Game, Player, Obstacle, Challenge
|
from .models import Game, Player, Obstacle
|
||||||
from . import cards
|
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, set_game_deck, calculate_max_hand_size, draw_cards_for_player,
|
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
||||||
add_game_event, reshuffle_discard_pile, capture_hand_maxes, apply_hand_increases,
|
change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS,
|
||||||
change_player_rank, set_captain
|
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
|
||||||
@@ -34,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:
|
||||||
@@ -64,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":
|
||||||
@@ -115,7 +130,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
# Joker drawn as an Obstacle: discard it and permanently increase the
|
# Joker drawn as an Obstacle: discard it and permanently increase the
|
||||||
# number of Obstacles required for following scenes by 1.
|
# number of Obstacles required for following scenes by 1.
|
||||||
game.extra_obstacles += 1
|
game.extra_obstacles += 1
|
||||||
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.")
|
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.", kind="joker")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
info = cards.get_obstacle_info(card)
|
info = cards.get_obstacle_info(card)
|
||||||
@@ -135,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:
|
||||||
@@ -148,7 +165,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
total_obstacles = active_count + drawn_count
|
total_obstacles = active_count + drawn_count
|
||||||
add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {drawn_count} new obstacle(s) drawn ({total_obstacles} active).")
|
add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {drawn_count} new obstacle(s) drawn ({total_obstacles} active).", kind="scene")
|
||||||
|
|
||||||
return True, "Scene started successfully!"
|
return True, "Scene started successfully!"
|
||||||
|
|
||||||
@@ -161,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:
|
||||||
@@ -175,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")
|
||||||
@@ -193,83 +214,30 @@ def resolve_card_against_obstacle(
|
|||||||
acting_rank: int
|
acting_rank: int
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Resolves one (non-Joker) card from a player against an Obstacle:
|
Resolves one (non-Joker) card from a player against an Obstacle via the shared
|
||||||
determines success (Secret Pirate Techniques, Gat/Name privileges, face-card
|
rules in evaluate_card_play, appends the card to the Obstacle's column, and
|
||||||
Obstacle values), appends the card to the Obstacle's column, and updates
|
updates the Obstacle's current value. Does not touch hands or draws.
|
||||||
the Obstacle's current value. Does not touch hands or draws.
|
|
||||||
"""
|
"""
|
||||||
played_parsed = cards.parse_card(card_code)
|
# The last card in the column (or the original Obstacle card) determines the value.
|
||||||
|
played_list = json.loads(obstacle.played_cards)
|
||||||
|
obstacle_card = played_list[-1]["card"] if played_list else obstacle.original_card
|
||||||
|
|
||||||
success = False
|
result = evaluate_card_play(player, card_code, obstacle_card, obstacle.current_value, acting_rank)
|
||||||
details = ""
|
|
||||||
is_technique = False
|
|
||||||
tech_name = ""
|
|
||||||
|
|
||||||
# Face Cards (Jack, Queen, King) played by a Pi-Rat trigger a Secret Pirate Technique: automatic success!
|
|
||||||
if played_parsed["value"] in ["J", "Q", "K"] and player.role != "deep":
|
|
||||||
success = True
|
|
||||||
is_technique = True
|
|
||||||
if played_parsed["value"] == "J":
|
|
||||||
tech_name = player.tech_jack or "Jack Secret Technique"
|
|
||||||
elif played_parsed["value"] == "Q":
|
|
||||||
tech_name = player.tech_queen or "Queen Secret Technique"
|
|
||||||
elif played_parsed["value"] == "K":
|
|
||||||
tech_name = player.tech_king or "King Secret Technique"
|
|
||||||
details = f"Used Secret Pirate Technique: '{tech_name}'!"
|
|
||||||
else:
|
|
||||||
# Regular card play vs Obstacle value. The last card in the column (or the
|
|
||||||
# original Obstacle card) determines the value; face cards count as the
|
|
||||||
# Rank of the challenged Pi-Rat.
|
|
||||||
obs_val_card = cards.parse_card(obstacle.original_card)
|
|
||||||
played_on_obs = json.loads(obstacle.played_cards)
|
|
||||||
if played_on_obs:
|
|
||||||
obs_val_card = cards.parse_card(played_on_obs[-1]["card"])
|
|
||||||
|
|
||||||
if obs_val_card["value"] in ["J", "Q", "K"]:
|
|
||||||
target_value = acting_rank
|
|
||||||
obs_detail = f"Rank {acting_rank} (Face Card Obstacle)"
|
|
||||||
else:
|
|
||||||
target_value = obstacle.current_value
|
|
||||||
obs_detail = str(target_value)
|
|
||||||
|
|
||||||
played_val = played_parsed["numeric_value"]
|
|
||||||
|
|
||||||
# Gat privilege: Black cards +1. Name privilege: Red cards +1.
|
|
||||||
privilege_bonus = 0
|
|
||||||
if player.completed_personal_1 and played_parsed["suit"] in ["C", "S"]:
|
|
||||||
privilege_bonus = 1
|
|
||||||
if player.completed_personal_2 and played_parsed["suit"] in ["H", "D"]:
|
|
||||||
privilege_bonus = 1
|
|
||||||
|
|
||||||
final_played_value = played_val + privilege_bonus
|
|
||||||
|
|
||||||
if final_played_value > target_value:
|
|
||||||
success = True
|
|
||||||
details = f"Success! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
|
|
||||||
else:
|
|
||||||
success = False
|
|
||||||
details = f"Failure! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
|
|
||||||
|
|
||||||
# Place the card in the Obstacle's column; it becomes the Obstacle's new value.
|
# Place the card in the Obstacle's column; it becomes the Obstacle's new value.
|
||||||
played_list = json.loads(obstacle.played_cards)
|
|
||||||
played_list.append({
|
played_list.append({
|
||||||
"card": card_code,
|
"card": card_code,
|
||||||
"player_id": player.id,
|
"player_id": player.id,
|
||||||
"player_name": player.name,
|
"player_name": player.name,
|
||||||
"success": success,
|
"success": result["success"],
|
||||||
"details": details
|
"details": result["details"]
|
||||||
})
|
})
|
||||||
obstacle.played_cards = json.dumps(played_list)
|
obstacle.played_cards = json.dumps(played_list)
|
||||||
obstacle.current_value = played_parsed["numeric_value"]
|
obstacle.current_value = cards.parse_card(card_code)["numeric_value"]
|
||||||
db.add(obstacle)
|
db.add(obstacle)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {
|
return result
|
||||||
"success": success,
|
|
||||||
"details": details,
|
|
||||||
"is_technique": is_technique,
|
|
||||||
"tech_name": tech_name
|
|
||||||
}
|
|
||||||
|
|
||||||
def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]:
|
def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
@@ -318,7 +286,7 @@ def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) ->
|
|||||||
new_parsed = cards.parse_card(new_card)
|
new_parsed = cards.parse_card(new_card)
|
||||||
if new_parsed["is_joker"]:
|
if new_parsed["is_joker"]:
|
||||||
game.extra_obstacles += 1
|
game.extra_obstacles += 1
|
||||||
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.")
|
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.", kind="joker")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
info = cards.get_obstacle_info(new_card)
|
info = cards.get_obstacle_info(new_card)
|
||||||
@@ -340,39 +308,12 @@ def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) ->
|
|||||||
|
|
||||||
msg = f"{player.name} played a Joker! '{obstacle_title}' is discarded"
|
msg = f"{player.name} played a Joker! '{obstacle_title}' is discarded"
|
||||||
msg += f" and replaced by '{new_title}'." if new_title else " (no replacement left in the deck)."
|
msg += f" and replaced by '{new_title}'." if new_title else " (no replacement left in the deck)."
|
||||||
add_game_event(db, game.id, msg)
|
add_game_event(db, game.id, msg, kind="joker")
|
||||||
|
|
||||||
return True, msg
|
return True, msg
|
||||||
|
|
||||||
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
|
||||||
@@ -380,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":
|
||||||
@@ -418,60 +381,31 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
|||||||
|
|
||||||
obj_name = obj_type.replace('_', ' ').title()
|
obj_name = obj_type.replace('_', ' ').title()
|
||||||
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}.")
|
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)
|
||||||
if obstacle:
|
if obstacle:
|
||||||
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.")
|
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.", kind="obstacle")
|
||||||
db.delete(obstacle)
|
db.delete(obstacle)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -483,4 +417,5 @@ def finish_game(db: Session, game_id: str):
|
|||||||
game.phase = "ended"
|
game.phase = "ended"
|
||||||
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. 🎉")
|
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,4 +1,4 @@
|
|||||||
import json
|
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
|
||||||
@@ -6,24 +6,31 @@ 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(
|
||||||
@@ -42,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.")
|
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):
|
||||||
"""
|
"""
|
||||||
@@ -64,63 +93,90 @@ 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)
|
||||||
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.")
|
game.last_rank_up_player_id = winner.id
|
||||||
|
db.add(game)
|
||||||
|
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank")
|
||||||
|
|
||||||
# Clear all votes
|
# Clear all votes
|
||||||
for v in votes:
|
for v in votes:
|
||||||
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"
|
||||||
add_game_event(db, game.id, "Phase changed: Deep Upkeep")
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
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")
|
|
||||||
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)
|
||||||
@@ -156,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")
|
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"),
|
||||||
@@ -31,16 +47,37 @@ def create_db_and_tables():
|
|||||||
("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
|
("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
|
||||||
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
|
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
|
||||||
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
|
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("player", "player_name", "VARCHAR DEFAULT ''"),
|
||||||
|
("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for table, col, def_type in columns:
|
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
|
||||||
|
session.execute(
|
||||||
|
text("UPDATE player SET player_name = name WHERE player_name = '' OR player_name IS NULL")
|
||||||
|
)
|
||||||
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,25 +1,235 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status, APIRouter
|
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
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, select
|
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 .models import GameEvent
|
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
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
|
|
||||||
app = FastAPI(title="Rats with Gats Remote Play API")
|
_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
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
configure_logging()
|
||||||
|
logger.info(
|
||||||
|
"Starting Rats with Gats backend (db=%s, dev_mode_default=%s)",
|
||||||
|
engine.url.render_as_string(hide_password=True),
|
||||||
|
crud.dev_mode_default(),
|
||||||
|
)
|
||||||
|
run_migrations()
|
||||||
|
logger.info("Database migrations applied")
|
||||||
|
|
||||||
|
purge_cfg = maintenance.purge_config()
|
||||||
|
purge_task = None
|
||||||
|
if purge_cfg.enabled:
|
||||||
|
purge_task = asyncio.create_task(maintenance.purge_loop(purge_cfg))
|
||||||
|
else:
|
||||||
|
logger.info("Game purge task disabled (PIRATS_PURGE_ENABLED)")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
if purge_task is not None:
|
||||||
|
purge_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await purge_task
|
||||||
|
logger.info("Shutting down")
|
||||||
|
|
||||||
|
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)
|
||||||
api = APIRouter()
|
api = APIRouter()
|
||||||
|
|
||||||
# Register startup event to create tables
|
# Every state mutation is a POST under /api/game/{game_id}/..., so one
|
||||||
@app.on_event("startup")
|
# middleware can notify a game's websocket clients after any successful write.
|
||||||
def on_startup():
|
GAME_PATH_RE = re.compile(r"^/api/game/([^/]+)/")
|
||||||
create_db_and_tables()
|
# 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 ---
|
||||||
|
|
||||||
@@ -28,7 +238,16 @@ 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
|
||||||
|
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}
|
return {"id": game.id}
|
||||||
|
|
||||||
@api.post("/game/{game_id}/join")
|
@api.post("/game/{game_id}/join")
|
||||||
@@ -43,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)
|
||||||
@@ -53,16 +284,41 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi
|
|||||||
if not game or not player:
|
if not game or not player:
|
||||||
raise HTTPException(status_code=404, detail="Game or Player not found")
|
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||||
|
|
||||||
events = db.exec(select(GameEvent).where(GameEvent.game_id == game_id).order_by(GameEvent.timestamp.asc())).all()
|
events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"game": game.model_dump(),
|
# 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,
|
||||||
|
# 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")
|
||||||
|
def get_game_events(
|
||||||
|
game_id: str,
|
||||||
|
before: Optional[float] = None,
|
||||||
|
limit: int = EVENT_PAGE_SIZE,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Pages back through the event log: returns the newest `limit` events older than `before`."""
|
||||||
|
game = crud.get_game(db, game_id)
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
|
limit = max(1, min(limit, 200))
|
||||||
|
events, have_more = crud.get_events_page(db, game_id, before=before, limit=limit)
|
||||||
|
return {
|
||||||
|
"events": [e.model_dump() for e in events],
|
||||||
|
"have_more": have_more,
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Register Modular Phase Routers ---
|
# --- Register Modular Phase Routers ---
|
||||||
@@ -72,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)
|
||||||
@@ -79,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,37 +1,53 @@
|
|||||||
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)
|
||||||
game_id: str = Field(foreign_key="game.id", index=True)
|
game_id: str = Field(foreign_key="game.id", index=True)
|
||||||
name: str = Field(default="")
|
name: str = Field(default="") # The Pi-Rat's current identity: "Recruit {smell}" until they earn a Name
|
||||||
|
player_name: str = Field(default="") # The human player's lobby name, shown alongside the Pi-Rat
|
||||||
is_ready: bool = Field(default=False)
|
is_ready: bool = Field(default=False)
|
||||||
rank: int = Field(default=1)
|
rank: int = Field(default=1)
|
||||||
hand_cards: str = Field(default="[]") # JSON string of list of card codes
|
hand_cards: str = Field(default="[]") # JSON string of list of card codes
|
||||||
role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
||||||
previous_role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
previous_role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
||||||
is_creator: bool = Field(default=False)
|
is_creator: bool = Field(default=False)
|
||||||
|
is_admin: bool = Field(default=False) # Admin privileges; the creator starts with them and can grant them to others
|
||||||
|
|
||||||
# Character sheet questions
|
# Character sheet questions
|
||||||
avatar_look: str = Field(default="")
|
avatar_look: str = Field(default="")
|
||||||
@@ -47,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="")
|
||||||
@@ -57,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")
|
||||||
@@ -97,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"
|
||||||
|
|
||||||
@@ -121,5 +148,22 @@ class GameEvent(SQLModel, table=True):
|
|||||||
game_id: str = Field(foreign_key="game.id", index=True)
|
game_id: str = Field(foreign_key="game.id", index=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"
|
||||||
|
# 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,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
import json
|
||||||
|
from fastapi import APIRouter, Request, Form, Depends, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
@@ -6,6 +7,102 @@ 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)):
|
||||||
@@ -22,10 +119,13 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
|
|||||||
players_data.append({
|
players_data.append({
|
||||||
"id": p.id,
|
"id": p.id,
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
|
"player_name": p.player_name,
|
||||||
"rank": p.rank,
|
"rank": p.rank,
|
||||||
"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"}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
from typing import Optional
|
from fastapi import APIRouter, Form, Depends, HTTPException
|
||||||
from fastapi import APIRouter, Form, Depends, HTTPException, status
|
|
||||||
from fastapi.responses import JSONResponse
|
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 TECHNIQUE_SUGGESTIONS
|
||||||
|
from .validation import sanitize_text, sanitize_name
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Secret Pirate Technique name pool (for the frontend's Suggest buttons)
|
||||||
|
@router.get("/suggestions/techniques")
|
||||||
|
def technique_suggestions():
|
||||||
|
return {"techniques": TECHNIQUE_SUGGESTIONS}
|
||||||
|
|
||||||
# Save basic character details
|
# Save basic character details
|
||||||
@router.post("/game/{game_id}/player/{player_id}/save-basic")
|
@router.post("/game/{game_id}/player/{player_id}/save-basic")
|
||||||
def player_save_basic_details(
|
def player_save_basic_details(
|
||||||
@@ -22,13 +28,16 @@ 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
|
||||||
|
if not player.completed_personal_2:
|
||||||
|
player.name = crud.recruit_name(player)
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates
|
# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates
|
||||||
@@ -80,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(
|
||||||
@@ -112,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
|
||||||
@@ -125,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(
|
||||||
@@ -142,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,5 +1,5 @@
|
|||||||
from typing import Optional
|
|
||||||
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
|
||||||
@@ -11,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"}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user