Compare commits
81 Commits
a074ff77b1
...
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 |
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "pirats",
|
||||
"runtimeExecutable": ".venv/bin/python",
|
||||
"runtimeArgs": ["-m", "uvicorn", "pirats.main:app", "--host", "127.0.0.1", "--port", "8123"],
|
||||
"port": 8123,
|
||||
"env": { "DATABASE_URL": "sqlite:///smoke_test.db", "PYTHONPATH": "src" }
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,3 +5,7 @@ result
|
||||
*.pdf
|
||||
# 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.
|
||||
|
||||
## Database migrations
|
||||
|
||||
Schema changes use Alembic (migrations live in `src/pirats/migrations/`, shipped inside the package). The app applies them automatically at startup via `pirats.database.run_migrations()`; databases predating Alembic are normalized and stamped at the `0001` baseline first.
|
||||
|
||||
After editing `src/pirats/models.py`, generate a migration and sanity-check it:
|
||||
|
||||
```
|
||||
.venv/bin/alembic revision --autogenerate -m "describe the change"
|
||||
.venv/bin/alembic upgrade head # or just start the app
|
||||
```
|
||||
|
||||
**Note on adding NOT NULL columns in SQLite:**
|
||||
SQLite does not support adding a `NOT NULL` column without a default value to an existing table. When Alembic auto-generates a migration that adds a `nullable=False` column, you MUST manually edit the migration to include `server_default="..."` in the `sa.Column` definition (e.g., `server_default=""` for strings) before applying it. Otherwise, the migration will crash with `Cannot add a NOT NULL column with default value NULL`.
|
||||
|
||||
Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list exists only to upgrade pre-Alembic databases to the baseline and must not grow.
|
||||
|
||||
## Learning during testing
|
||||
|
||||
When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions.
|
||||
|
||||
- If Alembic autogeneration says the target database is not up to date, create a temporary database with `DATABASE_URL=sqlite:////tmp/<name>.db .venv/bin/alembic upgrade head`, then run the autogeneration command with that same `DATABASE_URL`.
|
||||
|
||||
## Versioning & changelog
|
||||
|
||||
The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
|
||||
|
||||
- Bump `VERSION` by one on **every** commit.
|
||||
- When a commit changes something players can see, add an entry to the **top** of `CHANGELOG` (`{ version, date, changes: [...] }`) describing it in player-facing terms. Group everything shipping under one version into a single entry.
|
||||
- The changelog is for players: skip refactors, tests, tooling, and other internal-only changes. A commit with no user-facing change bumps `VERSION` but adds no entry.
|
||||
|
||||
## Work order
|
||||
|
||||
You will be working on tasks in [TODO.md](./TODO.md]. Work on at most two tasks at a time (one is preferable, but two is fine if they dove tail really nicely), and after you finish each task, make a git commit and update the TODO list to check off the completed task.
|
||||
|
||||
36
README.md
36
README.md
@@ -61,6 +61,20 @@ uvicorn pirats.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
Once running, access the web UI at [http://localhost:8000](http://localhost:8000).
|
||||
|
||||
#### 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
|
||||
@@ -99,10 +113,22 @@ services.pirats = {
|
||||
host = "127.0.0.1";
|
||||
port = 8000;
|
||||
databasePath = "/var/lib/pirats/rats_with_gats.db";
|
||||
logFile = "/var/log/pirats/pirats.log"; # rotating; stderr also goes to the journal
|
||||
logLevel = "info";
|
||||
openFirewall = false; # Set to true to open ports in the firewall
|
||||
purge = {
|
||||
enable = true; # periodically remove old finished/inactive games
|
||||
intervalHours = 24;
|
||||
finishedDays = 14; # purge games finished more than 14 days ago
|
||||
inactiveDays = 30; # ...or with no activity for 30 days
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The service writes its log to `logFile` (under the systemd-managed `/var/log/pirats`
|
||||
`LogsDirectory`, so the sandboxed `DynamicUser` can write to it) and additionally
|
||||
to the systemd journal via stderr (`journalctl -u pirats`).
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
@@ -147,8 +173,16 @@ pirats/
|
||||
|
||||
### Frontend Development
|
||||
|
||||
Run the backend (`pirats --reload`) and the Vite dev server side by side; Vite proxies `/api` to port 8000:
|
||||
Start the backend and Vite dev server together from the repository root. Pressing Ctrl-C stops both:
|
||||
|
||||
```bash
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
To run only the frontend, start the backend separately on port 8000, then run:
|
||||
|
||||
```bash
|
||||
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";
|
||||
version = "0.1.0";
|
||||
src = ./frontend;
|
||||
npmDepsHash = "sha256-ekYBi0oUjtnsfdig2B0yrmT7KbSc9IB1DVbTCRwYI24=";
|
||||
npmDepsHash = "sha256-S2CXmFgovGD40wS4bTsiVF84J3zEQK6qrg7EPy+ojPY=";
|
||||
installPhase = ''
|
||||
mkdir -p $out
|
||||
cp -r dist $out/
|
||||
@@ -45,9 +45,11 @@
|
||||
];
|
||||
|
||||
propagatedBuildInputs = with python.pkgs; [
|
||||
alembic
|
||||
fastapi
|
||||
sqlmodel
|
||||
uvicorn
|
||||
websockets
|
||||
python-multipart
|
||||
httpx
|
||||
];
|
||||
@@ -110,11 +112,59 @@
|
||||
default = "/var/lib/pirats/rats_with_gats.db";
|
||||
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 {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
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 {
|
||||
@@ -127,15 +177,29 @@
|
||||
|
||||
environment = {
|
||||
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 = {
|
||||
ExecStart = "${cfg.package}/bin/pirats --host ${cfg.host} --port ${toString cfg.port}";
|
||||
Restart = "always";
|
||||
|
||||
|
||||
# Sandboxing and security
|
||||
DynamicUser = true;
|
||||
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";
|
||||
|
||||
ProtectSystem = "strict";
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -50,6 +53,33 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"vite": "^8.0.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/alegreya-sans": "^5.2.8",
|
||||
"@fontsource/alegreya-sc": "^5.2.8",
|
||||
"@fontsource/pirata-one": "^5.2.8",
|
||||
"svelte-spa-router": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
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 Dashboard from './pages/Dashboard.svelte';
|
||||
import Admin from './pages/Admin.svelte';
|
||||
import CornerMenu from './components/CornerMenu.svelte';
|
||||
|
||||
const routes = {
|
||||
'/': Home,
|
||||
@@ -15,4 +16,5 @@
|
||||
|
||||
<main>
|
||||
<Router {routes} />
|
||||
<CornerMenu />
|
||||
</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/card.css';
|
||||
@import './assets/css/welcome.css';
|
||||
@import './assets/css/lobby.css';
|
||||
@import './assets/css/character.css';
|
||||
@import './assets/css/scene-setup.css';
|
||||
@import './assets/css/scene-play.css';
|
||||
@import './assets/css/card.css';
|
||||
@import './assets/css/upkeep.css';
|
||||
@import './assets/css/admin.css';
|
||||
|
||||
@@ -1,22 +1,123 @@
|
||||
/* --- Admin Panel --- */
|
||||
.admin-players-list {
|
||||
/* --- Admin panel --- */
|
||||
.admin-page {
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.admin-page h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: 2.2rem;
|
||||
color: var(--accent);
|
||||
margin-bottom: 1.5rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.admin-key-chip {
|
||||
background: var(--well);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-summary-details {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-join-code {
|
||||
flex: 0 0 auto;
|
||||
min-width: 10.5rem;
|
||||
padding: 0.85rem 1.1rem 0.95rem;
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--well));
|
||||
color: var(--text);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-soft);
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.admin-join-code:hover {
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--well));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.admin-join-code:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.admin-join-code.copied {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.admin-join-code-label {
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-join-code strong {
|
||||
display: block;
|
||||
color: var(--accent);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.admin-join-code-hint {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.admin-summary {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-join-code {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: var(--well);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-sm);
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.admin-player-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
.admin-table th, .admin-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--edge-soft);
|
||||
}
|
||||
|
||||
.player-info-basic {
|
||||
font-size: 1.05rem;
|
||||
.admin-table th {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.link-copy-action {
|
||||
@@ -26,3 +127,38 @@
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.link-copy-action > .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5.5rem;
|
||||
height: 2.3rem;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- Game over --- */
|
||||
.gameover-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.gameover-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gameover-card h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: 2.6rem;
|
||||
color: var(--accent);
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.gameover-card .sheet-group {
|
||||
text-align: left;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
231
frontend/src/assets/css/base.css
Normal file
231
frontend/src/assets/css/base.css
Normal file
@@ -0,0 +1,231 @@
|
||||
/* --- Reset & page setup --- */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(ellipse at 50% -20%, color-mix(in srgb, var(--accent) 7%, transparent) 0%, transparent 55%),
|
||||
radial-gradient(circle at 50% 60%, var(--bg) 0%, var(--bg-deep) 100%);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--deep);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-deep);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--accent) 50%, var(--bg-deep));
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* --- App layout --- */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Top padding clears the fixed corner menu */
|
||||
.dashboard-container {
|
||||
padding: 3.5rem 2rem 4rem;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.phase-view-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr) minmax(300px, 0.8fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.phase-main-column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1101px) {
|
||||
.phase-view-layout.log-collapsed {
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.phase-view-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dashboard-container {
|
||||
padding: 3.5rem 1rem 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.view-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.view-header h2,
|
||||
.lobby-view h2,
|
||||
.scene-setup-view h2,
|
||||
.between-scenes-view h2 {
|
||||
font-size: 2.1rem;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-muted);
|
||||
max-width: 720px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
/* --- Loader / spinner widgets --- */
|
||||
.loader-container {
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid var(--edge-soft);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--edge-soft);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* --- Text utilities --- */
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.italic { font-style: italic; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-mono { font-family: ui-monospace, monospace; }
|
||||
.underline { text-decoration: underline; }
|
||||
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-accent, .gold-text { color: var(--accent); }
|
||||
.text-danger { color: var(--danger); }
|
||||
.text-success, .success-text { color: var(--success); font-weight: 600; }
|
||||
.text-warning { color: var(--warning); }
|
||||
.text-deep, .deep-text { color: var(--deep); }
|
||||
|
||||
.info-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footnote, .footnote-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.footnote-desc {
|
||||
margin-top: 0.15rem;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
/* --- Spacing & layout utilities --- */
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mt-6 { margin-top: 1.5rem; }
|
||||
.mt-8 { margin-top: 2rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-6 { margin-bottom: 1.5rem; }
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||
.margin-top { margin-top: 1.5rem; }
|
||||
.margin-top-small { margin-top: 0.5rem; }
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-grow { flex: 1; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.w-full { width: 100%; }
|
||||
.max-w-4xl { max-width: 56rem; }
|
||||
.max-w-5xl { max-width: 64rem; }
|
||||
.space-y-2 > * + * { margin-top: 0.5rem; }
|
||||
.space-y-6 > * + * { margin-top: 1.5rem; }
|
||||
|
||||
.center-block { margin: 1rem auto; max-width: 500px; }
|
||||
.inline-form { display: flex; gap: 0.5rem; width: 100%; }
|
||||
.inline-group { display: flex; gap: 0.5rem; width: 100%; }
|
||||
|
||||
/* --- Ghost World --- */
|
||||
.ghost-world {
|
||||
filter: grayscale(95%) brightness(0.85) sepia(15%) contrast(1.05);
|
||||
transition: filter 1.2s ease-in-out;
|
||||
}
|
||||
@@ -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 {
|
||||
width: 32px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--text-muted);
|
||||
background: var(--bg-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 2px 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 900;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-mini.base-card {
|
||||
border-color: var(--gold);
|
||||
color: var(--gold);
|
||||
background: rgba(212,175,55,0.1);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.card-mini.rotated {
|
||||
@@ -25,13 +47,11 @@
|
||||
}
|
||||
|
||||
.card-mini.success {
|
||||
border-color: var(--neon-emerald);
|
||||
color: var(--neon-emerald);
|
||||
box-shadow: 0 0 0 2px var(--success);
|
||||
}
|
||||
|
||||
.card-mini.failure {
|
||||
border-color: var(--red-suit);
|
||||
color: var(--red-suit);
|
||||
box-shadow: 0 0 0 2px var(--danger);
|
||||
}
|
||||
|
||||
.card-mini .val {
|
||||
@@ -48,17 +68,15 @@
|
||||
position: absolute;
|
||||
bottom: -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 {
|
||||
width: 75px;
|
||||
height: 112px;
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid var(--glass-border);
|
||||
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -83,29 +101,6 @@
|
||||
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 {
|
||||
font-size: 1.8rem;
|
||||
text-align: center;
|
||||
@@ -113,17 +108,13 @@
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.card-medium.suit-c .card-center, .card-medium.suit-s .card-center { color: var(--black-suit); }
|
||||
.card-medium.suit-h .card-center, .card-medium.suit-d .card-center { color: var(--red-suit); }
|
||||
|
||||
/* Card Widget (Large) */
|
||||
/* --- Large (the player's hand) --- */
|
||||
.card-large {
|
||||
width: 110px;
|
||||
height: 165px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--glass-border);
|
||||
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||
border-radius: var(--radius-md);
|
||||
border-width: 2px;
|
||||
box-shadow: var(--shadow-card-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -134,29 +125,7 @@
|
||||
|
||||
.card-large:hover {
|
||||
transform: translateY(-8px) scale(1.05);
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 10px 25px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.card-large.suit-c, .card-large.suit-s {
|
||||
border-color: rgba(226, 232, 240, 0.15);
|
||||
}
|
||||
.card-large.suit-c:hover, .card-large.suit-s:hover {
|
||||
border-color: var(--black-suit);
|
||||
box-shadow: 0 10px 25px var(--black-glow);
|
||||
}
|
||||
|
||||
.card-large.suit-h, .card-large.suit-d {
|
||||
border-color: rgba(255, 42, 95, 0.15);
|
||||
}
|
||||
.card-large.suit-h:hover, .card-large.suit-d:hover {
|
||||
border-color: var(--red-suit);
|
||||
box-shadow: 0 10px 25px var(--red-glow);
|
||||
}
|
||||
|
||||
.card-large.joker-card {
|
||||
border-color: var(--gold);
|
||||
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
|
||||
box-shadow: var(--shadow-card-lifted), 0 0 14px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.card-large .card-corner {
|
||||
@@ -175,16 +144,6 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card-large.suit-c .val, .card-large.suit-c .suit,
|
||||
.card-large.suit-s .val, .card-large.suit-s .suit {
|
||||
color: var(--black-suit);
|
||||
}
|
||||
|
||||
.card-large.suit-h .val, .card-large.suit-h .suit,
|
||||
.card-large.suit-d .val, .card-large.suit-d .suit {
|
||||
color: var(--red-suit);
|
||||
}
|
||||
|
||||
.card-large .card-center {
|
||||
font-size: 2.8rem;
|
||||
text-align: center;
|
||||
@@ -192,9 +151,6 @@
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.card-large.suit-c .card-center, .card-large.suit-s .card-center { color: var(--black-suit); }
|
||||
.card-large.suit-h .card-center, .card-large.suit-d .card-center { color: var(--red-suit); }
|
||||
|
||||
.card-large .card-tech-overlay {
|
||||
position: absolute;
|
||||
bottom: 0.5rem;
|
||||
@@ -203,10 +159,10 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tech-tag {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--gold);
|
||||
.card-large .tech-tag {
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent-bright);
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
@@ -220,7 +176,7 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Drag and Drop Interface Styles */
|
||||
/* --- Drag & drop --- */
|
||||
.card-large[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
@@ -236,9 +192,9 @@
|
||||
}
|
||||
|
||||
.obstacle-item.drag-over {
|
||||
border-color: var(--gold) !important;
|
||||
background: rgba(212, 175, 55, 0.1) !important;
|
||||
box-shadow: 0 0 20px rgba(212, 175, 55, 0.2) !important;
|
||||
border-color: var(--accent) !important;
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent) !important;
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 20%, transparent) !important;
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
transition: 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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -10,20 +10,18 @@
|
||||
.creation-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
.creation-grid > .card {
|
||||
grid-column: span 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.record-line {
|
||||
background: rgba(0,0,0,0.2);
|
||||
background: var(--well);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 3px solid var(--gold);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.delegation-box {
|
||||
@@ -33,14 +31,15 @@
|
||||
}
|
||||
|
||||
.delegation-box h4 {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.answer-box {
|
||||
font-style: italic;
|
||||
color: var(--neon-cyan);
|
||||
color: var(--accent-bright);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
@@ -50,14 +49,6 @@
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
@@ -65,8 +56,8 @@
|
||||
.inbox-item {
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 3px solid var(--neon-cyan);
|
||||
background: rgba(0, 242, 254, 0.02);
|
||||
border-left: 3px solid var(--deep);
|
||||
background: color-mix(in srgb, var(--deep) 4%, transparent);
|
||||
}
|
||||
|
||||
.inbox-item label {
|
||||
@@ -76,26 +67,22 @@
|
||||
}
|
||||
|
||||
.submitted-techniques .tech-chip {
|
||||
background: rgba(212,175,55,0.05);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--gold);
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.tech-chip {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tech-assignment-pill {
|
||||
background: rgba(0, 242, 254, 0.05);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--deep) 6%, transparent);
|
||||
border: 1px solid var(--deep);
|
||||
color: var(--text);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
@@ -104,8 +91,8 @@
|
||||
}
|
||||
|
||||
.card-rank {
|
||||
background: var(--neon-cyan);
|
||||
color: var(--bg-dark);
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
font-weight: 900;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -114,31 +101,30 @@
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* --- Character Sheet layout --- */
|
||||
/* --- Character sheet layout --- */
|
||||
.character-sheet-details {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sheet-group {
|
||||
background: rgba(0,0,0,0.15);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border-left: 2px solid var(--gold-dark);
|
||||
}
|
||||
|
||||
.sheet-group h4 {
|
||||
color: var(--gold);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
@media (min-width: 768px) {
|
||||
.character-sheet-details {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.sheet-main-column {
|
||||
flex: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
.sheet-side-column {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.techniques-list-sheet {
|
||||
@@ -150,37 +136,23 @@
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.footnote-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.15rem;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.margin-top-small {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Visual & Tactile Technique Assignment --- */
|
||||
/* --- Face-card technique assignment (drag & drop) --- */
|
||||
.face-tech-drag-form {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.available-techniques-container {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: 12px;
|
||||
background: var(--well);
|
||||
border: 1px dashed var(--edge);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: inset 0 0 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.available-techniques-container h4 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--gold);
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -200,18 +172,17 @@
|
||||
}
|
||||
|
||||
.draggable-tech-chip {
|
||||
background: linear-gradient(135deg, rgba(22, 36, 59, 0.9) 0%, rgba(13, 23, 38, 0.9) 100%);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--text);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -219,8 +190,7 @@
|
||||
|
||||
.draggable-tech-chip:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4), 0 0 10px rgba(0, 242, 254, 0.3);
|
||||
border-color: var(--accent-bright);
|
||||
}
|
||||
|
||||
.draggable-tech-chip:active {
|
||||
@@ -232,26 +202,20 @@
|
||||
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 {
|
||||
opacity: 0.2;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
border-color: var(--text-muted);
|
||||
border-color: var(--edge);
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
color: var(--gold);
|
||||
color: var(--accent);
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Slots Grid */
|
||||
/* Face-card slots */
|
||||
.face-cards-slots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -276,9 +240,9 @@
|
||||
max-width: 220px;
|
||||
aspect-ratio: 2 / 3;
|
||||
min-height: 280px;
|
||||
background: linear-gradient(135deg, rgba(13, 23, 38, 0.8) 0%, rgba(7, 11, 18, 0.9) 100%);
|
||||
border: 2px dashed rgba(212, 175, 55, 0.3);
|
||||
border-radius: 12px;
|
||||
background: var(--well);
|
||||
border: 2px dashed var(--edge-accent);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -286,27 +250,25 @@
|
||||
transition: var(--transition-smooth);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.face-card-slot:hover {
|
||||
border-color: rgba(212, 175, 55, 0.7);
|
||||
box-shadow: 0 12px 30px rgba(212, 175, 55, 0.15);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.face-card-slot.drag-over {
|
||||
border-color: var(--neon-cyan);
|
||||
border-color: var(--accent-bright);
|
||||
border-style: solid;
|
||||
background: rgba(0, 242, 254, 0.05);
|
||||
box-shadow: 0 0 20px rgba(0, 242, 254, 0.2);
|
||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.face-card-slot.has-assignment {
|
||||
border-style: solid;
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 15px rgba(212, 175, 55, 0.15);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 5%, var(--well));
|
||||
}
|
||||
|
||||
.card-bg-letter {
|
||||
@@ -314,10 +276,9 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 8rem;
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.025);
|
||||
font-family: var(--font-display);
|
||||
font-size: 9rem;
|
||||
color: color-mix(in srgb, var(--text) 4%, transparent);
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
@@ -325,7 +286,7 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -342,7 +303,7 @@
|
||||
|
||||
.face-card-slot.has-assignment .card-slot-header,
|
||||
.face-card-slot.has-assignment .card-slot-footer {
|
||||
color: var(--gold);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-slot-body {
|
||||
@@ -360,29 +321,29 @@
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
margin-bottom: 0.75rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.face-card-slot.has-assignment .card-title {
|
||||
color: var(--gold);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.drop-zone-placeholder {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--edge);
|
||||
background: var(--well);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem 0.75rem;
|
||||
transition: var(--transition-smooth);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.face-card-slot:hover .drop-zone-placeholder {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: var(--text);
|
||||
border-color: var(--edge-accent);
|
||||
}
|
||||
|
||||
.assigned-tech-content {
|
||||
@@ -399,14 +360,14 @@
|
||||
}
|
||||
|
||||
.assigned-tech-chip {
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--text);
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-soft);
|
||||
word-break: break-word;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
@@ -418,22 +379,21 @@
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #c0392b;
|
||||
border: 1px solid #e74c3c;
|
||||
color: white;
|
||||
background: var(--danger);
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 70%, var(--text));
|
||||
color: var(--text-inverse);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.5);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.remove-tech-btn:hover {
|
||||
background: #e74c3c;
|
||||
transform: scale(1.1);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
|
||||
@@ -1,39 +1,108 @@
|
||||
/* --- UI Panels & Glassmorphism --- */
|
||||
/* --- Panels --- */
|
||||
.glass-panel {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
border-color: rgba(212, 175, 55, 0.35);
|
||||
box-shadow: 0 8px 32px rgba(212, 175, 55, 0.05);
|
||||
border-color: var(--edge-accent);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-ocean-light);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||
transition: var(--transition-smooth);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--gold);
|
||||
color: var(--accent);
|
||||
font-size: 1.35rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-bottom: 1px solid var(--edge-accent);
|
||||
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 {
|
||||
margin-bottom: 1.25rem;
|
||||
text-align: left;
|
||||
@@ -47,40 +116,35 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
.input-field, .form-control, .select-field, .copy-input {
|
||||
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);
|
||||
padding: 0.7rem 1rem;
|
||||
background: var(--well);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.input-field: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;
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 10px rgba(0, 242, 254, 0.2);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.select-field {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(7, 11, 18, 0.8);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
transition: var(--transition-smooth);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--neon-cyan);
|
||||
.input-large {
|
||||
padding: 0.9rem 1.1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
@@ -92,96 +156,116 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; }
|
||||
.select-xsmall { padding: 0.4rem 0.5rem; font-size: 0.85rem; width: 140px; }
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.checkbox-label:has(input:disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.objectives-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* --- Buttons --- */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-family: var(--font-body);
|
||||
padding: 0.7rem 1.5rem;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-smooth);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-dark) 100%);
|
||||
color: var(--text-dark);
|
||||
box-shadow: 0 4px 15px var(--gold-glow);
|
||||
background: linear-gradient(160deg, var(--accent-bright) 0%, var(--accent) 45%, var(--accent-dark) 100%);
|
||||
border-color: var(--accent-dark);
|
||||
color: var(--text-inverse);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(212, 175, 55, 0.6);
|
||||
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
border-color: var(--edge);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,0.15);
|
||||
background: color-mix(in srgb, var(--text) 15%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-deep {
|
||||
background: linear-gradient(135deg, #152238 0%, #0d1726 100%);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.1);
|
||||
background: color-mix(in srgb, var(--deep) 10%, var(--surface));
|
||||
border-color: var(--deep);
|
||||
color: var(--deep);
|
||||
}
|
||||
|
||||
.btn-deep:hover:not(:disabled) {
|
||||
background: var(--neon-cyan);
|
||||
color: var(--text-dark);
|
||||
box-shadow: 0 4px 20px rgba(0, 242, 254, 0.4);
|
||||
background: var(--deep);
|
||||
color: var(--text-inverse);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #7a1c1c 0%, #c0392b 100%);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 15px rgba(192, 57, 43, 0.2);
|
||||
background: color-mix(in srgb, var(--danger) 18%, var(--surface));
|
||||
border-color: var(--danger);
|
||||
color: color-mix(in srgb, var(--danger) 70%, var(--text));
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--danger);
|
||||
color: var(--text-inverse);
|
||||
transform: translateY(-2px);
|
||||
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 {
|
||||
background: transparent;
|
||||
border: 2px solid var(--gold);
|
||||
color: var(--gold);
|
||||
box-shadow: 0 0 10px rgba(212,175,55,0.1);
|
||||
border: 2px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-gold:hover:not(:disabled) {
|
||||
background: var(--gold);
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 0 20px var(--gold-glow);
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.btn-full, .btn-block { width: 100%; }
|
||||
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
|
||||
.btn-large { padding: 0.9rem 2rem; font-size: 1.15rem; }
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
@@ -191,111 +275,215 @@
|
||||
}
|
||||
|
||||
.glow-effect:hover {
|
||||
filter: brightness(1.2);
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
/* --- Event Log --- */
|
||||
.event-log-fab {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--gold);
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5), 0 0 10px rgba(212, 175, 55, 0.2);
|
||||
/* --- Alerts --- */
|
||||
.alert {
|
||||
padding: 0.75rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
border: 1px solid var(--danger);
|
||||
color: color-mix(in srgb, var(--danger) 75%, var(--text));
|
||||
}
|
||||
|
||||
.deep-chip {
|
||||
border-color: var(--deep);
|
||||
background: color-mix(in srgb, var(--deep) 5%, transparent);
|
||||
}
|
||||
|
||||
.pirat-chip {
|
||||
border-color: var(--pirat);
|
||||
background: color-mix(in srgb, var(--pirat) 5%, transparent);
|
||||
}
|
||||
|
||||
/* --- Badges --- */
|
||||
.creator-badge {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.deep-badge, .pirat-badge, .ghost-badge {
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deep-badge {
|
||||
background: color-mix(in srgb, var(--deep) 14%, transparent);
|
||||
border: 1px solid var(--deep);
|
||||
color: var(--deep);
|
||||
}
|
||||
|
||||
.pirat-badge {
|
||||
background: color-mix(in srgb, var(--pirat) 14%, transparent);
|
||||
border: 1px solid var(--pirat);
|
||||
color: var(--pirat);
|
||||
}
|
||||
|
||||
.ghost-badge {
|
||||
background: color-mix(in srgb, var(--ghost) 14%, transparent);
|
||||
border: 1px solid var(--ghost);
|
||||
color: var(--ghost);
|
||||
}
|
||||
|
||||
|
||||
/* --- Waiting indicator --- */
|
||||
.waiting-box, .waiting-indicator {
|
||||
font-size: 0.95rem;
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
transition: var(--transition-smooth);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.event-log-fab:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6), 0 0 15px rgba(212, 175, 55, 0.4);
|
||||
}
|
||||
|
||||
.event-log-panel {
|
||||
/* --- Modal overlay (shared by popups) --- */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
bottom: 90px;
|
||||
right: 20px;
|
||||
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);
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
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;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.event-log-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.1rem;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.event-log-container {
|
||||
flex: 1;
|
||||
.modal-box {
|
||||
position: relative;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.75rem;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-deep);
|
||||
}
|
||||
|
||||
.event-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
.modal-box h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
font-size: 0.9rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--neon-cyan);
|
||||
.modal-box.sheet-modal {
|
||||
max-width: 520px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
.modal-box.character-sheet-modal {
|
||||
max-width: 1260px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
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 {
|
||||
color: var(--text-primary);
|
||||
.modal-close:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
}
|
||||
|
||||
/* --- Graphical tooltip (lib/tooltip.js action) --- */
|
||||
.tooltip-pop {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
max-width: 280px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--edge-accent);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-deep);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.tooltip-pop.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-card {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 900;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-card.tt-red { color: var(--suit-red); }
|
||||
.tooltip-pop .tt-card.tt-black { color: var(--suit-black); }
|
||||
.tooltip-pop .tt-card.tt-joker { color: var(--accent); }
|
||||
|
||||
.tooltip-pop .tt-color {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 700;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-color-red {
|
||||
color: var(--suit-red);
|
||||
background: color-mix(in srgb, var(--suit-red) 16%, transparent);
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-color-black {
|
||||
color: var(--suit-black);
|
||||
background: color-mix(in srgb, var(--suit-black) 16%, transparent);
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-theme {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-row {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.tooltip-pop .tt-label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
max-width: 700px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.lobby-view h2 {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 2.2rem;
|
||||
color: var(--gold);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.lobby-status-box {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.player-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.player-chip {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.player-chip.current-user {
|
||||
background: rgba(0, 242, 254, 0.15);
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 10px rgba(0,242,254,0.15);
|
||||
}
|
||||
|
||||
.player-chip.voted-chip {
|
||||
border-color: var(--neon-emerald);
|
||||
background-color: rgba(0,255,135,0.05);
|
||||
}
|
||||
|
||||
.player-chip.not-voted-chip {
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.creator-badge {
|
||||
background: var(--gold);
|
||||
color: var(--text-dark);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.links-box {
|
||||
text-align: left;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.teaching-box {
|
||||
text-align: left;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.teaching-box .checkbox-label.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.teaching-box .info-text {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.link-item h4 {
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.lobby-join-code-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.copy-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -80,27 +46,17 @@
|
||||
|
||||
.copy-input {
|
||||
flex: 1;
|
||||
background: #05080e;
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-body);
|
||||
background: var(--bg-deep);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.copy-input.admin-input {
|
||||
border-color: var(--gold-dark);
|
||||
color: var(--gold);
|
||||
border-color: var(--edge-accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-link-item {
|
||||
border-top: 1px dashed rgba(212, 175, 55, 0.3);
|
||||
border-top: 1px dashed var(--edge-accent);
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 2rem;
|
||||
grid-template-columns: 180px minmax(0, 1.7fr) minmax(300px, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* The middle column stacks hand / challenge / obstacle cards. */
|
||||
.table-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Stretch the log column to the full row height so its sticky panel can travel. */
|
||||
.log-column {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
/* When the inline log is collapsed, reclaim its column (3-col layout only). */
|
||||
@media (min-width: 1101px) {
|
||||
.scene-view-layout.log-collapsed {
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Corner button that toggles the inline Event Log without moving. */
|
||||
.log-reopen-btn {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-deep);
|
||||
color: var(--text);
|
||||
font-weight: bold;
|
||||
font-family: var(--font-heading);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.log-reopen-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@@ -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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-bottom: 1px solid var(--edge-accent);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -28,11 +219,11 @@
|
||||
|
||||
.deck-counter {
|
||||
font-size: 0.85rem;
|
||||
color: var(--gold);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* --- Active Obstacle Item Display --- */
|
||||
/* --- Obstacles --- */
|
||||
.obstacles-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -40,10 +231,10 @@
|
||||
}
|
||||
|
||||
.obstacle-item {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
background: rgba(7, 11, 18, 0.4);
|
||||
background: var(--well);
|
||||
display: grid;
|
||||
grid-template-columns: 0.8fr 2fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
@@ -51,6 +242,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.obstacle-item.in-challenge {
|
||||
box-shadow: 0 0 0 2px var(--deep);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.obstacle-item {
|
||||
grid-template-columns: 0.8fr 2fr 1.5fr;
|
||||
@@ -63,20 +258,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Color theme mappings for suits */
|
||||
.suit-c { border-left: 4px solid var(--black-suit); }
|
||||
.suit-s { border-left: 4px solid var(--black-suit); }
|
||||
.suit-h { border-left: 4px solid var(--red-suit); }
|
||||
.suit-d { border-left: 4px solid var(--red-suit); }
|
||||
/* Suit accent stripes */
|
||||
.suit-c, .suit-s { border-left: 4px solid var(--suit-black); }
|
||||
.suit-h, .suit-d { border-left: 4px solid var(--suit-red); }
|
||||
|
||||
.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;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
padding: 2px; /* room for the rings, which overflow:hidden would clip */
|
||||
}
|
||||
|
||||
.stack-card {
|
||||
position: relative;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.stack-card + .stack-card {
|
||||
margin-top: calc(var(--peek) - var(--card-h));
|
||||
}
|
||||
|
||||
.stack-card.is-original {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.stack-card.is-success {
|
||||
box-shadow: 0 0 0 2px var(--success);
|
||||
}
|
||||
|
||||
.stack-card.is-failure {
|
||||
box-shadow: 0 0 0 2px var(--danger);
|
||||
}
|
||||
|
||||
.suit-badge {
|
||||
@@ -84,8 +303,8 @@
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.suit-c .suit-badge, .suit-s .suit-badge { color: var(--black-suit); }
|
||||
.suit-h .suit-badge, .suit-d .suit-badge { color: var(--red-suit); }
|
||||
.suit-c .suit-badge, .suit-s .suit-badge { color: var(--suit-black); }
|
||||
.suit-h .suit-badge, .suit-d .suit-badge { color: var(--suit-red); }
|
||||
|
||||
.card-code {
|
||||
font-size: 0.8rem;
|
||||
@@ -94,10 +313,21 @@
|
||||
}
|
||||
|
||||
.obstacle-details h4 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
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 {
|
||||
@@ -106,29 +336,32 @@
|
||||
}
|
||||
|
||||
.obstacle-value-display {
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||
border: 1px dashed var(--edge-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.obstacle-successes-display {
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 255, 135, 0.04);
|
||||
border: 1px dashed rgba(0, 255, 135, 0.25);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--success) 5%, transparent);
|
||||
border: 1px dashed color-mix(in srgb, var(--success) 30%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.obstacle-successes-display .val-number {
|
||||
color: var(--neon-emerald);
|
||||
color: var(--success);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.val-label {
|
||||
@@ -141,132 +374,104 @@
|
||||
font-size: 1.8rem;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 900;
|
||||
color: var(--gold);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.played-column {
|
||||
grid-column: span 4;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
.clear-obstacle-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.played-column {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.played-column h5 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.column-cards-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* --- Challenges Section --- */
|
||||
.challenges-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
/* --- Challenges --- (framed in the Deep's teal so red/black always means suit color) */
|
||||
/* The challenge-area card IS the box; the challenge content sits flush inside it. */
|
||||
.challenge-area-card {
|
||||
border-color: color-mix(in srgb, var(--deep) 45%, transparent);
|
||||
}
|
||||
|
||||
.challenge-item {
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.challenge-item + .challenge-item {
|
||||
border-top: 1px solid var(--edge-soft);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.challenge-item h4 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--neon-cyan);
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--deep);
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.challenge-item .desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.applied-obstacles h5 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.applied-obs-row {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
.challenge-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.obs-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.obs-info .symbol {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.play-card-form {
|
||||
.challenge-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select-xsmall {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
width: 140px;
|
||||
background: var(--bg-dark);
|
||||
.tax-callout {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
/* --- Deep Control panel --- */
|
||||
.deep-text {
|
||||
color: var(--neon-cyan) !important;
|
||||
.tax-callout p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.deep-divider {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, rgba(0, 242, 254, 0), rgba(0, 242, 254, 0.4), rgba(0, 242, 254, 0));
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.obstacles-checkboxes {
|
||||
/* --- Challenge area (middle column) --- */
|
||||
/* Obstacles applied to an active Challenge, pulled up into the Challenge panel */
|
||||
.challenge-obstacles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--glass-border);
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
/* The Deep's call-a-challenge + end-scene controls live below any open Challenges */
|
||||
.deep-challenge-controls {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* When a Challenge is active the rest of the Obstacle list collapses out of the way */
|
||||
.obstacle-collapse > summary {
|
||||
cursor: pointer;
|
||||
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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* --- Scene Setup / Role Choose --- */
|
||||
/* --- Scene setup / role choice --- */
|
||||
.setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
@@ -6,6 +6,11 @@
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.setup-grid.single {
|
||||
grid-template-columns: minmax(0, 700px);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.setup-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -22,62 +27,44 @@
|
||||
.role-btn {
|
||||
padding: 1.5rem;
|
||||
font-size: 1.2rem;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 2px solid rgba(255,255,255,0.1);
|
||||
background: var(--well);
|
||||
border: 2px solid var(--edge);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-btn.pirat-btn:hover, .role-btn.pirat-btn.active {
|
||||
border-color: var(--neon-emerald);
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(0,255,135,0.06);
|
||||
box-shadow: 0 0 15px rgba(0,255,135,0.15);
|
||||
.role-btn.pirat-btn:hover:not(:disabled), .role-btn.pirat-btn.active {
|
||||
border-color: var(--pirat);
|
||||
color: var(--text);
|
||||
background-color: color-mix(in srgb, var(--pirat) 8%, transparent);
|
||||
}
|
||||
|
||||
.role-btn.deep-btn:hover, .role-btn.deep-btn.active {
|
||||
border-color: var(--neon-cyan);
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(0,242,254,0.06);
|
||||
box-shadow: 0 0 15px rgba(0,242,254,0.15);
|
||||
.role-btn.deep-btn:hover:not(:disabled), .role-btn.deep-btn.active {
|
||||
border-color: var(--deep);
|
||||
color: var(--text);
|
||||
background-color: color-mix(in srgb, var(--deep) 8%, transparent);
|
||||
}
|
||||
|
||||
.large-badge {
|
||||
padding: 0.5rem 2rem;
|
||||
font-size: 1.5rem;
|
||||
border-radius: 8px;
|
||||
.role-badge {
|
||||
padding: 0.15rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-heading);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.role-badge.deep {
|
||||
background: rgba(0, 242, 254, 0.15);
|
||||
border: 2px solid var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
box-shadow: 0 0 15px rgba(0,242,254,0.1);
|
||||
}
|
||||
|
||||
.role-badge.pirat {
|
||||
background: rgba(0, 255, 135, 0.15);
|
||||
border: 2px solid var(--neon-emerald);
|
||||
color: var(--neon-emerald);
|
||||
box-shadow: 0 0 15px rgba(0,255,135,0.1);
|
||||
}
|
||||
|
||||
.list-chips {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.list-chips .player-chip {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.deep-chip {
|
||||
border-color: var(--neon-cyan);
|
||||
background: rgba(0, 242, 254, 0.03);
|
||||
.role-badge.deep {
|
||||
background: color-mix(in srgb, var(--deep) 14%, transparent);
|
||||
border: 1px solid var(--deep);
|
||||
color: var(--deep);
|
||||
}
|
||||
|
||||
.pirat-chip {
|
||||
border-color: var(--neon-emerald);
|
||||
background: rgba(0, 255, 135, 0.03);
|
||||
.role-badge.pirat {
|
||||
background: color-mix(in srgb, var(--pirat) 14%, transparent);
|
||||
border: 1px solid var(--pirat);
|
||||
color: var(--pirat);
|
||||
}
|
||||
|
||||
.role-badge.unassigned {
|
||||
background: var(--well);
|
||||
border: 1px dashed var(--edge);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -6,6 +6,14 @@
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* Between scenes there's only the voting/roster panel — center it. */
|
||||
.between-grid.single {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 640px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.between-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -19,12 +27,7 @@
|
||||
.voted-confirmation-box {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--neon-emerald);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--neon-emerald);
|
||||
font-weight: 700;
|
||||
border: 1px solid var(--success);
|
||||
}
|
||||
|
||||
.ranks-ledger {
|
||||
@@ -33,30 +36,66 @@
|
||||
|
||||
.ranks-ledger li {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
border-bottom: 1px solid var(--edge-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.deep-badge {
|
||||
background: rgba(0, 242, 254, 0.15);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
/* --- Death fate panel --- */
|
||||
.death-fate-card {
|
||||
border: 2px solid var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 5%, var(--surface));
|
||||
padding: 2.5rem;
|
||||
margin: 2rem auto;
|
||||
max-width: 650px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pirat-badge {
|
||||
background: rgba(0, 255, 135, 0.15);
|
||||
border: 1px solid var(--neon-emerald);
|
||||
color: var(--neon-emerald);
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
.death-fate-card > h2 {
|
||||
color: var(--danger);
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* --- 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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -76,9 +115,9 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.upkeep-box h3 {
|
||||
.upkeep-box h4 {
|
||||
margin-bottom: 1rem;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.upkeep-flex {
|
||||
@@ -87,14 +126,16 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: 8px;
|
||||
background: var(--well);
|
||||
border: 1px dashed var(--edge);
|
||||
border-radius: var(--radius-md);
|
||||
align-content: flex-start;
|
||||
min-height: 300px;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.upkeep-box[ondragover]:hover .upkeep-flex {
|
||||
border-color: var(--neon-cyan);
|
||||
.end-story-box {
|
||||
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 {
|
||||
max-width: 900px;
|
||||
margin: 4rem auto;
|
||||
max-width: 560px;
|
||||
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-size: 2.5rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.welcome-hero .subtitle {
|
||||
font-size: 1.2rem;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.welcome-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.welcome-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.action-card {
|
||||
padding: 2.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.action-card h3 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--gold);
|
||||
font-size: 1.5rem;
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: clamp(3.2rem, 10vw, 5rem);
|
||||
line-height: 1;
|
||||
letter-spacing: 2px;
|
||||
background: linear-gradient(175deg, var(--accent-bright) 20%, var(--accent) 55%, var(--accent-dark) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
filter: drop-shadow(0 3px 12px color-mix(in srgb, var(--accent) 30%, transparent));
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.action-card p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
.wordmark .amp {
|
||||
font-size: 0.45em;
|
||||
vertical-align: 0.45em;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.welcome-hero .subtitle {
|
||||
font-size: 1.15rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Anchor divider: ——— ⚓ ——— */
|
||||
.hero-flourish {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: var(--accent);
|
||||
font-size: 1.2rem;
|
||||
margin: 1.75rem auto 0;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.hero-flourish::before,
|
||||
.hero-flourish::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--edge-accent), transparent);
|
||||
}
|
||||
|
||||
.creation-card {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.creation-card h2 {
|
||||
color: var(--accent);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.join-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.welcome-links {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
82
frontend/src/components/AboutModal.svelte
Normal file
82
frontend/src/components/AboutModal.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { VERSION, CHANGELOG } from '../lib/changelog';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
function close() { dispatch('close'); }
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||
|
||||
<div class="modal-backdrop" on:click|self={close}>
|
||||
<div class="modal-box sheet-modal glass-panel about-modal">
|
||||
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||
<h3>About</h3>
|
||||
<p class="about-tagline">A remote-play companion for <em>Rats with Gats</em>.</p>
|
||||
<p class="about-version">Version {VERSION}</p>
|
||||
|
||||
<h4 class="about-heading">What's New</h4>
|
||||
<ul class="changelog">
|
||||
{#each CHANGELOG as entry (entry.version)}
|
||||
<li class="changelog-entry">
|
||||
<div class="changelog-head">
|
||||
<span class="changelog-ver">v{entry.version}</span>
|
||||
<span class="changelog-date text-muted">{entry.date}</span>
|
||||
</div>
|
||||
<ul class="changelog-changes">
|
||||
{#each entry.changes as change}
|
||||
<li>{change}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.about-tagline {
|
||||
margin: 0.25rem 0 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.about-version {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
.about-heading {
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
border-top: 1px solid var(--edge-soft);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.changelog {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.changelog-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.changelog-ver {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.changelog-date {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.changelog-changes {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { SUIT_EMOJI, isJoker, cardValue, cardSuit } from '../lib/cards';
|
||||
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, cardTooltipHtml, obstacleTable } from '../lib/cards';
|
||||
import { tooltip } from '../lib/tooltip';
|
||||
|
||||
export let card; // card code, e.g. "10D" or "Joker1"
|
||||
export let size = 'large'; // 'large' | 'medium' | 'mini'
|
||||
@@ -7,7 +8,7 @@
|
||||
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 = '';
|
||||
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 = '';
|
||||
|
||||
@@ -16,17 +17,23 @@
|
||||
$: 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' : ''}" {title}>
|
||||
<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' : ''}"
|
||||
{title} {draggable} {style}
|
||||
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>
|
||||
@@ -41,7 +48,7 @@
|
||||
</div>
|
||||
{#if techName}
|
||||
<div class="card-tech-overlay">
|
||||
<div class="tech-tag" title="Automatic success when played">{cardValue(card)}: "{techName}"</div>
|
||||
<div class="tech-tag" use:tooltip={`Automatic success when played\n\n"${techName}"`}>{cardValue(card)}: "{techName}"</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card-corner bottom-right">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions';
|
||||
import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
|
||||
import { displayName } from '../lib/cards';
|
||||
import FaceCardAssigner from './FaceCardAssigner.svelte';
|
||||
|
||||
export let state;
|
||||
|
||||
@@ -14,11 +16,13 @@
|
||||
};
|
||||
|
||||
let savingBasic = false;
|
||||
let editingBasic = false;
|
||||
|
||||
async function saveBasicDetails() {
|
||||
savingBasic = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails);
|
||||
editingBasic = false;
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
@@ -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.
|
||||
// This is a fallback for games that entered the phase before assignments existed.
|
||||
onMount(async () => {
|
||||
@@ -42,13 +61,17 @@
|
||||
function getPlayerName(id) {
|
||||
if (!id) return "None";
|
||||
const p = state.players.find(x => x.id === id);
|
||||
return p ? p.name : "Unknown";
|
||||
return p ? displayName(p) : "Unknown";
|
||||
}
|
||||
|
||||
// Techniques
|
||||
let tech1 = '', tech2 = '', tech3 = '';
|
||||
let savingTechs = false;
|
||||
let techError = '';
|
||||
// True while editing previously-submitted techniques (vs. first-time entry),
|
||||
// so we know to offer a Cancel button. techSnapshot holds the saved values.
|
||||
let revisingTechs = false;
|
||||
let techSnapshot = [];
|
||||
|
||||
async function submitTechniques() {
|
||||
savingTechs = true;
|
||||
@@ -57,6 +80,7 @@
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||
tech1, tech2, tech3
|
||||
});
|
||||
revisingTechs = false;
|
||||
} catch(e) {
|
||||
techError = e.message;
|
||||
} 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
|
||||
let jackTech = '', queenTech = '', kingTech = '';
|
||||
let assigningFace = false;
|
||||
let faceError = '';
|
||||
// True while revising a previously-assigned J/Q/K layout, so we offer Cancel.
|
||||
let revisingFace = false;
|
||||
let faceSnapshot = {};
|
||||
|
||||
async function assignFaceTechniques() {
|
||||
assigningFace = true;
|
||||
@@ -78,6 +171,44 @@
|
||||
queen: queenTech,
|
||||
king: kingTech
|
||||
});
|
||||
revisingFace = false;
|
||||
} catch(e) {
|
||||
faceError = e.message;
|
||||
} finally {
|
||||
assigningFace = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reviseFaceTechniques() {
|
||||
faceSnapshot = {
|
||||
jack: state.player.tech_jack,
|
||||
queen: state.player.tech_queen,
|
||||
king: state.player.tech_king
|
||||
};
|
||||
jackTech = state.player.tech_jack;
|
||||
queenTech = state.player.tech_queen;
|
||||
kingTech = state.player.tech_king;
|
||||
revisingFace = true;
|
||||
faceError = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unassign-face-techniques`, 'POST');
|
||||
} catch(e) {
|
||||
faceError = e.message;
|
||||
revisingFace = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Throw away edits and re-assign the J/Q/K layout as it was before Revise.
|
||||
async function cancelReviseFaceTechniques() {
|
||||
assigningFace = true;
|
||||
faceError = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/assign-face-techniques`, 'POST', {
|
||||
jack: faceSnapshot.jack,
|
||||
queen: faceSnapshot.queen,
|
||||
king: faceSnapshot.king
|
||||
});
|
||||
revisingFace = false;
|
||||
} catch(e) {
|
||||
faceError = e.message;
|
||||
} finally {
|
||||
@@ -101,68 +232,23 @@
|
||||
}
|
||||
|
||||
// Check completion status
|
||||
$: phase = state.game.phase;
|
||||
$: createdTechniques = state.player.created_techniques ? JSON.parse(state.player.created_techniques) : [];
|
||||
$: heldTechniques = state.player.held_techniques ? JSON.parse(state.player.held_techniques) : [];
|
||||
$: swappedTechniques = state.player.swapped_techniques ? JSON.parse(state.player.swapped_techniques) : [];
|
||||
|
||||
$: ownHeldCount = heldTechniques.filter(t => t.creator_id === state.player.id).length;
|
||||
$: incomingOffers = state.players.filter(p => p.id !== state.player.id && p.swap_offer_to_id === state.player.id);
|
||||
$: swapTargets = state.players.filter(p => p.id !== state.player.id && !p.needs_reroll);
|
||||
$: offerTechCreator = (heldTechniques.find(t => t.text === offerTech) || {}).creator_id;
|
||||
$: offerInvalid = !offerTech || !offerTargetId || offerTechCreator === offerTargetId;
|
||||
|
||||
$: basicComplete = !!(state.player.avatar_look && state.player.avatar_smell && state.player.first_words && state.player.good_at_math);
|
||||
$: delegateComplete = !!(state.player.other_like_from_player_id && state.player.other_hate_from_player_id);
|
||||
$: techniquesComplete = !!(state.player.tech_jack && state.player.tech_queen && state.player.tech_king);
|
||||
|
||||
// Dev Mode
|
||||
let devMode = false;
|
||||
let autoFilling = false;
|
||||
|
||||
async function autoFillAll() {
|
||||
autoFilling = true;
|
||||
try {
|
||||
// 1. Basic details — drawn from the same pools as the Suggest buttons
|
||||
if (!basicComplete) {
|
||||
basicDetails = {
|
||||
avatar_look: getSuggestion('look'),
|
||||
avatar_smell: getSuggestion('smell'),
|
||||
first_words: getSuggestion('first_words'),
|
||||
good_at_math: getSuggestion('good_at_math')
|
||||
};
|
||||
await saveBasicDetails();
|
||||
}
|
||||
|
||||
// 2. Answer pending inbox questions with pool suggestions
|
||||
for (const p of state.players) {
|
||||
if (p.other_like_from_player_id === state.player.id && !p.other_like) {
|
||||
inboxAnswers[`${p.id}:like`] = getSuggestion('like');
|
||||
await submitDelegatedAnswer('like', p.id);
|
||||
}
|
||||
if (p.other_hate_from_player_id === state.player.id && !p.other_hate) {
|
||||
inboxAnswers[`${p.id}:hate`] = getSuggestion('hate');
|
||||
await submitDelegatedAnswer('hate', p.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Techniques — 3 distinct pool picks; retry if another player
|
||||
// grabbed the same name first (the backend rejects collisions)
|
||||
if (createdTechniques.length === 0) {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
[tech1, tech2, tech3] = await getTechniqueSuggestions(3);
|
||||
await submitTechniques();
|
||||
if (!techError) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Face Techniques - we need the swapped ones. We can't do this synchronously without waiting for the server
|
||||
// to process the state, because swapped_techniques comes from the server once everyone is done.
|
||||
if (!techniquesComplete && swappedTechniques.length === 3) {
|
||||
jackTech = swappedTechniques[0];
|
||||
queenTech = swappedTechniques[1];
|
||||
kingTech = swappedTechniques[2];
|
||||
await assignFaceTechniques();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
autoFilling = false;
|
||||
}
|
||||
}
|
||||
// Dev Mode (toggled by the creator from the corner menu) reveals the
|
||||
// Suggest buttons; in real games players write their own material.
|
||||
$: devMode = !!state.game.dev_mode;
|
||||
|
||||
</script>
|
||||
|
||||
@@ -170,24 +256,8 @@
|
||||
<div class="view-header text-center">
|
||||
<h2>Character Sheet Creation</h2>
|
||||
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
|
||||
<div 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>
|
||||
|
||||
{#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 -->
|
||||
<div class="creation-grid max-w-5xl mx-auto">
|
||||
|
||||
@@ -195,7 +265,7 @@
|
||||
<div class="card glass-panel section-basic">
|
||||
<h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3>
|
||||
|
||||
{#if basicComplete}
|
||||
{#if basicComplete && !editingBasic}
|
||||
<!-- Saved View -->
|
||||
<div class="read-only-sheet">
|
||||
<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>Good at Math?</strong> {state.player.good_at_math}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
|
||||
{:else}
|
||||
<!-- Form View -->
|
||||
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
|
||||
@@ -210,31 +281,36 @@
|
||||
<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">
|
||||
<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 class="form-group">
|
||||
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
|
||||
<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 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">
|
||||
<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 class="form-group">
|
||||
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
|
||||
<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>
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -261,7 +337,7 @@
|
||||
<div class="author-label">— {getPlayerName(state.player.other_hate_from_player_id)}</div>
|
||||
</div>
|
||||
{: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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,29 +348,35 @@
|
||||
<p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p>
|
||||
<div class="inbox-list">
|
||||
{#each state.players as p}
|
||||
{#if p.other_like_from_player_id === state.player.id && !p.other_like}
|
||||
{#if p.other_like_from_player_id === state.player.id}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{/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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/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}
|
||||
<p class="inbox-empty-message text-gray-400 italic">No pending tasks! Wait for other players to assign you tasks.</p>
|
||||
{#if state.players.filter(p => p.other_like_from_player_id === state.player.id || p.other_hate_from_player_id === state.player.id).length === 0}
|
||||
<p class="inbox-empty-message text-muted italic">No pending tasks! Wait for other players to assign you tasks.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,7 +385,9 @@
|
||||
<div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;">
|
||||
<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>
|
||||
{#if techError}
|
||||
<div class="alert alert-danger">{techError}</div>
|
||||
@@ -313,186 +397,161 @@
|
||||
<label for="tech1">Technique #1:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech1 = await getTechniqueSuggestion([tech2, tech3])}>Suggest</button>
|
||||
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech1 = await getTechniqueSuggestion([tech2, tech3])}>Suggest</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tech2">Technique #2:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech2 = await getTechniqueSuggestion([tech1, tech3])}>Suggest</button>
|
||||
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech2 = await getTechniqueSuggestion([tech1, tech3])}>Suggest</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tech3">Technique #3:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button>
|
||||
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
||||
{#if revisingTechs}
|
||||
<button type="button" class="btn btn-secondary btn-full mt-4" disabled={savingTechs} on:click={cancelReviseTechniques}>Cancel — keep my original techniques</button>
|
||||
{/if}
|
||||
</form>
|
||||
{:else if createdTechniques.length === 3 && swappedTechniques.length < 3}
|
||||
{:else if phase === 'character_creation'}
|
||||
<div class="submitted-techniques text-center mt-4">
|
||||
{#if techError}
|
||||
<div class="alert alert-danger">{techError}</div>
|
||||
{/if}
|
||||
<div class="tech-chip">#1: {createdTechniques[0]}</div>
|
||||
<div class="tech-chip">#2: {createdTechniques[1]}</div>
|
||||
<div class="tech-chip">#3: {createdTechniques[2]}</div>
|
||||
<p class="waiting-box margin-top flex justify-center mt-4">
|
||||
<span class="spinner-small"></span>
|
||||
Techniques submitted! 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>
|
||||
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseTechniques}>✏️ Revise Techniques</button>
|
||||
</div>
|
||||
{:else if !techniquesComplete}
|
||||
{#if swappedTechniques.length === 3}
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<p class="text-gray-300">Drag and drop your assigned techniques onto your Face Cards:</p>
|
||||
|
||||
{#if faceError}
|
||||
<div class="alert alert-danger">{faceError}</div>
|
||||
{/if}
|
||||
{:else if phase === 'swap_techniques'}
|
||||
<p class="section-desc">
|
||||
Trade away all 3 techniques you created by offering blind swaps to your crewmates.
|
||||
They only learn that you offered a swap — never which technique is on the table!
|
||||
You can't offer a technique to the rat who wrote it.
|
||||
</p>
|
||||
{#if swapError}
|
||||
<div class="alert alert-danger">{swapError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="available-techniques-container">
|
||||
<h4>Available Swapped Techniques</h4>
|
||||
<p class="instruction-help">Drag a technique onto a card slot below to assign it.</p>
|
||||
<div class="techniques-drag-pool">
|
||||
{#each swappedTechniques as tech}
|
||||
<div
|
||||
draggable={tech !== jackTech && tech !== queenTech && tech !== kingTech}
|
||||
on:dragstart={(e) => e.dataTransfer.setData('text/plain', tech)}
|
||||
class="draggable-tech-chip {tech === jackTech || tech === queenTech || tech === kingTech ? 'assigned-hidden' : ''}"
|
||||
>
|
||||
<span class="drag-icon">⋮⋮</span> {tech}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<h4>Your techniques in hand</h4>
|
||||
<div class="submitted-techniques mt-4">
|
||||
{#each heldTechniques as t}
|
||||
<div class="tech-chip">
|
||||
{t.text}
|
||||
{#if t.creator_id === state.player.id}
|
||||
<span class="text-warning"> — yours, swap it away!</span>
|
||||
{:else}
|
||||
<span class="text-success"> — from {getPlayerName(t.creator_id)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Visual Card Slots Grid -->
|
||||
<div class="face-cards-slots-grid">
|
||||
|
||||
<!-- JACK SLOT -->
|
||||
<div class="face-card-slot-wrapper">
|
||||
<div class="face-card-slot {jackTech ? 'has-assignment' : ''}"
|
||||
on:dragover|preventDefault
|
||||
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>
|
||||
{#if state.player.swap_offer_to_id}
|
||||
<div class="delegation-box glass-panel mt-4">
|
||||
<p>
|
||||
You offered <strong>"{state.player.swap_offer_technique}"</strong> to
|
||||
{getPlayerName(state.player.swap_offer_to_id)}. Waiting for their answer...
|
||||
</p>
|
||||
<button class="btn btn-secondary mt-4" disabled={swapBusy} on:click={cancelOffer}>Withdraw Offer</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center italic text-gray-400">
|
||||
Waiting for all players to submit techniques so they can be shuffled...
|
||||
{:else if !state.player.is_ready}
|
||||
<div class="delegation-box glass-panel mt-4">
|
||||
<h4>Offer a swap</h4>
|
||||
<div class="input-row">
|
||||
<select class="input-field" bind:value={offerTech}>
|
||||
<option value="" disabled>Pick a technique to give...</option>
|
||||
{#each heldTechniques as t}
|
||||
<option value={t.text}>{t.text}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="input-field" bind:value={offerTargetId}>
|
||||
<option value="" disabled>Pick a crewmate...</option>
|
||||
{#each swapTargets as p}
|
||||
<option value={p.id} disabled={offerTechCreator === p.id}>
|
||||
{displayName(p)}{offerTechCreator === p.id ? ' (wrote it!)' : ''}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn btn-primary" disabled={swapBusy || offerInvalid} on:click={offerSwap}>Offer Swap</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each incomingOffers as offerer (offerer.id)}
|
||||
<div class="delegation-box glass-panel mt-4">
|
||||
<h4>📨 {displayName(offerer)} offered you a mystery technique!</h4>
|
||||
<p class="text-sm text-muted">Choose one of your techniques to trade back (not one they wrote):</p>
|
||||
<div class="input-row">
|
||||
<select class="input-field" bind:value={acceptChoice[offerer.id]}>
|
||||
<option value="" disabled selected>Pick a technique to give back...</option>
|
||||
{#each heldTechniques as t}
|
||||
<option value={t.text} disabled={t.creator_id === offerer.id}>
|
||||
{t.text}{t.creator_id === offerer.id ? ' (they wrote it!)' : ''}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn btn-primary" disabled={swapBusy || !acceptChoice[offerer.id]} on:click={() => respondOffer(offerer.id, true)}>Accept</button>
|
||||
<button class="btn btn-secondary" disabled={swapBusy} on:click={() => respondOffer(offerer.id, false)}>Decline</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="text-center mt-6">
|
||||
{#if state.player.is_ready}
|
||||
<p class="waiting-box flex justify-center">
|
||||
<span class="spinner-small"></span>
|
||||
Done swapping! Waiting for the rest of the crew...
|
||||
</p>
|
||||
<button class="btn btn-secondary mt-4" disabled={swapBusy} on:click={() => setSwapReady(false)}>Wait, I want to keep swapping</button>
|
||||
{:else if ownHeldCount > 0}
|
||||
<p class="text-muted italic">Swap away {ownHeldCount} more of your own technique{ownHeldCount === 1 ? '' : 's'} before you can ready up.</p>
|
||||
{:else}
|
||||
<button class="btn btn-primary btn-large" disabled={swapBusy || !!state.player.swap_offer_to_id} on:click={() => setSwapReady(true)}>I'm done swapping</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !techniquesComplete}
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<p>Drag and drop your assigned techniques onto your Face Cards:</p>
|
||||
|
||||
{#if faceError}
|
||||
<div class="alert alert-danger">{faceError}</div>
|
||||
{/if}
|
||||
|
||||
<FaceCardAssigner techniques={swappedTechniques} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} />
|
||||
|
||||
<div class="mt-6">
|
||||
<button
|
||||
class="btn btn-primary btn-large w-full"
|
||||
disabled={assigningFace || !jackTech || !queenTech || !kingTech}
|
||||
on:click={assignFaceTechniques}
|
||||
>
|
||||
{assigningFace ? 'Assigning...' : 'Assign to Cards'}
|
||||
</button>
|
||||
{#if revisingFace}
|
||||
<button
|
||||
class="btn btn-secondary w-full mt-4"
|
||||
disabled={assigningFace}
|
||||
on:click={cancelReviseFaceTechniques}
|
||||
>Cancel — keep my original assignment</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="assigned-techs text-center mt-4">
|
||||
{#if faceError}
|
||||
<div class="alert alert-danger">{faceError}</div>
|
||||
{/if}
|
||||
<div class="tech-assignment-pill"><span class="card-rank">J</span> {state.player.tech_jack}</div>
|
||||
<div class="tech-assignment-pill"><span class="card-rank">Q</span> {state.player.tech_queen}</div>
|
||||
<div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div>
|
||||
@@ -500,32 +559,12 @@
|
||||
<span class="spinner-small"></span>
|
||||
Ready! Waiting for other players to finish J/Q/K assignment...
|
||||
</p>
|
||||
{#if phase === 'assign_techniques'}
|
||||
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseFaceTechniques}>✏️ Revise Assignment</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
112
frontend/src/components/CornerMenu.svelte
Normal file
112
frontend/src/components/CornerMenu.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<script>
|
||||
import AboutModal from './AboutModal.svelte';
|
||||
import { extraMenuLinks } from '../lib/menu';
|
||||
import { theme, toggleTheme } from '../lib/theme';
|
||||
|
||||
let open = false;
|
||||
let showAbout = false;
|
||||
|
||||
$: themeLink = $theme === 'dark'
|
||||
? { label: '☀️ Light Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the light theme' }
|
||||
: { label: '🌙 Dark Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the dark theme' };
|
||||
|
||||
$: baseLinks = [
|
||||
{ label: '📖 Rules', href: '/rules', external: true, title: 'Open the rulebook in a new tab' },
|
||||
themeLink,
|
||||
{ label: 'ℹ️ About', action: () => (showAbout = true), title: 'Version and changelog' },
|
||||
];
|
||||
|
||||
$: links = [...$extraMenuLinks, ...baseLinks];
|
||||
|
||||
function handleWindowClick(event) {
|
||||
if (open && !event.target.closest('.corner-menu')) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleWindowClick} />
|
||||
|
||||
<div class="corner-menu">
|
||||
<button class="menu-toggle" class:open on:click={() => (open = !open)} title="Menu">
|
||||
☰ Menu
|
||||
</button>
|
||||
{#if open}
|
||||
<nav class="menu-dropdown">
|
||||
{#each links as link}
|
||||
{#if link.action}
|
||||
<button
|
||||
class="menu-item"
|
||||
title={link.title}
|
||||
on:click={() => { if (!link.keepOpen) open = false; link.action(); }}
|
||||
>{link.label}</button>
|
||||
{:else}
|
||||
<a
|
||||
class="menu-item"
|
||||
href={link.href}
|
||||
target={link.external ? '_blank' : undefined}
|
||||
rel={link.external ? 'noopener' : undefined}
|
||||
title={link.title}
|
||||
on:click={() => (open = false)}
|
||||
>{link.label}</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAbout}
|
||||
<AboutModal on:close={() => (showAbout = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.corner-menu {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
z-index: 1001;
|
||||
text-align: right;
|
||||
}
|
||||
.menu-toggle {
|
||||
padding: 6px 14px;
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--bg-deep) 92%, transparent);
|
||||
border: 1px solid var(--edge-accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-heading);
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-toggle:hover, .menu-toggle.open {
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
|
||||
}
|
||||
.menu-dropdown {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: color-mix(in srgb, var(--bg-deep) 96%, transparent);
|
||||
border: 1px solid var(--edge-accent);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.menu-item {
|
||||
padding: 8px 16px;
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-heading);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
.menu-item + .menu-item {
|
||||
border-top: 1px solid var(--edge-soft);
|
||||
}
|
||||
</style>
|
||||
97
frontend/src/components/CrewSidebar.svelte
Normal file
97
frontend/src/components/CrewSidebar.svelte
Normal file
@@ -0,0 +1,97 @@
|
||||
<script>
|
||||
import { displayName, crewLabel } from '../lib/cards';
|
||||
import CharacterSheet from './scene/CharacterSheet.svelte';
|
||||
|
||||
export let state;
|
||||
|
||||
let openTargetId = null;
|
||||
|
||||
$: captainId = state.game.captain_player_id;
|
||||
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
||||
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
||||
|
||||
function iconFor(p) {
|
||||
if (p.role === 'deep') return '🌊';
|
||||
if (p.is_ghost) return '👻';
|
||||
if (p.is_dead) return '💀';
|
||||
return '🐀';
|
||||
}
|
||||
|
||||
function safeArray(value) {
|
||||
try { return JSON.parse(value || '[]'); } catch { return []; }
|
||||
}
|
||||
|
||||
function statusFor(p) {
|
||||
const phase = state.game.phase;
|
||||
if (phase === 'lobby') {
|
||||
if (p.is_creator) return 'Creator';
|
||||
if (p.is_admin) return 'Admin';
|
||||
return 'Crew member';
|
||||
}
|
||||
if (phase === 'character_creation') {
|
||||
if (p.needs_reroll) return 'Spectating';
|
||||
return safeArray(p.created_techniques).length === 3 ? 'Techniques submitted' : 'Writing sheet';
|
||||
}
|
||||
if (phase === 'swap_techniques') {
|
||||
if (p.is_ready) return 'Done swapping';
|
||||
const ownLeft = safeArray(p.held_techniques).filter(t => t.creator_id === p.id).length;
|
||||
return `Swapping · ${ownLeft} own left`;
|
||||
}
|
||||
if (phase === 'assign_techniques') return p.tech_jack ? 'Ready' : 'Assigning face cards';
|
||||
if (phase === 'scene_setup') {
|
||||
if (p.needs_reroll) return 'Spectating';
|
||||
if (p.role === 'deep') return 'The Deep';
|
||||
if (p.role === 'pirat') return `Pi-Rat · Rank ${p.rank}`;
|
||||
return 'Choosing a role…';
|
||||
}
|
||||
if (phase === 'between_scenes') {
|
||||
const voted = (state.votes || []).some(v => v.voter_player_id === p.id);
|
||||
const canVote = state.players.some(candidate =>
|
||||
candidate.id !== p.id && candidate.role !== 'deep' && !candidate.is_dead && !candidate.needs_reroll
|
||||
);
|
||||
return `Rank ${p.rank} · ${voted ? 'Nomination done' : canVote ? 'Nominating…' : 'No vote'}`;
|
||||
}
|
||||
if (phase === 'deep_upkeep') return `Rank ${p.rank} · ${p.is_ready ? 'Ready' : 'Finishing upkeep…'}`;
|
||||
if (phase === 'recruit_creation') return p.needs_reroll ? 'Creating a new Pi-Rat' : `Rank ${p.rank} · Helping recruits`;
|
||||
if (phase === 'ended') {
|
||||
const story = p.completed_personal_3 ? 'Story complete' : 'Still sailing';
|
||||
return `Rank ${p.rank} · ${story}`;
|
||||
}
|
||||
return `Rank ${p.rank}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="crew-column phase-crew-column" aria-label="Crew roster">
|
||||
<div class="crew-bubbles">
|
||||
{#each crew as p (p.id)}
|
||||
{#if state.game.phase === 'lobby'}
|
||||
<div
|
||||
class="crew-bubble is-static"
|
||||
class:is-you={p.id === state.player.id}
|
||||
>
|
||||
<span class="bubble-icon">{iconFor(p)}</span>
|
||||
<span class="bubble-name">{displayName(p)}</span>
|
||||
<span class="bubble-meta">{statusFor(p)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="crew-bubble"
|
||||
class:is-you={p.id === state.player.id}
|
||||
class:is-deep={p.role === 'deep'}
|
||||
class:is-dead={p.is_dead}
|
||||
class:is-ghost={p.is_ghost}
|
||||
title="View {p.name}'s character sheet"
|
||||
on:click={() => openTargetId = p.id}
|
||||
>
|
||||
<span class="bubble-icon">{iconFor(p)}</span>
|
||||
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||
<span class="bubble-meta">{statusFor(p)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{#if openTarget}
|
||||
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
||||
{/if}
|
||||
@@ -1,8 +1,13 @@
|
||||
<script>
|
||||
import { tick } from 'svelte';
|
||||
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"
|
||||
@@ -27,12 +32,32 @@
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
@@ -40,24 +65,54 @@
|
||||
// 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 (open && atBottom) {
|
||||
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() {
|
||||
@@ -107,14 +162,59 @@
|
||||
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 {open ? 'open' : ''}">
|
||||
<button class="toggle-log-btn" on:click={toggleOpen}>
|
||||
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||
</button>
|
||||
<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 open}
|
||||
{#if shown}
|
||||
{#if error}
|
||||
<div class="log-error">{error}</div>
|
||||
{/if}
|
||||
{#if head !== null}
|
||||
<div class="log-rolledback">
|
||||
↩ Rolled back — greyed entries are undone. Click one to redo, or take an action to make it permanent.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
|
||||
{#if events.length}
|
||||
<div class="log-top">
|
||||
@@ -127,10 +227,18 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#each events as event (event.id)}
|
||||
<div class="log-entry log-kind-{event.kind || 'info'}">
|
||||
{@const isFuture = head !== null && event.checkpoint_id > head}
|
||||
<div class="log-entry log-kind-{event.kind || 'info'}" class:greyed={isFuture}>
|
||||
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
|
||||
<span class="log-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}
|
||||
@@ -150,10 +258,10 @@
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 380px;
|
||||
background: rgba(10, 15, 25, 0.95);
|
||||
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
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;
|
||||
@@ -163,20 +271,66 @@
|
||||
.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: var(--dark-bg, #1a1a2e);
|
||||
color: white;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
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);
|
||||
border-radius: var(--radius-md);
|
||||
border-bottom: 1px solid var(--edge-soft);
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
.toggle-log-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
}
|
||||
.log-content {
|
||||
overflow-y: auto;
|
||||
@@ -193,34 +347,73 @@
|
||||
}
|
||||
.log-top-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.load-older-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-muted, #94a3b8);
|
||||
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: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary, #f1f5f9);
|
||||
background: color-mix(in srgb, var(--text) 12%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
.log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
padding: 6px 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-left: 3px solid rgba(255, 255, 255, 0.15);
|
||||
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: #c3cede;
|
||||
color: color-mix(in srgb, var(--text) 85%, var(--text-muted));
|
||||
}
|
||||
.rollback-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--edge);
|
||||
color: var(--text-muted);
|
||||
border-radius: 6px;
|
||||
padding: 0 6px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
}
|
||||
.rollback-btn:hover {
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
border-color: var(--danger);
|
||||
color: var(--text);
|
||||
}
|
||||
.log-error {
|
||||
margin: 6px 8px 0;
|
||||
padding: 6px 10px;
|
||||
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.log-rolledback {
|
||||
margin: 6px 8px 0;
|
||||
padding: 6px 10px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.log-entry.greyed {
|
||||
opacity: 0.45;
|
||||
font-style: italic;
|
||||
}
|
||||
.log-icon {
|
||||
font-size: 0.9rem;
|
||||
@@ -230,13 +423,13 @@
|
||||
}
|
||||
.log-time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.log-empty {
|
||||
padding: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.jump-to-bottom {
|
||||
@@ -244,37 +437,79 @@
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--gold, #d4af37);
|
||||
color: var(--text-dark, #0f172a);
|
||||
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: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
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(--red-suit, #ff2a5f); }
|
||||
.log-kind-card { border-left-color: var(--neon-cyan, #00f2fe); }
|
||||
.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(--gold, #d4af37); }
|
||||
.log-kind-victory { border-left-color: var(--accent); }
|
||||
.log-kind-scene,
|
||||
.log-kind-phase { border-left-color: var(--neon-emerald, #00ff87); }
|
||||
.log-kind-phase { border-left-color: var(--pirat); }
|
||||
.log-kind-joker,
|
||||
.log-kind-warning { border-left-color: #ff9f43; }
|
||||
.log-kind-obstacle { border-left-color: #4aa3df; }
|
||||
.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: #9b8cff; }
|
||||
.log-kind-objective { border-left-color: var(--mystic); }
|
||||
.log-kind-victory {
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
color: var(--gold, #d4af37);
|
||||
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>
|
||||
export let state;
|
||||
|
||||
$: pirats = state.players;
|
||||
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
|
||||
</script>
|
||||
|
||||
<div class="dashboard-container" style="max-width: 800px; margin: 0 auto; padding: 2rem;">
|
||||
<div class="card glass-panel" style="text-align: center; padding: 2rem;">
|
||||
<h1 style="font-family: var(--font-heading); color: var(--gold);">🎉 The Story Has Ended!</h1>
|
||||
<div class="gameover-container">
|
||||
<div class="card glass-panel gameover-card">
|
||||
<h1>🎉 The Story Has Ended!</h1>
|
||||
<p class="section-desc" style="font-size: 1.1rem;">
|
||||
The Pi-Rats of {state.game.crew_name || 'the crew'} party til they pass out in a pile.
|
||||
Tell one last tale of how your Pi-Rat is remembered!
|
||||
</p>
|
||||
|
||||
<div class="sheet-group" style="text-align: left; margin-top: 2rem;">
|
||||
<h3 style="color: var(--gold);">Crew Objectives</h3>
|
||||
<div class="sheet-group">
|
||||
<h3 class="text-accent">Crew Objectives</h3>
|
||||
<div class="objectives-checklist">
|
||||
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_1} disabled> <span>Steal a Ship</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_2} disabled> <span>Choose a Captain</span></label>
|
||||
@@ -27,24 +26,6 @@
|
||||
{/if}
|
||||
</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>
|
||||
</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;
|
||||
|
||||
let starting = false;
|
||||
let copiedLink = null;
|
||||
let copiedTimer = null;
|
||||
|
||||
function copyLink(which, value) {
|
||||
navigator.clipboard.writeText(value);
|
||||
copiedLink = which;
|
||||
clearTimeout(copiedTimer);
|
||||
copiedTimer = setTimeout(() => { copiedLink = null; }, 2000);
|
||||
}
|
||||
|
||||
async function startGame() {
|
||||
starting = true;
|
||||
@@ -15,59 +24,95 @@
|
||||
starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$: teaching = state.game.teaching_deep_player_id === state.player.id;
|
||||
$: teachingTakenByOther = state.game.teaching_deep_player_id && !teaching;
|
||||
|
||||
async function toggleTeaching() {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/teaching-mode`, 'POST', {
|
||||
enabled: !teaching
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle teaching mode:", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="lobby-view text-center">
|
||||
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
|
||||
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
||||
|
||||
<div class="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">
|
||||
{#if state.player.is_admin}
|
||||
<div class="link-item lobby-join-code-item">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-join-code"
|
||||
class:copied={copiedLink === 'code'}
|
||||
aria-label="Copy join code {state.game.join_code}"
|
||||
on:click={() => copyLink('code', state.game.join_code)}
|
||||
>
|
||||
<span class="admin-join-code-label">Join Code</span>
|
||||
<strong>{state.game.join_code}</strong>
|
||||
<span class="admin-join-code-hint">{copiedLink === 'code' ? '✓ Copied!' : 'Click to copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="link-item">
|
||||
<h4>Share Join Link:</h4>
|
||||
<div class="copy-container">
|
||||
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/join" class="copy-input" id="join-link-copy">
|
||||
<button on:click={() => { 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>
|
||||
|
||||
{#if state.player.is_creator}
|
||||
{#if state.player.is_admin}
|
||||
<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>
|
||||
<div class="copy-container">
|
||||
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/admin" class="copy-input admin-input" id="admin-link-copy">
|
||||
<button on:click={() => { 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 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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if state.player.is_admin}
|
||||
<div class="teaching-box glass-panel">
|
||||
<label class="checkbox-label" class:disabled={teachingTakenByOther}>
|
||||
<input type="checkbox" checked={teaching} disabled={teachingTakenByOther} on:change={toggleTeaching}>
|
||||
<span>📚 <strong>Teaching mode:</strong> I'll be the Deep for the first scene</span>
|
||||
</label>
|
||||
<p class="info-text">
|
||||
{#if teachingTakenByOther}
|
||||
Another admin has already volunteered to teach as the Deep.
|
||||
{:else}
|
||||
Seeds you as the Rank-3 player who must play the Deep in scene 1, so you can run the table for new crews. Otherwise it's assigned at random.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="lobby-action">
|
||||
{#if state.player.is_creator}
|
||||
{#if state.players.length >= 1}
|
||||
<button on:click={startGame}
|
||||
{#if state.player.is_admin}
|
||||
{#if state.players.length >= 3 || state.game.dev_mode}
|
||||
<button on:click={startGame}
|
||||
disabled={starting}
|
||||
class="btn btn-primary btn-large glow-effect">
|
||||
{starting ? 'Starting...' : 'Start Character Creation'}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-primary btn-large" disabled>Waiting for players...</button>
|
||||
<button class="btn btn-primary btn-large" disabled>Waiting for players... ({state.players.length}/3 minimum)</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<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>
|
||||
@@ -2,102 +2,78 @@
|
||||
import Card from './Card.svelte';
|
||||
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||
import DeepControlPanel from './scene/DeepControlPanel.svelte';
|
||||
import CharacterSheet from './scene/CharacterSheet.svelte';
|
||||
import CrewColumn from './scene/CrewColumn.svelte';
|
||||
import EventLog from './EventLog.svelte';
|
||||
|
||||
export let state;
|
||||
|
||||
// The inline Event Log can be toggled from a fixed corner button to declutter.
|
||||
let logOpen = true;
|
||||
|
||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
|
||||
$: isCaptain = state.game.captain_player_id === state.player.id;
|
||||
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
||||
$: isDeep = state.player.role === 'deep';
|
||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||
// The Challenge area is shown when something is happening there, or to the Deep
|
||||
// (who calls Challenges and ends the scene from it).
|
||||
$: showChallengeArea = isDeep || openChallenges.length > 0;
|
||||
</script>
|
||||
|
||||
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||
<!-- LEFT COLUMN: The Shared Table -->
|
||||
<div class="table-column">
|
||||
<div class="scene-view-layout" class:log-collapsed={!logOpen} id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||
<!-- LEFT COLUMN: The Crew -->
|
||||
<CrewColumn {state} />
|
||||
|
||||
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
|
||||
<div class="table-column">
|
||||
{#if !isDeep}
|
||||
<div class="card glass-panel hand-card">
|
||||
<span class="hand-label">🃏 Your Hand</span>
|
||||
|
||||
<div class="hand-flex">
|
||||
{#each hand as card}
|
||||
<Card {card} techs={playerTechs}
|
||||
draggable={true}
|
||||
on:dragstart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', card);
|
||||
e.currentTarget.classList.add("dragging");
|
||||
}}
|
||||
on:dragend={(e) => {
|
||||
e.currentTarget.classList.remove("dragging");
|
||||
}} />
|
||||
{:else}
|
||||
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showChallengeArea}
|
||||
<div class="card glass-panel challenge-area-card">
|
||||
<ChallengePanel {state} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Game Deck and Obstacles Roster -->
|
||||
<div class="card glass-panel obstacle-list-card">
|
||||
<div class="card-header">
|
||||
<h3>🌊 The Obstacle List</h3>
|
||||
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<ChallengePanel {state} />
|
||||
<ObstacleBoard {state} />
|
||||
</div>
|
||||
<ObstacleBoard {state} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
||||
<div class="private-column">
|
||||
{#if state.player.role === "deep"}
|
||||
<DeepControlPanel {state} />
|
||||
{/if}
|
||||
|
||||
<!-- Player Hand Card -->
|
||||
<div class="card glass-panel hand-card">
|
||||
<h3>🃏 Your Hand</h3>
|
||||
<p class="section-desc">Keep your cards secret! {#if state.player.role !== "deep"}When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.{/if}</p>
|
||||
|
||||
<div class="hand-flex">
|
||||
{#each hand as card}
|
||||
<Card {card} techs={playerTechs}
|
||||
draggable={state.player.role !== "deep"}
|
||||
on:dragstart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', card);
|
||||
e.currentTarget.classList.add("dragging");
|
||||
}}
|
||||
on:dragend={(e) => {
|
||||
e.currentTarget.classList.remove("dragging");
|
||||
}} />
|
||||
{:else}
|
||||
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- RIGHT COLUMN: The Event Log (collapsible to a corner button) -->
|
||||
{#if logOpen}
|
||||
<div class="log-column">
|
||||
<EventLog {state} inline on:collapse={() => (logOpen = false)} />
|
||||
</div>
|
||||
|
||||
{#if state.player.role !== "deep"}
|
||||
<CharacterSheet {state} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<EventLog {state} />
|
||||
<button
|
||||
class="log-reopen-btn"
|
||||
title={logOpen ? 'Hide the event log' : 'Show the event log'}
|
||||
on:click={() => (logOpen = !logOpen)}
|
||||
>
|
||||
{logOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||
</button>
|
||||
|
||||
@@ -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');
|
||||
$: 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>
|
||||
|
||||
<div class="scene-setup-view text-center">
|
||||
<h2>Scene Setup (Scene #{state.game.current_scene_number})</h2>
|
||||
<p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p>
|
||||
|
||||
<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 -->
|
||||
<div class="card glass-panel role-selection-card">
|
||||
<h3>Select Your Role</h3>
|
||||
|
||||
|
||||
{#if state.player.needs_reroll}
|
||||
<div class="notice-banner accent">
|
||||
<p class="info-text" style="margin: 0;">🐀 You don't have a Pi-Rat yet! You'll spectate this scene and create your recruit with the crew between scenes.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="role-buttons">
|
||||
<button on:click={() => setRole('pirat')}
|
||||
disabled={settingRole || !allowedRoles.includes('pirat')}
|
||||
@@ -70,41 +89,23 @@
|
||||
</div>
|
||||
|
||||
<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}
|
||||
<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}
|
||||
<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 !allowedRoles.includes('deep')}
|
||||
{#if !allowedRoles.includes('deep') && !state.player.needs_reroll}
|
||||
{#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}
|
||||
<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}
|
||||
</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 class="action-box margin-top">
|
||||
@@ -118,14 +119,16 @@
|
||||
class="btn btn-primary btn-large glow-effect">
|
||||
{starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'}
|
||||
</button>
|
||||
{:else if !enoughPlayers}
|
||||
<p class="text-danger italic mt-4">You need at least 3 players to start a scene — share the join link!</p>
|
||||
{:else if !allRolesAssigned}
|
||||
<p class="text-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}
|
||||
<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}
|
||||
<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}
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { getSuggestion } from '../lib/suggestions';
|
||||
import { displayName } from '../lib/cards';
|
||||
import Card from './Card.svelte';
|
||||
|
||||
export let state;
|
||||
@@ -13,22 +13,30 @@
|
||||
|
||||
// Helpers
|
||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||
// Winner of the rank-up vote, surfaced on the post-voting (deep_upkeep) screen.
|
||||
$: rankUpWinner = state.game.last_rank_up_player_id
|
||||
? state.players.find(p => p.id === state.game.last_rank_up_player_id)
|
||||
: null;
|
||||
|
||||
// A nominee must be another living, in-play Pi-Rat: not the voter, not a
|
||||
// previous-scene Deep, and not dead / awaiting-recruit (they'd reset to
|
||||
// Rank 1 anyway). Mirrors crud_upkeep.is_eligible_nominee.
|
||||
function eligibleNomineesFor(voter) {
|
||||
return state.players.filter(p =>
|
||||
p.id !== voter.id && p.role !== 'deep' && !p.is_dead && !p.needs_reroll
|
||||
);
|
||||
}
|
||||
$: myNominees = eligibleNomineesFor(state.player);
|
||||
$: hasVoted = !!state.votes.find(v => v.voter_player_id === state.player.id);
|
||||
// With no eligible crewmates, the voter skips voting entirely (no deadlock).
|
||||
$: mustVote = myNominees.length > 0;
|
||||
|
||||
// Drag & Drop State
|
||||
let keepCards = [];
|
||||
let discardCards = [];
|
||||
let dragNDropInitialized = false;
|
||||
|
||||
// Ghost & Re-rolling fate states
|
||||
let isRolling = false;
|
||||
let reRollDetails = {
|
||||
avatar_look: '',
|
||||
avatar_smell: '',
|
||||
first_words: '',
|
||||
good_at_math: ''
|
||||
};
|
||||
let rollingError = '';
|
||||
|
||||
// Ghost & Re-rolling fate choice
|
||||
async function becomeGhost() {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST');
|
||||
@@ -37,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() {
|
||||
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 {
|
||||
@@ -46,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 (!dragNDropInitialized) {
|
||||
@@ -181,75 +173,32 @@
|
||||
<h2>Between Scenes (Scene #{state.game.current_scene_number} Concluded)</h2>
|
||||
<p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p>
|
||||
|
||||
{#if state.player.is_dead && !state.player.is_ghost}
|
||||
{#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll}
|
||||
<!-- 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;">
|
||||
<h2 style="color: var(--red-suit); font-family: var(--font-heading); margin-bottom: 1rem; font-size: 2rem;">💀 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;">
|
||||
<div class="glass-panel death-fate-card">
|
||||
<h2>💀 Your Pi-Rat Has Died!</h2>
|
||||
<p class="description">
|
||||
Your story arc is complete! You went out in a blaze of glory (or retired honorably). You must now choose how your journey continues:
|
||||
</p>
|
||||
|
||||
{#if !isRolling}
|
||||
<div style="display: flex; gap: 1.5rem; justify-content: center; flex-wrap: wrap;">
|
||||
<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;">
|
||||
👻 Remain as a Ghost
|
||||
</button>
|
||||
<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;">
|
||||
🐀 Roll a New Character
|
||||
</button>
|
||||
</div>
|
||||
<p class="info-text" style="margin-top: 1.5rem; font-style: italic; color: var(--text-muted);">
|
||||
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 class="fate-choices">
|
||||
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}>
|
||||
👻 Remain as a Ghost
|
||||
</button>
|
||||
<button class="btn btn-primary btn-large glow-effect" on:click={chooseReRoll}>
|
||||
🐀 Roll a New Character
|
||||
</button>
|
||||
</div>
|
||||
<p class="info-text italic" style="margin-top: 1.5rem;">
|
||||
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh at Rank 1 — after upkeep, the whole crew will help create them.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<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 -->
|
||||
<div class="card glass-panel voting-card">
|
||||
<h3>1. Pirate Ranking (Voting)</h3>
|
||||
@@ -257,21 +206,27 @@
|
||||
|
||||
{#if state.game.phase === 'deep_upkeep'}
|
||||
<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>
|
||||
{:else if state.votes.find(v => v.voter_player_id === state.player.id)}
|
||||
{:else if hasVoted}
|
||||
<div class="voted-confirmation-box glass-panel">
|
||||
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
||||
</div>
|
||||
{:else if !mustVote}
|
||||
<div class="voted-confirmation-box glass-panel">
|
||||
<p class="info-text">No living crewmates left to nominate — you skip voting this round. Go ahead and ready up.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
|
||||
<div class="form-group inline-group">
|
||||
<select class="select-field" bind:value={selectedVoteId} required>
|
||||
<option value="">Nominate crewmate...</option>
|
||||
{#each state.players as p}
|
||||
{#if p.id !== state.player.id && p.role !== 'deep'}
|
||||
<option value={p.id}>{p.name} (Rank {p.rank})</option>
|
||||
{/if}
|
||||
{#each myNominees as p}
|
||||
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button>
|
||||
@@ -279,34 +234,31 @@
|
||||
</form>
|
||||
{/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>
|
||||
|
||||
<!-- 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">
|
||||
<h3>2. Resting Deep Upkeep</h3>
|
||||
|
||||
|
||||
{#if state.player.role === "deep"}
|
||||
<div class="deep-rest-panel glass-panel">
|
||||
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p>
|
||||
|
||||
{#if state.game.phase === 'between_scenes'}
|
||||
<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>
|
||||
{:else if state.game.phase === 'deep_upkeep'}
|
||||
|
||||
{#if state.player.is_ready}
|
||||
<!-- Confirmation: the refresh went through; show the freshly-drawn hand. -->
|
||||
<div class="voted-confirmation-box glass-panel" style="margin-top: 1rem;">
|
||||
<p class="success-text">✔️ Hand refreshed! You're holding {hand.length} card{hand.length === 1 ? '' : 's'} for the next scene.</p>
|
||||
</div>
|
||||
<div class="upkeep-flex" style="justify-content: center; margin-top: 1rem;">
|
||||
{#each hand as card}
|
||||
<Card {card} />
|
||||
{:else}
|
||||
<p class="empty-text text-center w-full">No cards in hand.</p>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="info-text text-center" style="margin-top: 1rem;">Waiting for the rest of the crew to finish upkeep...</p>
|
||||
{:else}
|
||||
<p class="info-text" style="margin-top: 1rem;">
|
||||
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
|
||||
Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
|
||||
@@ -349,8 +301,8 @@
|
||||
</div>
|
||||
|
||||
{#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);">
|
||||
<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>
|
||||
<div class="notice-banner danger" style="margin: 1rem 0; max-width: none;">
|
||||
<p class="text-danger font-bold" style="margin: 0;">⚠️ Discard Required: You have selected {keepCards.length} cards to keep, which exceeds your maximum hand limit of {maxHandSize}. Please discard at least {keepCards.length - maxHandSize} card(s).</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -360,31 +312,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if state.game.phase === 'between_scenes'}
|
||||
<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}
|
||||
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
|
||||
{/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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Next Scene Ready Up -->
|
||||
<div class="action-box margin-top">
|
||||
{#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>
|
||||
{:else}
|
||||
<div class="waiting-box">
|
||||
@@ -393,7 +330,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{: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>
|
||||
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
|
||||
{:else}
|
||||
@@ -412,8 +349,8 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if state.player.is_creator && state.game.phase === 'between_scenes'}
|
||||
<div style="margin-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
|
||||
{#if state.player.is_admin && state.game.phase === 'between_scenes'}
|
||||
<div class="end-story-box">
|
||||
<p class="info-text" style="font-size: 0.85rem;">All good stories end. When the crew agrees the legend is complete:</p>
|
||||
<button on:click={finishGame} class="btn btn-danger">🏴☠️ End the Story</button>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
<script>
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { getCardDisplay, isJoker, playerName as lookupName } from '../../lib/cards';
|
||||
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltipHtml, obstacleTable } from '../../lib/cards';
|
||||
import { tooltip } from '../../lib/tooltip';
|
||||
import ObstacleItem from './ObstacleItem.svelte';
|
||||
|
||||
export let state;
|
||||
|
||||
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);
|
||||
@@ -53,6 +63,31 @@
|
||||
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}
|
||||
@@ -60,11 +95,10 @@
|
||||
{/if}
|
||||
|
||||
{#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);">
|
||||
<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}
|
||||
@@ -72,39 +106,44 @@
|
||||
{/if}
|
||||
</h4>
|
||||
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<div class="challenge-actions">
|
||||
<button class="btn btn-primary" on:click={() => resolveChallenge(ch.id)} disabled={ch.tax_state === 'requested'}>Resolve</button>
|
||||
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ch.stakes}
|
||||
{#if ch.stakes_success}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>✅ On success:</strong> {ch.stakes_success}</p>
|
||||
{/if}
|
||||
{#if ch.stakes_failure}
|
||||
<p class="info-text" style="margin: 0.25rem 0 0 0;"><strong>❌ On failure:</strong> {ch.stakes_failure}</p>
|
||||
{/if}
|
||||
{#if ch.stakes && !ch.stakes_success && !ch.stakes_failure}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
|
||||
{/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!
|
||||
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}
|
||||
<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>
|
||||
<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}
|
||||
{#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>
|
||||
<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 style="display: flex; gap: 0.5rem;">
|
||||
<div class="challenge-actions">
|
||||
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
|
||||
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
|
||||
</div>
|
||||
@@ -116,12 +155,12 @@
|
||||
|
||||
<!-- 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 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>
|
||||
<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}>{p.name}</option>
|
||||
<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>
|
||||
@@ -132,12 +171,55 @@
|
||||
{#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;">
|
||||
<div class="challenge-actions">
|
||||
{#each hand.filter(c => !isJoker(c)) as card}
|
||||
<button class="btn btn-secondary" on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
||||
<button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable, true) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
||||
{/each}
|
||||
</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}
|
||||
|
||||
@@ -1,156 +1,213 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { getCardDisplay, isJoker } from '../../lib/cards';
|
||||
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 newNameInput = '';
|
||||
let pvpOpponentId = '';
|
||||
let pvpCard = '';
|
||||
|
||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||
$: duelTargets = scenePirats.filter(p => p.id !== state.player.id && !p.is_dead);
|
||||
$: 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);
|
||||
|
||||
async function submitNewName() {
|
||||
if (!newNameInput.trim()) return;
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
|
||||
new_name: newNameInput.trim()
|
||||
});
|
||||
newNameInput = '';
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
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/${state.player.id}/challenge/pvp/create`, 'POST', {
|
||||
defender_id: pvpOpponentId,
|
||||
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/challenge/pvp/create`, 'POST', {
|
||||
defender_id: target.id,
|
||||
card_code: pvpCard
|
||||
});
|
||||
pvpOpponentId = '';
|
||||
pvpCard = '';
|
||||
} catch(e) {
|
||||
close();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function setCaptain(makeCaptain) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/set-captain`, 'POST', {
|
||||
target_player_id: makeCaptain ? target.id : ''
|
||||
});
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleObjective(type) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${target.id}/objective/toggle`, 'POST', { type });
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card glass-panel sheet-card">
|
||||
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
||||
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||
|
||||
<div class="character-sheet-details">
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
<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}
|
||||
|
||||
{#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}
|
||||
<div class="character-sheet-details">
|
||||
<div class="sheet-main-column">
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</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>
|
||||
{#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}
|
||||
|
||||
{#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}
|
||||
{#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}
|
||||
|
||||
<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>
|
||||
<!-- 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}
|
||||
|
||||
<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 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}
|
||||
|
||||
<!-- Duel another Pi-Rat -->
|
||||
{#if !state.player.is_dead && duelTargets.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 duelTargets 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}
|
||||
{#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-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 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}
|
||||
@@ -1,152 +0,0 @@
|
||||
<script>
|
||||
import { apiRequest } from '../../lib/api';
|
||||
|
||||
export let state;
|
||||
|
||||
let error = '';
|
||||
let challengeTargetId = '';
|
||||
let challengeStakes = '';
|
||||
let selectedObstacles = {};
|
||||
let captainSelectId = '';
|
||||
|
||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||
|
||||
async function createChallenge() {
|
||||
error = '';
|
||||
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', {
|
||||
target_player_id: challengeTargetId,
|
||||
obstacle_ids: JSON.stringify(ids),
|
||||
stakes: challengeStakes
|
||||
});
|
||||
challengeTargetId = '';
|
||||
challengeStakes = '';
|
||||
selectedObstacles = {};
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function setCaptain() {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-captain`, 'POST', {
|
||||
target_player_id: captainSelectId
|
||||
});
|
||||
captainSelectId = '';
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleObjective(targetPlayerId, type) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', { type });
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function endScene() {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
|
||||
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Call a Challenge -->
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">⚔️ Call a Challenge</h4>
|
||||
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
|
||||
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<option value="">Challenge which Pi-Rat?</option>
|
||||
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||
<option value={p.id}>{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 scenePirats 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>
|
||||
@@ -1,131 +1,35 @@
|
||||
<script>
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { getCardDisplay, isJoker } from '../../lib/cards';
|
||||
import Card from '../Card.svelte';
|
||||
import ObstacleItem from './ObstacleItem.svelte';
|
||||
|
||||
export let state;
|
||||
|
||||
let error = '';
|
||||
|
||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||
|
||||
// A face-card Obstacle is worth the challenged Rat's Rank. When the Obstacle is
|
||||
// part of an open Challenge we know who that is — return their rank, else null.
|
||||
function challengedRankFor(obstacleId) {
|
||||
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obstacleId));
|
||||
if (!ch) return null;
|
||||
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
|
||||
}
|
||||
|
||||
async function playCard(obstacleId, cardCode) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
|
||||
obstacle_id: obstacleId,
|
||||
card_code: cardCode
|
||||
});
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function playJoker(obstacleId, cardCode) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-joker`, 'POST', {
|
||||
obstacle_id: obstacleId,
|
||||
card_code: cardCode
|
||||
});
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearObstacle(obstacleId) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
// 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 error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#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;">
|
||||
<Card card={active_card_code} size="medium" title="Active Card: {getCardDisplay(active_card_code)}" />
|
||||
</div>
|
||||
<div class="obstacle-details">
|
||||
<h4>{obs.title} {#if in_challenge}<span style="color: var(--red-suit, #ff2a5f); font-size: 0.8rem;">⚔️ In Challenge</span>{/if}</h4>
|
||||
<p class="desc">{obs.description}</p>
|
||||
</div>
|
||||
|
||||
<!-- Value Display -->
|
||||
<div class="obstacle-value-display text-center">
|
||||
<span class="val-label">Current Difficulty</span>
|
||||
<span class="val-number">
|
||||
{#if ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1))}
|
||||
{@const rank = challengedRankFor(obs.id)}
|
||||
{rank !== null ? `${rank} (Rat's Rank)` : "Challenged Rat's Rank"}
|
||||
{:else}
|
||||
{obs.current_value}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Successes Tracker -->
|
||||
<div class="obstacle-successes-display text-center">
|
||||
<span class="val-label">Successes</span>
|
||||
<span class="val-number" style="font-size: 1.5rem;">
|
||||
{success_count} / {state.players.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Played Cards Column -->
|
||||
<div class="played-column">
|
||||
<h5>Card History (Column)</h5>
|
||||
<div class="column-cards-flex">
|
||||
<Card card={obs.original_card} size="mini" />
|
||||
{#each column_cards as pc}
|
||||
<Card card={pc.card} size="mini" rotated success={pc.success}
|
||||
owner={pc.player_name.slice(0, 4)}
|
||||
title="{pc.player_name} played {getCardDisplay(pc.card)}" />
|
||||
{#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>
|
||||
</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>
|
||||
</details>
|
||||
{:else}
|
||||
<p class="empty-text">All Obstacles are tied up in the Challenge above.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
||||
{/each}
|
||||
<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>
|
||||
@@ -17,7 +17,9 @@ export async function apiRequest(endpoint, method = 'GET', data = null) {
|
||||
if (errBody.error) msg = errBody.error;
|
||||
else if (errBody.detail) msg = errBody.detail;
|
||||
} catch(e) {}
|
||||
throw new Error(msg);
|
||||
const error = new Error(msg);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,120 @@
|
||||
// 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');
|
||||
}
|
||||
@@ -22,6 +135,31 @@ export function getCardDisplay(code) {
|
||||
return `${cardValue(code)}${SUIT_EMOJI[cardSuit(code)] || cardSuit(code)}`;
|
||||
}
|
||||
|
||||
export function playerName(players, id) {
|
||||
return players.find(p => p.id === id)?.name || '???';
|
||||
// "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)));
|
||||
}
|
||||
@@ -1242,13 +1242,3 @@ function loadTechniquePool() {
|
||||
export async function getTechniqueSuggestion(exclude = []) {
|
||||
return pickFrom(await loadTechniquePool(), exclude);
|
||||
}
|
||||
|
||||
// Draws `count` distinct technique suggestions.
|
||||
export async function getTechniqueSuggestions(count, exclude = []) {
|
||||
const pool = await loadTechniquePool();
|
||||
const picked = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
picked.push(pickFrom(pool, [...exclude, ...picked]));
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
23
frontend/src/lib/theme.js
Normal file
23
frontend/src/lib/theme.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// Light/dark theme preference, shared across games via localStorage (the
|
||||
// rulebook page reads the same key). index.html applies the saved theme
|
||||
// before first paint; this store keeps the <html> attribute and storage in
|
||||
// sync when the user toggles from the corner menu.
|
||||
const STORAGE_KEY = 'pirats-theme';
|
||||
|
||||
function savedTheme() {
|
||||
const value = localStorage.getItem(STORAGE_KEY);
|
||||
return value === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export const theme = writable(savedTheme());
|
||||
|
||||
theme.subscribe((value) => {
|
||||
document.documentElement.dataset.theme = value;
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
});
|
||||
|
||||
export function toggleTheme() {
|
||||
theme.update((value) => (value === 'dark' ? 'light' : 'dark'));
|
||||
}
|
||||
9
frontend/src/lib/title.js
Normal file
9
frontend/src/lib/title.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Sets document.title to a " · "-joined set of page specifics followed by the
|
||||
// app name, so tabs and browser history entries are easy to tell apart.
|
||||
// Falsy parts are dropped; with no parts the title is just the app name.
|
||||
const BASE = 'Rats with Gats';
|
||||
|
||||
export function setPageTitle(...parts) {
|
||||
const prefix = parts.filter(Boolean).join(' · ');
|
||||
document.title = prefix ? `${prefix} — ${BASE}` : BASE;
|
||||
}
|
||||
84
frontend/src/lib/tooltip.js
Normal file
84
frontend/src/lib/tooltip.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// A styled hover/focus tooltip that replaces the native `title` attribute.
|
||||
//
|
||||
// use:tooltip={"plain text"} -> rendered as text
|
||||
// use:tooltip={{ html: "<b>rich</b>" }} -> rendered as HTML (trusted, static only)
|
||||
//
|
||||
// Only ever pass trusted, static HTML (e.g. built from the rulebook tables in
|
||||
// lib/cards.js); never interpolate raw player input into the `html` form.
|
||||
//
|
||||
// The floating element is appended to <body> and positioned with fixed
|
||||
// coordinates so it escapes any overflow:hidden / transformed ancestors
|
||||
// (cards, modals, scrolling columns).
|
||||
|
||||
function contentEmpty(content) {
|
||||
if (!content) return true;
|
||||
if (typeof content === 'object') return !content.html;
|
||||
return String(content).trim() === '';
|
||||
}
|
||||
|
||||
export function tooltip(node, content) {
|
||||
let current = content;
|
||||
let tip = null;
|
||||
|
||||
function show() {
|
||||
if (tip || contentEmpty(current)) return;
|
||||
tip = document.createElement('div');
|
||||
tip.className = 'tooltip-pop';
|
||||
tip.setAttribute('role', 'tooltip');
|
||||
if (typeof current === 'object' && current.html != null) {
|
||||
tip.innerHTML = current.html;
|
||||
} else {
|
||||
tip.textContent = String(current);
|
||||
}
|
||||
document.body.appendChild(tip);
|
||||
position();
|
||||
requestAnimationFrame(() => tip && tip.classList.add('visible'));
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (!tip) return;
|
||||
const r = node.getBoundingClientRect();
|
||||
const t = tip.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
// Prefer above the target; flip below when there isn't room.
|
||||
let top = r.top - t.height - margin;
|
||||
if (top < margin) top = r.bottom + margin;
|
||||
if (top + t.height > window.innerHeight - margin) {
|
||||
top = Math.max(margin, window.innerHeight - t.height - margin);
|
||||
}
|
||||
let left = r.left + r.width / 2 - t.width / 2;
|
||||
left = Math.max(margin, Math.min(left, window.innerWidth - t.width - margin));
|
||||
tip.style.top = `${top}px`;
|
||||
tip.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (tip) {
|
||||
tip.remove();
|
||||
tip = null;
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('mouseenter', show);
|
||||
node.addEventListener('mouseleave', hide);
|
||||
node.addEventListener('focus', show);
|
||||
node.addEventListener('blur', hide);
|
||||
|
||||
return {
|
||||
update(newContent) {
|
||||
current = newContent;
|
||||
if (tip) {
|
||||
// Refresh the content of an already-visible tip in place.
|
||||
hide();
|
||||
show();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
hide();
|
||||
node.removeEventListener('mouseenter', show);
|
||||
node.removeEventListener('mouseleave', hide);
|
||||
node.removeEventListener('focus', show);
|
||||
node.removeEventListener('blur', hide);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
import { mount } from 'svelte'
|
||||
|
||||
// Self-hosted fonts (bundled by Vite — no external font CDN at runtime)
|
||||
import '@fontsource/pirata-one'
|
||||
import '@fontsource/alegreya-sc/500.css'
|
||||
import '@fontsource/alegreya-sc/700.css'
|
||||
import '@fontsource/alegreya-sans/400.css'
|
||||
import '@fontsource/alegreya-sans/500.css'
|
||||
import '@fontsource/alegreya-sans/700.css'
|
||||
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
import { loadObstacleTable } from './lib/cards'
|
||||
|
||||
// Warm the obstacle-table cache so card tooltips can describe cards as Obstacles
|
||||
loadObstacleTable()
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app'),
|
||||
|
||||
@@ -1,13 +1,61 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { setPageTitle } from '../lib/title';
|
||||
import { getSessions } from '../lib/sessions';
|
||||
|
||||
export let params = {};
|
||||
let gameId = params.id;
|
||||
let adminKey = '';
|
||||
|
||||
// Return to this browser's own Pi-Rat dashboard for the game (the most
|
||||
// recently used session), not the public join screen. Falls back to join
|
||||
// if this browser has no saved session for the game.
|
||||
$: mySession = getSessions().find(s => s.gameId === gameId);
|
||||
$: backLink = mySession
|
||||
? `/#/game/${gameId}/player/${mySession.playerId}`
|
||||
: `/#/game/${gameId}/join`;
|
||||
$: backLabel = mySession ? '← Back to Game' : 'Back to Game Join Page';
|
||||
|
||||
let data = null;
|
||||
let error = '';
|
||||
let copiedPlayerId = null;
|
||||
let copiedTimer = null;
|
||||
let copiedInvite = false;
|
||||
let copiedInviteTimer = null;
|
||||
let copiedJoinCode = false;
|
||||
let copiedJoinCodeTimer = null;
|
||||
|
||||
$: setPageTitle('Admin', data?.game?.crew_name);
|
||||
|
||||
function rejoinLink(playerId) {
|
||||
return `${window.location.origin}/#/game/${gameId}/player/${playerId}`;
|
||||
}
|
||||
|
||||
function inviteLink() {
|
||||
return `${window.location.origin}/#/game/${gameId}/join`;
|
||||
}
|
||||
|
||||
function copyRejoinLink(playerId) {
|
||||
navigator.clipboard.writeText(rejoinLink(playerId));
|
||||
copiedPlayerId = playerId;
|
||||
clearTimeout(copiedTimer);
|
||||
copiedTimer = setTimeout(() => { copiedPlayerId = null; }, 2000);
|
||||
}
|
||||
|
||||
function copyInviteLink() {
|
||||
navigator.clipboard.writeText(inviteLink());
|
||||
copiedInvite = true;
|
||||
clearTimeout(copiedInviteTimer);
|
||||
copiedInviteTimer = setTimeout(() => { copiedInvite = false; }, 2000);
|
||||
}
|
||||
|
||||
function copyJoinCode() {
|
||||
navigator.clipboard.writeText(data.game.join_code);
|
||||
copiedJoinCode = true;
|
||||
clearTimeout(copiedJoinCodeTimer);
|
||||
copiedJoinCodeTimer = setTimeout(() => { copiedJoinCode = false; }, 2000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Try to load admin_key from local storage
|
||||
@@ -27,16 +75,49 @@
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setAdmin(playerId, makeAdmin) {
|
||||
try {
|
||||
await apiRequest(`/game/${gameId}/admin/set-admin`, 'POST', {
|
||||
key: adminKey,
|
||||
target_player_id: playerId,
|
||||
is_admin: makeAdmin,
|
||||
});
|
||||
await loadAdminData();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
// The last remaining Admin can't be kicked, so the table is never locked out.
|
||||
$: adminCount = data ? data.players.filter((p) => p.is_admin).length : 0;
|
||||
function canKick(p) {
|
||||
return !(p.is_admin && adminCount <= 1);
|
||||
}
|
||||
|
||||
async function kickPlayer(p) {
|
||||
const who = p.player_name || p.name;
|
||||
if (!confirm(`Kick ${who} from the game? Their cards go to the discard and they'll have to rejoin. This can't be undone.`)) return;
|
||||
try {
|
||||
await apiRequest(`/game/${gameId}/admin/kick`, 'POST', {
|
||||
key: adminKey,
|
||||
target_player_id: p.id,
|
||||
});
|
||||
await loadAdminData();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto p-4 space-y-6">
|
||||
<h1 class="text-3xl font-bold text-gold">Admin Panel</h1>
|
||||
<div class="admin-page">
|
||||
<h1>Admin Panel</h1>
|
||||
|
||||
{#if !data}
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl mb-4">Enter Admin Key</h2>
|
||||
<div class="card">
|
||||
<h2 class="mb-4">Enter Admin Key</h2>
|
||||
<form on:submit|preventDefault={loadAdminData} class="flex gap-4">
|
||||
<input type="text" bind:value={adminKey} class="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>
|
||||
</form>
|
||||
{#if error}
|
||||
@@ -44,45 +125,109 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-bold text-silver mb-4">Game: {data.game.crew_name}</h2>
|
||||
<p><strong>Phase:</strong> {data.game.phase}</p>
|
||||
<p><strong>Admin Key:</strong> <span class="bg-dark-900 px-2 py-1 font-mono text-sm">{data.game.admin_key}</span></p>
|
||||
<div class="card">
|
||||
<div class="admin-summary">
|
||||
<div class="admin-summary-details">
|
||||
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
|
||||
<p><strong>Phase:</strong> {data.game.phase}</p>
|
||||
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-join-code"
|
||||
class:copied={copiedJoinCode}
|
||||
aria-label="Copy join code {data.game.join_code}"
|
||||
on:click={copyJoinCode}
|
||||
>
|
||||
<span class="admin-join-code-label">Join Code</span>
|
||||
<strong>{data.game.join_code}</strong>
|
||||
<span class="admin-join-code-hint">{copiedJoinCode ? '✓ Copied!' : 'Click to copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-silver mt-8 mb-4">Players</h3>
|
||||
<table class="w-full text-left bg-dark-900 rounded border border-gray-700">
|
||||
{#if error}
|
||||
<div class="alert alert-danger mt-4">{error}</div>
|
||||
{/if}
|
||||
|
||||
<h3 class="mt-8 mb-4">Invite Link</h3>
|
||||
<p class="info-text">Share this link to let new players join the crew.</p>
|
||||
<div class="link-copy-action">
|
||||
<input type="text" readonly value={inviteLink()} class="input-field flex-grow" />
|
||||
<a href={inviteLink()} class="btn btn-secondary btn-small">Open</a>
|
||||
<button on:click={copyInviteLink} class="btn btn-secondary btn-small">
|
||||
{copiedInvite ? '✓ Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-8 mb-4">Players</h3>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700">
|
||||
<th class="p-3">Name</th>
|
||||
<th class="p-3">Role</th>
|
||||
<th class="p-3">Status</th>
|
||||
<th class="p-3">Rank</th>
|
||||
<tr>
|
||||
<th>Pi-Rat</th>
|
||||
<th>Player</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Rank</th>
|
||||
<th>Admin</th>
|
||||
<th>Re-join Link</th>
|
||||
<th>Kick</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.players as p}
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="p-3">{p.name}</td>
|
||||
<td class="p-3">
|
||||
<tr>
|
||||
<td>
|
||||
{#if p.name && p.name !== p.player_name}
|
||||
{p.name}
|
||||
{:else}
|
||||
<span class="text-muted">Not chosen yet</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>{p.player_name || p.name}</td>
|
||||
<td>
|
||||
{#if p.role === 'deep'} 🌊 Deep
|
||||
{:else if p.role === 'pirat'} 🐀 Pi-Rat
|
||||
{:else} None
|
||||
{/if}
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<td>
|
||||
{#if p.is_ghost} 👻 Ghost
|
||||
{:else if p.is_dead} 💀 Dead
|
||||
{:else} Alive
|
||||
{/if}
|
||||
</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>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { apiRequest } from '../lib/api';
|
||||
|
||||
import { extraMenuLinks } from '../lib/menu';
|
||||
import { getSuggestion } from '../lib/suggestions';
|
||||
import { displayName } from '../lib/cards';
|
||||
import { setPageTitle } from '../lib/title';
|
||||
import { saveSession, removeSession } from '../lib/sessions';
|
||||
|
||||
import LobbyPhase from '../components/LobbyPhase.svelte';
|
||||
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
|
||||
import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
|
||||
import ScenePhase from '../components/ScenePhase.svelte';
|
||||
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
||||
import RecruitPhase from '../components/RecruitPhase.svelte';
|
||||
import GameOverPhase from '../components/GameOverPhase.svelte';
|
||||
import CrewSidebar from '../components/CrewSidebar.svelte';
|
||||
import EventLog from '../components/EventLog.svelte';
|
||||
import NameModal from '../components/NameModal.svelte';
|
||||
import GatModal from '../components/GatModal.svelte';
|
||||
import RankBonusModal from '../components/RankBonusModal.svelte';
|
||||
|
||||
export let params = {};
|
||||
let gameId = params.id;
|
||||
@@ -16,52 +28,244 @@
|
||||
let state = null;
|
||||
let error = '';
|
||||
let intervalId;
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectDelay = 1000;
|
||||
let destroyed = false;
|
||||
let staleSession = false;
|
||||
// Non-scene phases use the same pinned Event Log column and fixed corner
|
||||
// toggle that ScenePhase owns for the main play screen.
|
||||
let phaseLogOpen = true;
|
||||
|
||||
function discardStaleSession(message) {
|
||||
staleSession = true;
|
||||
removeSession(gameId, playerId);
|
||||
state = null;
|
||||
error = message;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws) ws.close();
|
||||
}
|
||||
|
||||
async function fetchState() {
|
||||
if (staleSession) return;
|
||||
try {
|
||||
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
|
||||
// Preserve the Game Over screen for players who were already present
|
||||
// when play ended, but discard stale home-screen rejoin attempts.
|
||||
if (!state && data.game.phase === 'ended') {
|
||||
discardStaleSession('This game has ended and can no longer be rejoined.');
|
||||
return;
|
||||
}
|
||||
state = data;
|
||||
error = '';
|
||||
} catch (err) {
|
||||
if (!state && err.status === 404) {
|
||||
discardStaleSession('This saved game can no longer be rejoined. You may have been removed from the crew.');
|
||||
} else {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The server pushes a ping over this socket whenever the game changes;
|
||||
// each ping triggers a refetch of the state blob.
|
||||
function connectSocket() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
ws = new WebSocket(`${proto}://${location.host}/api/game/${gameId}/ws`);
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = 1000;
|
||||
fetchState(); // catch up on anything missed while disconnected
|
||||
};
|
||||
ws.onmessage = () => fetchState();
|
||||
ws.onclose = () => {
|
||||
if (destroyed || staleSession) return;
|
||||
reconnectTimer = setTimeout(connectSocket, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchState();
|
||||
connectSocket();
|
||||
// Slow fallback poll in case a push is ever missed
|
||||
intervalId = setInterval(fetchState, 30000);
|
||||
});
|
||||
|
||||
// Creator-only corner menu entries: Dev Mode toggle (all phases), the
|
||||
// Skip Character Creation shortcut (Dev Mode only), and the Admin link.
|
||||
async function toggleDevMode() {
|
||||
try {
|
||||
await apiRequest(`/game/${gameId}/player/${playerId}/dev-mode`, 'POST', {
|
||||
enabled: !state.game.dev_mode
|
||||
});
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchState();
|
||||
// Poll every 2 seconds
|
||||
intervalId = setInterval(fetchState, 2000);
|
||||
});
|
||||
async function skipCharacterCreation() {
|
||||
// Suggested values for everyone's free-text fields; the backend only
|
||||
// applies them to fields players haven't filled in themselves.
|
||||
const fills = {};
|
||||
for (const p of state.players) {
|
||||
fills[p.id] = {
|
||||
avatar_look: getSuggestion('look'),
|
||||
avatar_smell: getSuggestion('smell'),
|
||||
first_words: getSuggestion('first_words'),
|
||||
good_at_math: getSuggestion('good_at_math'),
|
||||
like: getSuggestion('like'),
|
||||
hate: getSuggestion('hate'),
|
||||
};
|
||||
}
|
||||
try {
|
||||
await apiRequest(`/game/${gameId}/player/${playerId}/skip-character-creation`, 'POST', {
|
||||
fills: JSON.stringify(fills)
|
||||
});
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Admin escape hatch: hand out every technique randomly and auto-assign J/Q/K
|
||||
async function autoAssignTechniques() {
|
||||
try {
|
||||
await apiRequest(`/game/${gameId}/player/${playerId}/auto-assign-techniques`, 'POST');
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Voluntarily remove yourself from the game (cards go to the discard)
|
||||
async function leaveGame() {
|
||||
if (!confirm("Leave this game? Your cards go to the discard and you'll need a new invite to rejoin. This can't be undone.")) return;
|
||||
try {
|
||||
await apiRequest(`/game/${gameId}/player/${playerId}/leave`, 'POST');
|
||||
removeSession(gameId, playerId);
|
||||
push('/');
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Distinguish tabs/history by the Pi-Rat (and crew) this dashboard is for
|
||||
$: setPageTitle(state ? displayName(state.player) : null, state?.game?.crew_name);
|
||||
|
||||
// Keep this browser's saved session up to date so the home page can rejoin
|
||||
// with current crew/rat names (the rat name changes when a Name is earned)
|
||||
$: if (state?.game && state?.player) {
|
||||
saveSession({
|
||||
gameId,
|
||||
playerId,
|
||||
crewName: state.game.crew_name,
|
||||
playerName: state.player.player_name,
|
||||
ratName: state.player.name,
|
||||
});
|
||||
}
|
||||
|
||||
// Admins get the admin key in their state blob; stash it so the Admin page works for them too
|
||||
$: if (state?.player?.is_admin && state?.game?.admin_key) {
|
||||
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);
|
||||
}
|
||||
|
||||
$: {
|
||||
const items = [];
|
||||
if (state?.player?.is_admin) {
|
||||
items.push({
|
||||
label: `🧪 Dev Mode: ${state.game.dev_mode ? 'On' : 'Off'}`,
|
||||
action: toggleDevMode,
|
||||
title: 'Toggle Dev Mode testing shortcuts for the whole table',
|
||||
});
|
||||
if (state.game.dev_mode && ['character_creation', 'swap_techniques', 'assign_techniques'].includes(state.game.phase)) {
|
||||
items.push({
|
||||
label: '⏭️ Skip Character Creation',
|
||||
action: skipCharacterCreation,
|
||||
title: 'Auto-fill anything unfinished for all players and jump to scene setup',
|
||||
});
|
||||
}
|
||||
if (state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques') {
|
||||
items.push({
|
||||
label: '🎲 Auto-Assign Techniques',
|
||||
action: autoAssignTechniques,
|
||||
title: 'Randomly distribute all techniques to eligible players and assign them to J/Q/K',
|
||||
});
|
||||
}
|
||||
items.push({ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' });
|
||||
}
|
||||
if (state?.player) {
|
||||
items.push({
|
||||
label: '🚪 Leave Game',
|
||||
action: leaveGame,
|
||||
title: 'Remove yourself from this game (your cards go to the discard)',
|
||||
});
|
||||
}
|
||||
extraMenuLinks.set(items);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws) ws.close();
|
||||
extraMenuLinks.set([]);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger" style="margin: 20px;">
|
||||
Error connecting to game: {error}
|
||||
{#if staleSession}
|
||||
<div class="mt-4">
|
||||
<a href="#/" class="btn btn-secondary btn-small">Back to Home</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state}
|
||||
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
||||
{#if state.game.phase === 'lobby'}
|
||||
<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'}
|
||||
{#if state.game.phase === 'scene'}
|
||||
<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}
|
||||
<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}
|
||||
</div>
|
||||
|
||||
{#if state.game.phase !== 'scene'}
|
||||
<button
|
||||
class="log-reopen-btn"
|
||||
title={phaseLogOpen ? 'Hide the event log' : 'Show the event log'}
|
||||
on:click={() => (phaseLogOpen = !phaseLogOpen)}
|
||||
>
|
||||
{phaseLogOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||
</button>
|
||||
{/if}
|
||||
<NameModal {state} />
|
||||
<GatModal {state} />
|
||||
<RankBonusModal {state} />
|
||||
{:else if !error}
|
||||
<div class="loading-container">
|
||||
<p>Loading game state...</p>
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { setPageTitle } from '../lib/title';
|
||||
import { getSessions, saveSession, removeSession } from '../lib/sessions';
|
||||
|
||||
let sessions = [];
|
||||
|
||||
onMount(() => {
|
||||
setPageTitle();
|
||||
sessions = getSessions();
|
||||
});
|
||||
|
||||
let crewName = '';
|
||||
let error = '';
|
||||
let creating = false;
|
||||
let joinCode = '';
|
||||
let joinCodeError = '';
|
||||
let joiningByCode = false;
|
||||
|
||||
function sessionRat(s) {
|
||||
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
|
||||
return s.ratName || s.playerName || 'Unknown rat';
|
||||
}
|
||||
|
||||
// Delete is a soft, undoable removal: drop it from localStorage right away
|
||||
// (so a refresh finalizes it) but keep the greyed row visible with an Undo.
|
||||
function deleteSession(s) {
|
||||
removeSession(s.gameId, s.playerId);
|
||||
s.deleted = true;
|
||||
sessions = sessions;
|
||||
}
|
||||
|
||||
function undoDelete(s) {
|
||||
saveSession({
|
||||
gameId: s.gameId, playerId: s.playerId,
|
||||
crewName: s.crewName, playerName: s.playerName, ratName: s.ratName,
|
||||
});
|
||||
s.deleted = false;
|
||||
sessions = sessions;
|
||||
}
|
||||
|
||||
async function createGame() {
|
||||
if (!crewName.trim()) return;
|
||||
@@ -21,41 +56,190 @@
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateJoinCode(event) {
|
||||
joinCode = event.currentTarget.value
|
||||
.toUpperCase()
|
||||
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, '')
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
async function joinByCode() {
|
||||
if (joinCode.length !== 5) return;
|
||||
joiningByCode = true;
|
||||
joinCodeError = '';
|
||||
try {
|
||||
const data = await apiRequest(`/game/code/${joinCode}`);
|
||||
push(`/game/${data.id}/join`);
|
||||
} catch (err) {
|
||||
joinCodeError = err.message;
|
||||
joiningByCode = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="welcome-container">
|
||||
<div class="header">
|
||||
<h1>RATS with GATS</h1>
|
||||
<p class="subtitle">A Remote Play Companion App</p>
|
||||
<header class="welcome-hero">
|
||||
<p class="hero-overline">A Remote Play Companion for</p>
|
||||
<h1 class="wordmark">Rats <span class="amp">with</span> Gats</h1>
|
||||
<p class="subtitle">Stowaway rats, transformed by math magic, out to steal a ship and commit some piracy.</p>
|
||||
<div class="hero-flourish" aria-hidden="true">🐀 ⚓ 🐀</div>
|
||||
</header>
|
||||
|
||||
{#if sessions.length > 0}
|
||||
<div class="glass-panel creation-card saved-sessions">
|
||||
<h2>Rejoin a Crew</h2>
|
||||
<p class="info-text" style="margin-bottom: 1rem;">Games you've joined from this browser. Removing one only forgets it here — it stays in the game.</p>
|
||||
<ul class="session-list">
|
||||
{#each sessions as s (s.gameId + s.playerId)}
|
||||
<li class="session-row {s.deleted ? 'is-deleted' : ''}">
|
||||
<div class="session-info">
|
||||
<span class="session-crew">{s.crewName || 'Unnamed Crew'}</span>
|
||||
<span class="session-rat text-muted">{sessionRat(s)}</span>
|
||||
</div>
|
||||
{#if s.deleted}
|
||||
<div class="session-actions">
|
||||
<span class="text-muted italic">Removed</span>
|
||||
<button class="btn btn-secondary btn-small" on:click={() => undoDelete(s)}>Undo</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="session-actions">
|
||||
<a href="#/game/{s.gameId}/player/{s.playerId}" class="btn btn-primary btn-small">Rejoin</a>
|
||||
<button class="btn btn-secondary btn-small" title="Forget this session" on:click={() => deleteSession(s)}>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="glass-panel creation-card join-code-card">
|
||||
<h2>Join a Crew</h2>
|
||||
<p class="info-text">Enter the five-character code shared by your game’s Admin.</p>
|
||||
<form class="join-code-form" on:submit|preventDefault={joinByCode}>
|
||||
<label for="joinCode">Join Code</label>
|
||||
<div class="join-code-action">
|
||||
<input
|
||||
type="text"
|
||||
id="joinCode"
|
||||
value={joinCode}
|
||||
on:input={updateJoinCode}
|
||||
required
|
||||
minlength="5"
|
||||
maxlength="5"
|
||||
placeholder="ABCDE"
|
||||
class="input-field input-large join-code-input"
|
||||
autocomplete="off"
|
||||
autocapitalize="characters"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary btn-large" disabled={joiningByCode || joinCode.length !== 5}>
|
||||
{joiningByCode ? 'Joining...' : 'Join'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if joinCodeError}
|
||||
<div class="alert alert-danger mt-4">{joinCodeError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card creation-card">
|
||||
<h2>Start a New Game</h2>
|
||||
<div class="glass-panel creation-card">
|
||||
<h2>Muster a New Crew</h2>
|
||||
<form on:submit|preventDefault={createGame}>
|
||||
<div class="form-group">
|
||||
<label for="crewName">Crew Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="crewName"
|
||||
bind:value={crewName}
|
||||
required
|
||||
placeholder="e.g. The Salty Dogs"
|
||||
class="form-control input-large"
|
||||
<input
|
||||
type="text"
|
||||
id="crewName"
|
||||
bind:value={crewName}
|
||||
required
|
||||
placeholder="e.g. The Salty Dogs"
|
||||
class="input-field input-large"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-large btn-block mt-4" disabled={creating}>
|
||||
<button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create Game'}
|
||||
</button>
|
||||
</form>
|
||||
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
|
||||
</div>
|
||||
|
||||
<div class="links mt-4">
|
||||
<a href="/rules" class="text-muted">Read the Rules</a>
|
||||
<div class="welcome-links">
|
||||
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Space the Rejoin box off the Muster box below it. */
|
||||
.saved-sessions {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.join-code-card {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.join-code-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.join-code-action {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
.join-code-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.session-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: var(--well);
|
||||
border: 1px solid var(--edge-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.session-row.is-deleted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.session-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.session-crew {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-rat {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.session-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { setPageTitle } from '../lib/title';
|
||||
import { saveSession } from '../lib/sessions';
|
||||
export let params = {};
|
||||
|
||||
const PLAYER_NAME_KEY = 'pirats-player-name';
|
||||
|
||||
let gameId = params.id;
|
||||
let playerName = '';
|
||||
let error = '';
|
||||
let joining = false;
|
||||
|
||||
onMount(() => {
|
||||
setPageTitle('Join the Crew');
|
||||
playerName = localStorage.getItem(PLAYER_NAME_KEY) || '';
|
||||
});
|
||||
|
||||
// We can extract ?creator=true from $querystring if needed,
|
||||
// though the backend determines creator based on if they are the first player.
|
||||
// For UI purposes, we could show "You are the creator" if we want.
|
||||
@@ -17,6 +28,10 @@
|
||||
error = '';
|
||||
try {
|
||||
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
||||
localStorage.setItem(PLAYER_NAME_KEY, playerName.trim());
|
||||
// Remember this session so the home page can offer a rejoin link;
|
||||
// the dashboard enriches it with crew/rat names once state loads.
|
||||
saveSession({ gameId, playerId: data.id, playerName });
|
||||
push(`/game/${gameId}/player/${data.id}`);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
@@ -26,12 +41,17 @@
|
||||
</script>
|
||||
|
||||
<div class="welcome-container">
|
||||
<div class="header">
|
||||
<h1>Join Crew</h1>
|
||||
</div>
|
||||
<header class="welcome-hero">
|
||||
<p class="hero-overline">Rats with Gats</p>
|
||||
<h1 class="wordmark">Join the Crew</h1>
|
||||
</header>
|
||||
|
||||
<div class="card creation-card">
|
||||
<h2>Enter your Pi-Rat name</h2>
|
||||
<div class="glass-panel creation-card">
|
||||
<h2>Enter your name</h2>
|
||||
<p class="info-text" style="margin-bottom: 1rem;">
|
||||
This is <strong>your</strong> name as a player, not your Pi-Rat's. Your Pi-Rat will be known
|
||||
by their smell (e.g. "Recruit Gunpowder") until they earn a proper Name in play.
|
||||
</p>
|
||||
<form on:submit|preventDefault={joinGame}>
|
||||
<div class="form-group">
|
||||
<label for="playerName">Your Name</label>
|
||||
@@ -40,7 +60,7 @@
|
||||
id="playerName"
|
||||
bind:value={playerName}
|
||||
required
|
||||
class="form-control input-large"
|
||||
class="input-field input-large"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
@@ -50,9 +70,13 @@
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/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'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="welcome-links">
|
||||
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,11 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
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"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"alembic",
|
||||
"fastapi",
|
||||
"sqlmodel",
|
||||
"uvicorn",
|
||||
"websockets",
|
||||
"python-multipart",
|
||||
]
|
||||
|
||||
@@ -27,7 +29,7 @@ pirats = "pirats.main:main"
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
pirats = ["static/**/*"]
|
||||
pirats = ["static/**/*", "rules.html", "migrations/**/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
|
||||
@@ -4,3 +4,4 @@ from .crud_character import *
|
||||
from .crud_scene import *
|
||||
from .crud_challenge import *
|
||||
from .crud_upkeep import *
|
||||
from .crud_rollback import *
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
from sqlmodel import Session, select
|
||||
from .models import Game, Player, GameEvent
|
||||
from .models import Game, Player, GameEvent, new_join_code
|
||||
from . import cards
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FACE_VALUES = ("J", "Q", "K")
|
||||
|
||||
# --- Card and Hand Utilities ---
|
||||
@@ -110,10 +114,6 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player], capt
|
||||
return base_size + 1
|
||||
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:
|
||||
"""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}
|
||||
@@ -138,6 +138,18 @@ def change_player_rank(db: Session, game: Game, player: Player, delta: int):
|
||||
db.commit()
|
||||
apply_hand_increases(db, game, old_maxes)
|
||||
|
||||
def is_eligible_nominee(voter, nominee) -> bool:
|
||||
"""A rank-up nominee must be another living, in-play Pi-Rat. Excludes the
|
||||
voter, previous-scene Deep players, and dead / awaiting-recruit players
|
||||
(ranking them up is wasted — recruits reset to Rank 1). Shared by the
|
||||
between-scenes rank vote and the 3rd-objective story bonus."""
|
||||
return (
|
||||
nominee.id != voter.id
|
||||
and nominee.role != "deep"
|
||||
and not nominee.is_dead
|
||||
and not nominee.needs_reroll
|
||||
)
|
||||
|
||||
def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""):
|
||||
"""Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
|
||||
if game.captain_player_id == player_id:
|
||||
@@ -222,54 +234,100 @@ def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -
|
||||
|
||||
# --- CRUD Operations ---
|
||||
|
||||
def dev_mode_default() -> bool:
|
||||
"""
|
||||
Initial Dev Mode state for new games, from $PIRATS_DEV_MODE. Unset means a
|
||||
local checkout, where defaulting on is convenient for testing; production
|
||||
deployments (the Nix service) always set it, "false" by default.
|
||||
"""
|
||||
val = os.environ.get("PIRATS_DEV_MODE")
|
||||
if val is None:
|
||||
return True
|
||||
return val.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
MIN_PLAYERS = 3
|
||||
|
||||
def has_min_players(game: Game) -> bool:
|
||||
"""The crew needs at least MIN_PLAYERS to start a game or a new scene; Dev Mode bypasses this for testing."""
|
||||
return game.dev_mode or len(game.players) >= MIN_PLAYERS
|
||||
|
||||
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
||||
deck = cards.get_fresh_deck()
|
||||
join_code = ""
|
||||
for _ in range(100):
|
||||
candidate = new_join_code()
|
||||
if not db.exec(select(Game).where(Game.join_code == candidate)).first():
|
||||
join_code = candidate
|
||||
break
|
||||
if not join_code:
|
||||
raise RuntimeError("Could not allocate a unique join code")
|
||||
game = Game(
|
||||
join_code=join_code,
|
||||
deck_cards=json.dumps(deck),
|
||||
phase="lobby",
|
||||
current_scene_number=1,
|
||||
extra_obstacles=0,
|
||||
crew_name=crew_name
|
||||
crew_name=crew_name,
|
||||
dev_mode=dev_mode_default()
|
||||
)
|
||||
db.add(game)
|
||||
db.commit()
|
||||
db.refresh(game)
|
||||
logger.info("Game %s created (crew_name=%r, dev_mode=%s)", game.id, crew_name, game.dev_mode)
|
||||
return game
|
||||
|
||||
def get_game(db: Session, game_id: str) -> Optional[Game]:
|
||||
return db.get(Game, game_id)
|
||||
|
||||
def get_game_by_join_code(db: Session, join_code: str) -> Optional[Game]:
|
||||
return db.exec(select(Game).where(Game.join_code == join_code)).first()
|
||||
|
||||
def get_player(db: Session, player_id: str) -> Optional[Player]:
|
||||
return db.get(Player, player_id)
|
||||
|
||||
def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player:
|
||||
game = db.get(Game, game_id)
|
||||
role = None
|
||||
if game and game.phase == "scene":
|
||||
role = "pirat"
|
||||
|
||||
|
||||
# Players who join after initial character creation create their Pi-Rat in
|
||||
# the between-scenes recruit_creation phase; until then they spectate.
|
||||
needs_reroll = bool(game and game.phase not in ("lobby", "character_creation"))
|
||||
|
||||
player = Player(
|
||||
game_id=game_id,
|
||||
name=name,
|
||||
name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
|
||||
player_name=name,
|
||||
is_creator=is_creator,
|
||||
is_admin=is_creator, # the creator starts as an Admin; more can be granted from the admin panel
|
||||
rank=2, # default starting rank (will be set properly when starting character creation)
|
||||
hand_cards="[]",
|
||||
is_ready=False,
|
||||
role=role
|
||||
role=None,
|
||||
needs_reroll=needs_reroll
|
||||
)
|
||||
db.add(player)
|
||||
db.commit()
|
||||
db.refresh(player)
|
||||
|
||||
# Players joining mid-scene would otherwise never receive cards (starting
|
||||
# hands are only dealt when scene 1 starts), so deal them in immediately.
|
||||
if game and game.phase == "scene":
|
||||
if game:
|
||||
db.refresh(game)
|
||||
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
|
||||
draw_cards_for_player(db, game, player, max_size)
|
||||
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)
|
||||
|
||||
add_game_event(db, game_id, f"{name} joined the crew!", kind="join")
|
||||
msg = f"{name} joined the crew!"
|
||||
if needs_reroll and game and game.phase != "recruit_creation":
|
||||
msg += " They'll create their Pi-Rat with the crew between scenes."
|
||||
add_game_event(db, game_id, msg, kind="join")
|
||||
|
||||
logger.info(
|
||||
"Player %s (%r) joined game %s (phase=%s, creator=%s)",
|
||||
player.id, name, game_id, game.phase if game else "?", is_creator,
|
||||
)
|
||||
return player
|
||||
|
||||
def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
|
||||
|
||||
@@ -9,6 +9,7 @@ from .crud_base import (
|
||||
draw_cards_for_player, add_game_event, change_player_rank, evaluate_card_play
|
||||
)
|
||||
from .crud_scene import resolve_card_against_obstacle
|
||||
from .crud_character import recruit_name
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
@@ -35,7 +36,8 @@ def create_challenge(
|
||||
deep_player_id: str,
|
||||
target_player_id: str,
|
||||
obstacle_ids: List[str],
|
||||
stakes: str = ""
|
||||
stakes_success: str = "",
|
||||
stakes_failure: str = ""
|
||||
) -> Tuple[bool, str]:
|
||||
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat."""
|
||||
game = get_game(db, game_id)
|
||||
@@ -49,6 +51,8 @@ def create_challenge(
|
||||
return False, "The Deep cannot challenge itself. Pick a Pi-Rat!"
|
||||
if target.is_dead:
|
||||
return False, f"{target.name} is dead. The Deep has no quarrel with the deceased."
|
||||
if target.needs_reroll:
|
||||
return False, f"{target.name} has no Pi-Rat in this scene."
|
||||
if not obstacle_ids:
|
||||
return False, "Apply at least one Obstacle to the Challenge."
|
||||
|
||||
@@ -71,15 +75,20 @@ def create_challenge(
|
||||
acting_player_id=target.id,
|
||||
challenger_player_id=deep_player.id,
|
||||
obstacle_ids=json.dumps(obstacle_ids),
|
||||
stakes=stakes.strip(),
|
||||
stakes_success=stakes_success.strip(),
|
||||
stakes_failure=stakes_failure.strip(),
|
||||
)
|
||||
db.add(challenge)
|
||||
db.commit()
|
||||
|
||||
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids)
|
||||
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
|
||||
if stakes.strip():
|
||||
msg += f" Stakes: {stakes.strip()}"
|
||||
success_text = stakes_success.strip()
|
||||
failure_text = stakes_failure.strip()
|
||||
if success_text:
|
||||
msg += f" On success: {success_text}"
|
||||
if failure_text:
|
||||
msg += f" On failure: {failure_text}"
|
||||
add_game_event(db, game.id, msg, kind="challenge")
|
||||
return True, "Challenge called!"
|
||||
|
||||
@@ -199,15 +208,21 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
|
||||
thing = "Gat" if challenge.tax_type == "gat" else "Name"
|
||||
if refuser:
|
||||
if success:
|
||||
add_game_event(db, game.id, f"{acting.name} succeeded and keeps {refuser.name}'s {thing}! {refuser.name} must find a new one.", kind="tax")
|
||||
add_game_event(db, game.id, f"{acting.name} succeeded and keeps the stolen {thing}! {refuser.name} must find a new one.", kind="tax")
|
||||
else:
|
||||
if challenge.tax_type == "gat":
|
||||
acting.completed_personal_1 = False
|
||||
refuser.completed_personal_1 = True
|
||||
# The Gat (and its description) returns to its owner.
|
||||
refuser.gat_description = acting.gat_description
|
||||
acting.gat_description = ""
|
||||
else:
|
||||
# Return the stolen name string; the failed thief reverts to
|
||||
# their smell-based recruit identity.
|
||||
acting.completed_personal_2 = False
|
||||
acting.needs_name = False
|
||||
refuser.completed_personal_2 = True
|
||||
refuser.name = acting.name
|
||||
acting.name = recruit_name(acting)
|
||||
acting.tax_banned = True
|
||||
db.add(refuser)
|
||||
db.add(acting)
|
||||
@@ -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."
|
||||
if requester.tax_banned:
|
||||
return False, "You failed a refused Tax this scene — no more Gat/Name Taxes for you!"
|
||||
if target.id == requester.id or target.role == "deep" or target.is_dead:
|
||||
if target.id == requester.id or target.role == "deep" or target.is_dead or target.needs_reroll:
|
||||
return False, "Pick another living Pi-Rat in the scene."
|
||||
|
||||
if not requester.completed_personal_1:
|
||||
@@ -332,17 +347,27 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
|
||||
if challenge.tax_type == "gat":
|
||||
requester.completed_personal_1 = True
|
||||
responder.completed_personal_1 = False
|
||||
# The Gat changes hands along with its description (like a stolen Name).
|
||||
requester.gat_description = responder.gat_description
|
||||
responder.gat_description = ""
|
||||
event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!"
|
||||
else:
|
||||
# The Name itself is stolen: the requester literally takes the responder's
|
||||
# name string, demoting the responder back to their smell-based identity.
|
||||
stolen_name = responder.name
|
||||
old_requester_name = requester.name
|
||||
requester.completed_personal_2 = True
|
||||
requester.needs_name = True
|
||||
responder.completed_personal_2 = False
|
||||
responder.name = recruit_name(responder)
|
||||
requester.name = stolen_name
|
||||
event_msg = f"{stolen_name} refused the Name Tax and is demoted to {responder.name}! {old_requester_name} steals the name '{stolen_name}' and attempts the Challenge — succeed to keep it!"
|
||||
challenge.tax_state = "refused"
|
||||
db.add(requester)
|
||||
db.add(responder)
|
||||
db.add(challenge)
|
||||
db.commit()
|
||||
change_player_rank(db, game, requester, 1)
|
||||
add_game_event(db, game.id, 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!", kind="tax")
|
||||
add_game_event(db, game.id, event_msg, kind="tax")
|
||||
return True, "Tax refused. The prize is on the line!"
|
||||
|
||||
# --- Pi-Rat vs Pi-Rat Challenges ---
|
||||
@@ -369,6 +394,8 @@ def create_pvp_challenge(
|
||||
return False, "You cannot challenge yourself. (Well, you can, but not with cards.)"
|
||||
if defender.is_dead:
|
||||
return False, f"{defender.name} is dead. Let them rest."
|
||||
if challenger.needs_reroll or defender.needs_reroll:
|
||||
return False, "Pending recruits have no Pi-Rat to duel with."
|
||||
|
||||
for challenge in game.challenges:
|
||||
if challenge.status == "open" and defender.id in (challenge.target_player_id, challenge.acting_player_id):
|
||||
|
||||
@@ -6,6 +6,33 @@ from .crud_base import get_player, get_game, add_game_event
|
||||
|
||||
# --- Character Creation Operations ---
|
||||
|
||||
def recruit_name_from_smell(avatar_smell: str, fallback: str = "") -> str:
|
||||
"""
|
||||
A nameless Pi-Rat goes by "Recruit {Smell}". Strips lead-ins like "smells like"
|
||||
or "faintly of" (the suggestion pool uses these) so the name reads naturally.
|
||||
"""
|
||||
clean_smell = avatar_smell.strip()
|
||||
if not clean_smell:
|
||||
return f"Recruit {fallback}".strip() if fallback else "Recruit"
|
||||
lead_ins = [
|
||||
"suspiciously smelling of", "pleasantly smelling of", "reminiscent of",
|
||||
"smells like", "a mix of", "like a mix of", "strongly of", "faintly of",
|
||||
"like", "of",
|
||||
]
|
||||
stripped = True
|
||||
while stripped:
|
||||
stripped = False
|
||||
for lead in lead_ins:
|
||||
if clean_smell.lower().startswith(lead + " "):
|
||||
clean_smell = clean_smell[len(lead) + 1:]
|
||||
stripped = True
|
||||
clean_smell = clean_smell[:1].upper() + clean_smell[1:]
|
||||
return f"Recruit {clean_smell}"
|
||||
|
||||
def recruit_name(player) -> str:
|
||||
"""The smell-based temporary identity for a player's Pi-Rat."""
|
||||
return recruit_name_from_smell(player.avatar_smell, fallback=player.player_name or player.name)
|
||||
|
||||
def auto_delegate_all(db: Session, game: Game):
|
||||
"""
|
||||
Assigns the Like/Hate delegated questions for every player at once.
|
||||
@@ -86,40 +113,87 @@ def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3
|
||||
# Check if all players have submitted techniques. If so, trigger the swap!
|
||||
if not game:
|
||||
return None
|
||||
maybe_trigger_technique_swap(db, game)
|
||||
return None
|
||||
|
||||
all_submitted = True
|
||||
for p in game.players:
|
||||
def unsubmit_techniques(db: Session, player_id: str):
|
||||
"""Unlocks a player's submitted techniques for revision. Only possible
|
||||
while the crew is still in character_creation: clearing the submission
|
||||
also keeps the swap phase from triggering until they resubmit."""
|
||||
player = get_player(db, player_id)
|
||||
if not player:
|
||||
return False, "Player not found."
|
||||
game = get_game(db, player.game_id)
|
||||
if not game or game.phase != "character_creation":
|
||||
return False, "Techniques can't be revised once swapping has begun."
|
||||
if player.needs_reroll:
|
||||
return False, "You're spectating until recruit creation."
|
||||
if not json.loads(player.created_techniques):
|
||||
return False, "You haven't submitted techniques yet."
|
||||
player.created_techniques = "[]"
|
||||
db.add(player)
|
||||
db.commit()
|
||||
return True, "Techniques unlocked for revision."
|
||||
|
||||
def unassign_face_techniques(db: Session, player_id: str):
|
||||
"""Unlocks a player's J/Q/K assignment for revision. Clearing is_ready
|
||||
keeps the game from advancing to scene setup until they reassign."""
|
||||
player = get_player(db, player_id)
|
||||
if not player:
|
||||
return False, "Player not found."
|
||||
game = get_game(db, player.game_id)
|
||||
if not game or game.phase != "assign_techniques":
|
||||
return False, "Face-card assignment isn't in progress."
|
||||
if not player.is_ready:
|
||||
return False, "You haven't assigned your face cards yet."
|
||||
player.tech_jack = ""
|
||||
player.tech_queen = ""
|
||||
player.tech_king = ""
|
||||
player.is_ready = False
|
||||
db.add(player)
|
||||
db.commit()
|
||||
return True, "Face cards unlocked for revision."
|
||||
|
||||
def swap_participants(game: Game) -> list:
|
||||
"""Players taking part in initial character creation (late joiners with a
|
||||
queued recruit re-roll spectate and are excluded)."""
|
||||
return [p for p in game.players if not p.needs_reroll]
|
||||
|
||||
def holds_own_technique(player) -> bool:
|
||||
return any(t["creator_id"] == player.id for t in json.loads(player.held_techniques))
|
||||
|
||||
def maybe_trigger_technique_swap(db: Session, game: Game):
|
||||
"""Begin the swap phase once every participant has submitted 3 techniques.
|
||||
Safe to call after a roster change (e.g. a kick) to unblock the phase."""
|
||||
participants = swap_participants(game)
|
||||
if not participants:
|
||||
return
|
||||
for p in participants:
|
||||
techs = json.loads(p.created_techniques)
|
||||
if len(techs) < 3 or not all(techs):
|
||||
all_submitted = False
|
||||
break
|
||||
|
||||
if all_submitted:
|
||||
trigger_technique_swap(db, game)
|
||||
return None
|
||||
return
|
||||
trigger_technique_swap(db, game)
|
||||
|
||||
def trigger_technique_swap(db: Session, game: Game):
|
||||
"""
|
||||
Shuffles and distributes 3 techniques created by OTHER players to each player.
|
||||
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:
|
||||
return
|
||||
|
||||
# Build a pool of all techniques with their creators
|
||||
pool = []
|
||||
|
||||
# Reject the pool if any two techniques collide (case-insensitively)
|
||||
seen_texts = set()
|
||||
has_duplicates = False
|
||||
|
||||
for p in players:
|
||||
techs = json.loads(p.created_techniques)
|
||||
for t in techs:
|
||||
for t in json.loads(p.created_techniques):
|
||||
t_clean = t.strip().lower()
|
||||
if t_clean in seen_texts:
|
||||
has_duplicates = True
|
||||
seen_texts.add(t_clean)
|
||||
pool.append({"text": t, "creator_id": p.id})
|
||||
|
||||
|
||||
if has_duplicates:
|
||||
# Force players to resubmit
|
||||
for p in players:
|
||||
@@ -128,223 +202,625 @@ def trigger_technique_swap(db: Session, game: Game):
|
||||
db.commit()
|
||||
add_game_event(db, game.id, "Duplicate techniques submitted! The pool has been cleared. All players must try again with unique technique names.", kind="warning")
|
||||
return
|
||||
|
||||
# Swap algorithm with simple retry backtracking
|
||||
# We want to assign 3 techniques from the pool to each player, ensuring creator_id != player.id.
|
||||
# For a small number of players, random shuffling with retries is highly efficient.
|
||||
success = False
|
||||
for attempt in range(200):
|
||||
random.shuffle(pool)
|
||||
assignments = {p.id: [] for p in players}
|
||||
temp_pool = pool.copy()
|
||||
|
||||
# Greedy assignment
|
||||
failed = False
|
||||
|
||||
for p in players:
|
||||
p.held_techniques = json.dumps([{"text": t, "creator_id": p.id} for t in json.loads(p.created_techniques)])
|
||||
p.swapped_techniques = "[]"
|
||||
p.swap_offer_to_id = None
|
||||
p.swap_offer_technique = ""
|
||||
p.is_ready = False
|
||||
db.add(p)
|
||||
game.phase = "swap_techniques"
|
||||
db.add(game)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, "Phase changed: Swap Techniques — trade away all 3 of your own techniques with blind swap offers!", kind="phase")
|
||||
|
||||
def offer_swap(db: Session, player_id: str, target_player_id: str, technique: str):
|
||||
"""Offers one held technique to another player as a blind swap. The target
|
||||
learns only that an offer exists, never which technique is on the table."""
|
||||
player = get_player(db, player_id)
|
||||
target = get_player(db, target_player_id)
|
||||
if not player or not target or player.game_id != target.game_id:
|
||||
return False, "Player not found."
|
||||
game = get_game(db, player.game_id)
|
||||
if not game or game.phase != "swap_techniques":
|
||||
return False, "Technique swapping isn't in progress."
|
||||
if player.id == target.id:
|
||||
return False, "You can't swap techniques with yourself."
|
||||
if target.needs_reroll:
|
||||
return False, "That player is spectating until recruit creation."
|
||||
if player.swap_offer_to_id:
|
||||
return False, "You already have a swap offer pending. Cancel it first."
|
||||
held = json.loads(player.held_techniques)
|
||||
entry = next((t for t in held if t["text"] == technique), None)
|
||||
if not entry:
|
||||
return False, "You don't hold that technique."
|
||||
if entry["creator_id"] == target.id:
|
||||
return False, "You can't offer a technique back to its creator."
|
||||
player.swap_offer_to_id = target.id
|
||||
player.swap_offer_technique = technique
|
||||
db.add(player)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, f"{player.name} offered a mystery technique swap to {target.name}.", kind="info")
|
||||
return True, "Swap offered."
|
||||
|
||||
def cancel_swap_offer(db: Session, player_id: str):
|
||||
player = get_player(db, player_id)
|
||||
if not player:
|
||||
return False, "Player not found."
|
||||
if not player.swap_offer_to_id:
|
||||
return False, "You have no pending swap offer."
|
||||
target = get_player(db, player.swap_offer_to_id)
|
||||
player.swap_offer_to_id = None
|
||||
player.swap_offer_technique = ""
|
||||
db.add(player)
|
||||
db.commit()
|
||||
if target:
|
||||
add_game_event(db, player.game_id, f"{player.name} withdrew their swap offer to {target.name}.", kind="info")
|
||||
return True, "Offer withdrawn."
|
||||
|
||||
def respond_swap_offer(db: Session, player_id: str, offerer_id: str, accept: bool, technique: str = ""):
|
||||
"""Accepts (trading back one of your held techniques) or declines a swap
|
||||
offer. On accept the two techniques change hands, creators and all."""
|
||||
player = get_player(db, player_id)
|
||||
offerer = get_player(db, offerer_id)
|
||||
if not player or not offerer or player.game_id != offerer.game_id:
|
||||
return False, "Player not found."
|
||||
game = get_game(db, player.game_id)
|
||||
if not game or game.phase != "swap_techniques":
|
||||
return False, "Technique swapping isn't in progress."
|
||||
if offerer.swap_offer_to_id != player.id:
|
||||
return False, "That player hasn't offered you a swap."
|
||||
|
||||
if not accept:
|
||||
offerer.swap_offer_to_id = None
|
||||
offerer.swap_offer_technique = ""
|
||||
db.add(offerer)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, f"{player.name} declined {offerer.name}'s swap offer.", kind="info")
|
||||
return True, "Offer declined."
|
||||
|
||||
my_held = json.loads(player.held_techniques)
|
||||
my_entry = next((t for t in my_held if t["text"] == technique), None)
|
||||
if not my_entry:
|
||||
return False, "You don't hold that technique."
|
||||
if my_entry["creator_id"] == offerer.id:
|
||||
return False, "You can't trade a technique back to its creator."
|
||||
their_held = json.loads(offerer.held_techniques)
|
||||
their_entry = next((t for t in their_held if t["text"] == offerer.swap_offer_technique), None)
|
||||
if not their_entry:
|
||||
return False, "The offered technique is no longer available."
|
||||
|
||||
my_held.remove(my_entry)
|
||||
my_held.append(their_entry)
|
||||
their_held.remove(their_entry)
|
||||
their_held.append(my_entry)
|
||||
player.held_techniques = json.dumps(my_held)
|
||||
offerer.held_techniques = json.dumps(their_held)
|
||||
offerer.swap_offer_to_id = None
|
||||
offerer.swap_offer_technique = ""
|
||||
# If the technique we just gave away was promised to someone else, withdraw that offer
|
||||
if player.swap_offer_technique == my_entry["text"]:
|
||||
player.swap_offer_to_id = None
|
||||
player.swap_offer_technique = ""
|
||||
db.add(player)
|
||||
db.add(offerer)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, f"{player.name} and {offerer.name} swapped techniques!", kind="info")
|
||||
return True, "Techniques swapped."
|
||||
|
||||
def set_swap_ready(db: Session, player_id: str, ready: bool):
|
||||
"""Marks a player done (or not done) swapping. Readying up requires having
|
||||
traded away all self-created techniques; once everyone is ready the game
|
||||
moves on to face-card assignment."""
|
||||
player = get_player(db, player_id)
|
||||
if not player:
|
||||
return False, "Player not found."
|
||||
game = get_game(db, player.game_id)
|
||||
if not game or game.phase != "swap_techniques":
|
||||
return False, "Technique swapping isn't in progress."
|
||||
if ready:
|
||||
if holds_own_technique(player):
|
||||
return False, "You still hold techniques you created. Swap them away first!"
|
||||
if player.swap_offer_to_id:
|
||||
return False, "You have a swap offer pending. Cancel it or wait for an answer first."
|
||||
player.is_ready = ready
|
||||
db.add(player)
|
||||
db.commit()
|
||||
|
||||
maybe_finish_technique_swap(db, game)
|
||||
return True, "Ready!" if ready else "No longer ready."
|
||||
|
||||
def maybe_finish_technique_swap(db: Session, game: Game):
|
||||
"""Advance to face-card assignment once everyone has readied up in the swap
|
||||
phase. Safe to call after a roster change to unblock the phase."""
|
||||
participants = swap_participants(game)
|
||||
if participants and all(p.is_ready for p in participants):
|
||||
finish_technique_swap(db, game)
|
||||
|
||||
def finish_technique_swap(db: Session, game: Game):
|
||||
"""Locks in everyone's held techniques and advances to J/Q/K assignment."""
|
||||
for p in swap_participants(game):
|
||||
p.swapped_techniques = json.dumps([t["text"] for t in json.loads(p.held_techniques)])
|
||||
p.swap_offer_to_id = None
|
||||
p.swap_offer_technique = ""
|
||||
p.is_ready = False # Reset ready flag for the J/Q/K assignment step
|
||||
db.add(p)
|
||||
game.phase = "assign_techniques"
|
||||
db.add(game)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, "Phase changed: Assign Techniques — place your new techniques on your Jack, Queen, and King.", kind="phase")
|
||||
|
||||
def auto_assign_techniques(db: Session, game: Game):
|
||||
"""
|
||||
Admin escape hatch: randomly redistributes every created technique to an
|
||||
eligible player (never its creator) and assigns them to J/Q/K, finishing
|
||||
the whole technique distribution step in one click.
|
||||
"""
|
||||
if game.phase not in ("swap_techniques", "assign_techniques"):
|
||||
return False, "Technique distribution isn't in progress."
|
||||
|
||||
if game.phase == "swap_techniques":
|
||||
players = swap_participants(game)
|
||||
pool = []
|
||||
for p in players:
|
||||
valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id]
|
||||
if len(valid_for_p) < 3:
|
||||
# If only 1-2 players are playing, it might be impossible to assign 3 non-owned techniques.
|
||||
# In that case, let's allow assigning their own if there are no other options (e.g. 1 player).
|
||||
if len(players) < 3:
|
||||
valid_for_p = temp_pool.copy()
|
||||
else:
|
||||
failed = True
|
||||
break
|
||||
|
||||
# Take 3
|
||||
chosen = valid_for_p[:3]
|
||||
for c in chosen:
|
||||
assignments[p.id].append(c["text"])
|
||||
temp_pool.remove(c)
|
||||
|
||||
if not failed:
|
||||
# Successfully matched! Save assignments.
|
||||
for t in json.loads(p.created_techniques):
|
||||
pool.append({"text": t, "creator_id": p.id})
|
||||
|
||||
# Random shuffling with retries: for a small table this finds a valid
|
||||
# creator != assignee distribution almost immediately.
|
||||
assignments = None
|
||||
for attempt in range(200):
|
||||
random.shuffle(pool)
|
||||
candidate = {p.id: [] for p in players}
|
||||
temp_pool = pool.copy()
|
||||
failed = False
|
||||
for p in players:
|
||||
p.swapped_techniques = json.dumps(assignments[p.id])
|
||||
p.is_ready = False # Reset ready flag for J/Q/K assignment step
|
||||
db.add(p)
|
||||
game.phase = "swap_techniques"
|
||||
db.add(game)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, "Phase changed: Swap Techniques", kind="phase")
|
||||
success = True
|
||||
break
|
||||
|
||||
if not success:
|
||||
# Fallback in case of failure (e.g., edge cases, single player testing): just distribute whatever
|
||||
valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id]
|
||||
if len(valid_for_p) < 3:
|
||||
# With 1-2 players a non-owned distribution can be impossible;
|
||||
# fall back to allowing own techniques.
|
||||
if len(players) < 3:
|
||||
valid_for_p = temp_pool.copy()
|
||||
else:
|
||||
failed = True
|
||||
break
|
||||
chosen = valid_for_p[:3]
|
||||
for c in chosen:
|
||||
candidate[p.id].append(c)
|
||||
temp_pool.remove(c)
|
||||
if not failed:
|
||||
assignments = candidate
|
||||
break
|
||||
|
||||
if assignments is None:
|
||||
# Last-resort fallback: deal the pool out in order
|
||||
random.shuffle(pool)
|
||||
assignments = {p.id: pool[i * 3:(i + 1) * 3] for i, p in enumerate(players)}
|
||||
|
||||
for p in players:
|
||||
# Just give them the first 3 from the pool
|
||||
p.swapped_techniques = json.dumps([t["text"] for t in pool[:3]])
|
||||
p.held_techniques = json.dumps(assignments[p.id])
|
||||
p.swapped_techniques = json.dumps([t["text"] for t in assignments[p.id]])
|
||||
p.swap_offer_to_id = None
|
||||
p.swap_offer_technique = ""
|
||||
p.is_ready = False
|
||||
db.add(p)
|
||||
game.phase = "swap_techniques"
|
||||
game.phase = "assign_techniques"
|
||||
db.add(game)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, "Phase changed: Swap Techniques", kind="phase")
|
||||
add_game_event(db, game.id, "Techniques were auto-distributed to the crew!", kind="info")
|
||||
|
||||
# Random J/Q/K for anyone who hasn't assigned yet; the last assignment
|
||||
# triggers the rank rolls and the scene_setup transition.
|
||||
for p in list(swap_participants(game)):
|
||||
if not (p.tech_jack and p.tech_queen and p.tech_king):
|
||||
swapped = json.loads(p.swapped_techniques)
|
||||
random.shuffle(swapped)
|
||||
assign_face_card_techniques(db, p.id, swapped[0], swapped[1], swapped[2])
|
||||
elif not p.is_ready:
|
||||
assign_face_card_techniques(db, p.id, p.tech_jack, p.tech_queen, p.tech_king)
|
||||
|
||||
db.refresh(game)
|
||||
if game.phase != "scene_setup":
|
||||
return False, "Auto-assignment couldn't finish; check the event log."
|
||||
return True, "Techniques auto-assigned."
|
||||
|
||||
def maybe_finish_assign_techniques(db: Session, game: Game):
|
||||
"""Once everyone has assigned their J/Q/K, roll starting ranks and move to
|
||||
scene setup. Safe to call after a roster change to unblock the phase."""
|
||||
players_list = swap_participants(game)
|
||||
if not players_list or not all(p.is_ready for p in players_list):
|
||||
return
|
||||
|
||||
# 1. Randomly assign starting ranks
|
||||
random.shuffle(players_list)
|
||||
|
||||
# Teaching mode: an admin opted to be the forced Rank-3 Deep for the first
|
||||
# scene, so seed them at the front instead of leaving it to chance.
|
||||
if game.teaching_deep_player_id:
|
||||
teacher = next((p for p in players_list if p.id == game.teaching_deep_player_id), None)
|
||||
if teacher:
|
||||
players_list.remove(teacher)
|
||||
players_list.insert(0, teacher)
|
||||
|
||||
# "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene."
|
||||
# "Randomly choose one Player to start the game at Rank 1. This Player must play their Pi-Rat during the first scene."
|
||||
# "All remaining Players start at Rank 2. These Players may choose to play either during the first scene."
|
||||
|
||||
if len(players_list) >= 2:
|
||||
players_list[0].rank = 3
|
||||
players_list[0].role = "deep"
|
||||
players_list[0].previous_role = "deep"
|
||||
|
||||
players_list[1].rank = 1
|
||||
players_list[1].role = "pirat"
|
||||
players_list[1].previous_role = "pirat"
|
||||
|
||||
for p in players_list[2:]:
|
||||
p.rank = 2
|
||||
p.role = None
|
||||
p.previous_role = None
|
||||
else:
|
||||
# Edge case for 1 player testing
|
||||
players_list[0].rank = 3
|
||||
players_list[0].role = "pirat"
|
||||
players_list[0].previous_role = "pirat"
|
||||
|
||||
# Reset ready status for scene setup
|
||||
for p in game.players:
|
||||
p.is_ready = False
|
||||
db.add(p)
|
||||
|
||||
game.phase = "scene_setup"
|
||||
db.add(game)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
||||
|
||||
def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
|
||||
player = get_player(db, player_id)
|
||||
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_queen = queen
|
||||
player.tech_king = king
|
||||
player.is_ready = True
|
||||
db.add(player)
|
||||
db.commit()
|
||||
|
||||
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
|
||||
game = get_game(db, player.game_id)
|
||||
if not game:
|
||||
return
|
||||
|
||||
if all(p.is_ready for p in game.players):
|
||||
# 1. Randomly assign starting ranks
|
||||
players_list = list(game.players)
|
||||
random.shuffle(players_list)
|
||||
|
||||
# "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene."
|
||||
# "Randomly choose one Player to start the game at Rank 1. This Player must play their Pi-Rat during the first scene."
|
||||
# "All remaining Players start at Rank 2. These Players may choose to play either during the first scene."
|
||||
|
||||
if len(players_list) >= 2:
|
||||
players_list[0].rank = 3
|
||||
players_list[0].role = "deep"
|
||||
players_list[0].previous_role = "deep"
|
||||
|
||||
players_list[1].rank = 1
|
||||
players_list[1].role = "pirat"
|
||||
players_list[1].previous_role = "pirat"
|
||||
|
||||
for p in players_list[2:]:
|
||||
p.rank = 2
|
||||
p.role = None
|
||||
p.previous_role = None
|
||||
else:
|
||||
# Edge case for 1 player testing
|
||||
players_list[0].rank = 3
|
||||
players_list[0].role = "pirat"
|
||||
players_list[0].previous_role = "pirat"
|
||||
|
||||
# Reset ready status for scene setup
|
||||
for p in game.players:
|
||||
p.is_ready = False
|
||||
db.add(p)
|
||||
|
||||
game.phase = "scene_setup"
|
||||
db.add(game)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
||||
|
||||
def roll_new_character(
|
||||
db: Session,
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
avatar_look: str,
|
||||
avatar_smell: str,
|
||||
first_words: str,
|
||||
good_at_math: str
|
||||
):
|
||||
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
|
||||
maybe_finish_assign_techniques(db, game)
|
||||
return True, "Techniques assigned."
|
||||
|
||||
def skip_character_creation(db: Session, game: Game, fills: dict):
|
||||
"""
|
||||
Dev Mode shortcut: auto-completes every unfinished part of character
|
||||
creation for ALL players and advances to scene setup. `fills` maps
|
||||
player_id -> suggested values for the free-text fields (avatar_look,
|
||||
avatar_smell, first_words, good_at_math, like, hate), supplied by the
|
||||
admin's client from the frontend suggestion pools. Techniques come from
|
||||
the backend pool. Already-filled fields are left untouched.
|
||||
"""
|
||||
from .cards import TECHNIQUE_SUGGESTIONS
|
||||
|
||||
if game.phase not in ("character_creation", "swap_techniques", "assign_techniques"):
|
||||
return False, "Character creation isn't in progress."
|
||||
|
||||
if game.phase == "character_creation":
|
||||
# 1. Basic Rat Records
|
||||
for p in game.players:
|
||||
f = fills.get(p.id, {})
|
||||
p.avatar_look = p.avatar_look or f.get("avatar_look", "A nondescript test rat")
|
||||
p.avatar_smell = p.avatar_smell or f.get("avatar_smell", "of fresh paint")
|
||||
p.first_words = p.first_words or f.get("first_words", "Is this thing on?")
|
||||
p.good_at_math = p.good_at_math or f.get("good_at_math", "Untested")
|
||||
if not p.completed_personal_2:
|
||||
p.name = recruit_name(p)
|
||||
db.add(p)
|
||||
|
||||
# 2. Delegated Like/Hate questions
|
||||
auto_delegate_all(db, game)
|
||||
for p in game.players:
|
||||
f = fills.get(p.id, {})
|
||||
if p.other_like_from_player_id and not p.other_like:
|
||||
p.other_like = f.get("like", "Their impeccable punctuality")
|
||||
if p.other_hate_from_player_id and not p.other_hate:
|
||||
p.other_hate = f.get("hate", "Their impeccable punctuality")
|
||||
db.add(p)
|
||||
|
||||
# 3. Created techniques, drawn from the backend pool without collisions
|
||||
taken = set()
|
||||
for p in game.players:
|
||||
for t in json.loads(p.created_techniques):
|
||||
taken.add(t.strip().lower())
|
||||
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in taken]
|
||||
random.shuffle(available)
|
||||
for p in game.players:
|
||||
techs = json.loads(p.created_techniques)
|
||||
if len(techs) < 3 or not all(techs):
|
||||
techs = [available.pop() for _ in range(3)]
|
||||
p.created_techniques = json.dumps(techs)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
|
||||
# 4. Move to the swap phase (rejects the pool if there are duplicates)
|
||||
trigger_technique_swap(db, game)
|
||||
db.refresh(game)
|
||||
if game.phase != "swap_techniques":
|
||||
return False, "Technique swap failed; check the event log."
|
||||
|
||||
# 5. Auto-distribute any unswapped techniques and random-assign J/Q/K;
|
||||
# the last assignment triggers the rank rolls and the scene_setup transition.
|
||||
ok, msg = auto_assign_techniques(db, game)
|
||||
if not ok:
|
||||
return False, "Could not finish character creation; check the event log."
|
||||
return True, "Character creation skipped."
|
||||
|
||||
# --- Recruit Creation (death re-rolls and late joiners) ---
|
||||
#
|
||||
# Dead Pi-Rats who choose to re-roll (and players who joined after initial
|
||||
# character creation) are queued with needs_reroll=True. Once between-scenes
|
||||
# upkeep finishes, the game detours through the "recruit_creation" phase:
|
||||
# the recruit answers the basic sheet questions while crewmates answer their
|
||||
# Like/Hate questions and write their 3 replacement techniques. The recruit
|
||||
# then assigns those techniques to J/Q/K and finalizes, and once every pending
|
||||
# recruit has finalized the game moves on to scene_setup.
|
||||
|
||||
def choose_reroll(db: Session, player_id: str):
|
||||
"""A dead Pi-Rat opts to come back as a fresh recruit (vs. becoming a Ghost).
|
||||
The actual creation happens in the recruit_creation phase after upkeep."""
|
||||
player = get_player(db, player_id)
|
||||
game = get_game(db, game_id)
|
||||
if not player or not game:
|
||||
return
|
||||
|
||||
# 1. Return current hand cards to the deck
|
||||
from .crud_base import get_player_hand, set_player_hand, get_game_deck, set_game_deck, draw_cards_for_player, calculate_max_hand_size
|
||||
if not player:
|
||||
return False, "Player not found."
|
||||
if player.needs_reroll:
|
||||
return True, "Already queued for a new recruit."
|
||||
if not player.is_dead or player.is_ghost:
|
||||
return False, "Only dead Pi-Rats can roll a new recruit."
|
||||
player.needs_reroll = True
|
||||
db.add(player)
|
||||
db.commit()
|
||||
add_game_event(db, player.game_id, f"{player.name} will return as a fresh recruit! The crew will help create them after upkeep.", kind="join")
|
||||
return True, "Queued for recruit creation."
|
||||
|
||||
def techniques_in_use(game: Game, exclude_player_id: str = None) -> set:
|
||||
"""Lowercased technique names already on someone's sheet or pending for a recruit."""
|
||||
in_use = set()
|
||||
for p in game.players:
|
||||
if p.id == exclude_player_id:
|
||||
continue
|
||||
texts = [p.tech_jack, p.tech_queen, p.tech_king]
|
||||
texts += json.loads(p.created_techniques) + json.loads(p.swapped_techniques)
|
||||
texts += [t["text"] for t in json.loads(p.held_techniques)]
|
||||
texts += [s["text"] for s in json.loads(p.incoming_techniques)]
|
||||
for t in texts:
|
||||
if t:
|
||||
in_use.add(t.strip().lower())
|
||||
return in_use
|
||||
|
||||
def activate_recruit(db: Session, game: Game, player):
|
||||
"""Wipes a pending recruit's sheet and assigns crewmates to answer their
|
||||
Like/Hate questions and write their 3 replacement techniques."""
|
||||
from .crud_base import get_player_hand, set_player_hand, get_game_deck, set_game_deck
|
||||
|
||||
# Return any leftover hand to the deck
|
||||
hand = get_player_hand(player)
|
||||
deck = get_game_deck(game)
|
||||
deck.extend(hand)
|
||||
random.shuffle(deck)
|
||||
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
|
||||
if hand:
|
||||
deck = get_game_deck(game)
|
||||
deck.extend(hand)
|
||||
random.shuffle(deck)
|
||||
set_game_deck(game, deck)
|
||||
set_player_hand(player, [])
|
||||
|
||||
# A dead Captain's position does not pass to their replacement recruit
|
||||
if game.captain_player_id == player.id:
|
||||
game.captain_player_id = None
|
||||
|
||||
# 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
|
||||
# in play on another player's sheet (collisions make for boring recruits).
|
||||
from .cards import TECHNIQUE_SUGGESTIONS
|
||||
in_use = set()
|
||||
for p in game.players:
|
||||
if p.id == player.id:
|
||||
continue
|
||||
for t in [p.tech_jack, p.tech_queen, p.tech_king] + json.loads(p.created_techniques) + json.loads(p.swapped_techniques):
|
||||
if t:
|
||||
in_use.add(t.strip().lower())
|
||||
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use]
|
||||
if len(available) < 3:
|
||||
available = TECHNIQUE_SUGGESTIONS
|
||||
chosen_techs = random.sample(available, 3)
|
||||
player.created_techniques = json.dumps(chosen_techs)
|
||||
player.swapped_techniques = json.dumps(chosen_techs)
|
||||
player.tech_jack = chosen_techs[0]
|
||||
player.tech_queen = chosen_techs[1]
|
||||
player.tech_king = chosen_techs[2]
|
||||
|
||||
# 4. Auto-delegate Like/Hate questions to other players
|
||||
other_players = [p for p in game.players if p.id != player.id]
|
||||
if other_players:
|
||||
like_target = random.choice(other_players)
|
||||
player.other_like_from_player_id = like_target.id
|
||||
# New recruits always start at Rank 1 with a blank sheet
|
||||
player.rank = 1
|
||||
player.role = None
|
||||
player.is_ready = False
|
||||
player.is_dead = False
|
||||
player.is_ghost = False
|
||||
player.tax_banned = False
|
||||
player.needs_name = False
|
||||
player.needs_rank_3_bonus = False
|
||||
player.completed_personal_1 = False
|
||||
player.completed_personal_2 = False
|
||||
player.completed_personal_3 = False
|
||||
player.avatar_look = ""
|
||||
player.avatar_smell = ""
|
||||
player.first_words = ""
|
||||
player.good_at_math = ""
|
||||
player.created_techniques = "[]"
|
||||
player.held_techniques = "[]"
|
||||
player.swapped_techniques = "[]"
|
||||
player.swap_offer_to_id = None
|
||||
player.swap_offer_technique = ""
|
||||
player.tech_jack = ""
|
||||
player.tech_queen = ""
|
||||
player.tech_king = ""
|
||||
player.name = recruit_name(player)
|
||||
|
||||
# Crewmates fill in the social questions and write the recruit's techniques.
|
||||
# Prefer helpers who aren't busy creating their own recruit.
|
||||
others = [p for p in game.players if p.id != player.id]
|
||||
helpers = [p for p in others if not p.needs_reroll] or others
|
||||
if helpers:
|
||||
random.shuffle(helpers)
|
||||
player.other_like_from_player_id = helpers[0].id
|
||||
player.other_like = ""
|
||||
|
||||
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_from_player_id = helpers[1 % len(helpers)].id
|
||||
player.other_hate = ""
|
||||
slots = [{"from_id": helpers[i % len(helpers)].id, "text": ""} for i in range(3)]
|
||||
player.incoming_techniques = json.dumps(slots)
|
||||
else:
|
||||
# Solo game: nobody to ask, so fall back to the suggestion pool
|
||||
from .cards import TECHNIQUE_SUGGESTIONS
|
||||
player.other_like_from_player_id = None
|
||||
player.other_like = ""
|
||||
player.other_hate_from_player_id = None
|
||||
player.other_hate = ""
|
||||
|
||||
in_use = techniques_in_use(game, exclude_player_id=player.id)
|
||||
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use]
|
||||
if len(available) < 3:
|
||||
available = TECHNIQUE_SUGGESTIONS
|
||||
chosen = random.sample(available, 3)
|
||||
player.incoming_techniques = json.dumps([{"from_id": None, "text": t} for t in chosen])
|
||||
|
||||
db.add(player)
|
||||
db.add(game)
|
||||
db.commit()
|
||||
|
||||
# 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)
|
||||
draw_cards_for_player(db, game, player, max_size)
|
||||
|
||||
add_game_event(db, game.id, f"{player.name} has joined the crew as a new Rank {player.rank} recruit!", kind="join")
|
||||
add_game_event(db, game.id, f"{player.name} has joined the crew as a fresh Rank {player.rank} recruit!", kind="join")
|
||||
|
||||
maybe_finish_recruit_creation(db, game)
|
||||
return True, "Welcome aboard!"
|
||||
|
||||
def maybe_finish_recruit_creation(db: Session, game: Game):
|
||||
"""Leave recruit creation for scene setup once no pending recruits remain.
|
||||
Safe to call after a roster change to unblock the phase."""
|
||||
if not any(p.needs_reroll for p in game.players):
|
||||
from .crud_upkeep import begin_next_scene_setup
|
||||
begin_next_scene_setup(db, game)
|
||||
|
||||
def _unused_recruit_technique(game: Game, recruit_id: str) -> str:
|
||||
"""A technique name not already in play, for backfilling a recruit slot whose
|
||||
assigned crewmate is gone (no one left to write it)."""
|
||||
from .cards import TECHNIQUE_SUGGESTIONS
|
||||
in_use = techniques_in_use(game, exclude_player_id=recruit_id)
|
||||
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use] or list(TECHNIQUE_SUGGESTIONS)
|
||||
return random.choice(available)
|
||||
|
||||
def repair_references_to_removed_player(db: Session, game: Game, removed_id: str):
|
||||
"""Fix every other player's pointers to a player who is being removed, so
|
||||
nothing dangles or stalls: pending swap offers are dropped, delegated
|
||||
Like/Hate questions and recruit technique slots are handed to a present
|
||||
crewmate (or filled from the pool when nobody is left to do them)."""
|
||||
others = [p for p in game.players if p.id != removed_id]
|
||||
helpers = [p for p in others if not p.needs_reroll]
|
||||
|
||||
def pick_helper(exclude_id):
|
||||
pool = [h for h in helpers if h.id != exclude_id]
|
||||
return random.choice(pool) if pool else None
|
||||
|
||||
for p in others:
|
||||
changed = False
|
||||
|
||||
# A swap offer aimed at the removed player can never be answered.
|
||||
if p.swap_offer_to_id == removed_id:
|
||||
p.swap_offer_to_id = None
|
||||
p.swap_offer_technique = ""
|
||||
changed = True
|
||||
|
||||
# Delegated Like/Hate the removed player still owed: hand to another
|
||||
# present crewmate, or drop the question if there's no one left.
|
||||
if p.other_like_from_player_id == removed_id and not p.other_like:
|
||||
alt = pick_helper(p.id)
|
||||
p.other_like_from_player_id = alt.id if alt else None
|
||||
changed = True
|
||||
if p.other_hate_from_player_id == removed_id and not p.other_hate:
|
||||
alt = pick_helper(p.id)
|
||||
p.other_hate_from_player_id = alt.id if alt else None
|
||||
changed = True
|
||||
|
||||
# Recruit technique slots the removed player was assigned to write.
|
||||
if p.needs_reroll:
|
||||
slots = json.loads(p.incoming_techniques)
|
||||
slots_changed = False
|
||||
for s in slots:
|
||||
if s.get("from_id") == removed_id:
|
||||
alt = pick_helper(p.id)
|
||||
if alt:
|
||||
s["from_id"] = alt.id
|
||||
elif not s.get("text"):
|
||||
# Nobody left to write it — backfill so the recruit can
|
||||
# still finalize.
|
||||
s["from_id"] = None
|
||||
s["text"] = _unused_recruit_technique(game, p.id)
|
||||
else:
|
||||
s["from_id"] = None
|
||||
slots_changed = True
|
||||
if slots_changed:
|
||||
p.incoming_techniques = json.dumps(slots)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
db.add(p)
|
||||
db.commit()
|
||||
|
||||
216
src/pirats/crud_rollback.py
Normal file
216
src/pirats/crud_rollback.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Game-state rollback via full snapshots.
|
||||
|
||||
The whole of a game's gameplay state lives in a handful of small, `game_id`-scoped
|
||||
tables, so a "checkpoint" is just a JSON dump of those rows. Rolling back = restoring
|
||||
the dump. This sidesteps deterministic replay entirely (the deck uses random.shuffle,
|
||||
whose result is materialized into state and captured verbatim).
|
||||
|
||||
Scope (deliberately confined to keep the edge cases out — see the plan):
|
||||
- Checkpoints are captured only while the game is in the `scene` phase, one per action.
|
||||
- Leaving a scene purges that scene's checkpoints, so any surviving checkpoint is in
|
||||
the current scene. Rollback therefore can never cross a scene boundary.
|
||||
|
||||
The event log (`GameEvent`) is an append-only spine and is NOT part of a snapshot.
|
||||
Each event carries a `checkpoint_id` instead (see models.py):
|
||||
None = in-flight (created since the last capture; the next capture tags it)
|
||||
> 0 = a live rollback target within the current scene
|
||||
0 = sealed/historical (a past scene or non-scene event; never rollback-able)
|
||||
|
||||
Rolling back is non-destructive: it restores a checkpoint's snapshot into the live
|
||||
tables and points `Game.rollback_head_checkpoint_id` at it, leaving the later
|
||||
checkpoints/events in place (the frontend greys them). Rolling forward to a later
|
||||
checkpoint "undoes" the rollback. The abandoned future is only discarded when a new
|
||||
action is taken from a rolled-back state (see maintain_rollback_history), which is
|
||||
what commits the new timeline.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from .models import Game, Player, Obstacle, Challenge, Vote, GameEvent, Checkpoint
|
||||
|
||||
# Game columns that are rollback *control* state, not gameplay — never snapshotted,
|
||||
# and preserved (not overwritten) when a snapshot is restored.
|
||||
SNAPSHOT_EXCLUDE = {"rollback_timeline_version", "rollback_head_checkpoint_id"}
|
||||
# Additionally never reassigned on restore (identity / secrets that don't change).
|
||||
_APPLY_SKIP = SNAPSHOT_EXCLUDE | {"id", "admin_key"}
|
||||
|
||||
# The child tables captured in a snapshot, in the order rows are re-inserted.
|
||||
_CHILD_MODELS = (Player, Obstacle, Challenge, Vote)
|
||||
|
||||
|
||||
def serialize_game_state(game: Game) -> str:
|
||||
"""Dump a game's gameplay state (Game minus control columns, plus all child rows)
|
||||
to a JSON string. The event log is intentionally excluded."""
|
||||
return json.dumps({
|
||||
"game": game.model_dump(exclude=SNAPSHOT_EXCLUDE),
|
||||
"players": [p.model_dump() for p in game.players],
|
||||
"obstacles": [o.model_dump() for o in game.obstacles],
|
||||
"challenges": [c.model_dump() for c in game.challenges],
|
||||
"votes": [v.model_dump() for v in game.votes],
|
||||
})
|
||||
|
||||
|
||||
def apply_game_state(db: Session, game: Game, state_json: str) -> None:
|
||||
"""Restore a serialized snapshot into the live tables: overwrite the Game row's
|
||||
gameplay columns, then replace all child rows (re-inserted with their original ids
|
||||
so foreign keys and frontend references stay stable)."""
|
||||
blob = json.loads(state_json)
|
||||
|
||||
for key, value in blob["game"].items():
|
||||
if key not in _APPLY_SKIP:
|
||||
setattr(game, key, value)
|
||||
db.add(game)
|
||||
|
||||
# Wipe existing children via the ORM (keeps the identity map consistent so we can
|
||||
# re-insert rows with the same primary keys), then flush before re-inserting.
|
||||
for model in _CHILD_MODELS:
|
||||
for row in db.exec(select(model).where(model.game_id == game.id)).all():
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
|
||||
for row in blob["players"]:
|
||||
db.add(Player(**row))
|
||||
for row in blob["obstacles"]:
|
||||
db.add(Obstacle(**row))
|
||||
for row in blob["challenges"]:
|
||||
db.add(Challenge(**row))
|
||||
for row in blob["votes"]:
|
||||
db.add(Vote(**row))
|
||||
db.commit()
|
||||
|
||||
|
||||
def capture_checkpoint(db: Session, game: Game) -> Checkpoint:
|
||||
"""Snapshot the post-action state and tag this action's events with it.
|
||||
|
||||
Called from the middleware after a successful mutation while in the `scene` phase.
|
||||
The in-flight events (checkpoint_id IS NULL) are exactly this action's events, since
|
||||
every prior event is already tagged (>0) or sealed (0)."""
|
||||
cp = Checkpoint(game_id=game.id, state_json=serialize_game_state(game))
|
||||
db.add(cp)
|
||||
db.commit()
|
||||
db.refresh(cp)
|
||||
|
||||
untagged = db.exec(
|
||||
select(GameEvent).where(
|
||||
GameEvent.game_id == game.id,
|
||||
GameEvent.checkpoint_id.is_(None),
|
||||
)
|
||||
).all()
|
||||
for e in untagged:
|
||||
e.checkpoint_id = cp.id
|
||||
db.add(e)
|
||||
db.commit()
|
||||
return cp
|
||||
|
||||
|
||||
def seal_and_purge(db: Session, game: Game) -> None:
|
||||
"""Clear a game's rollback history. Called from the middleware after any mutation
|
||||
outside the `scene` phase, so leaving a scene tidies up and the next scene starts
|
||||
clean: every still-active event is sealed to 0 (committed history) and all
|
||||
checkpoints are deleted."""
|
||||
active = db.exec(
|
||||
select(GameEvent).where(
|
||||
GameEvent.game_id == game.id,
|
||||
# NULL (in-flight) or >0 (current-scene targets); 0 is already sealed.
|
||||
(GameEvent.checkpoint_id.is_(None)) | (GameEvent.checkpoint_id > 0),
|
||||
)
|
||||
).all()
|
||||
for e in active:
|
||||
e.checkpoint_id = 0
|
||||
db.add(e)
|
||||
|
||||
for cp in db.exec(select(Checkpoint).where(Checkpoint.game_id == game.id)).all():
|
||||
db.delete(cp)
|
||||
game.rollback_head_checkpoint_id = None
|
||||
db.add(game)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _discard_future(db: Session, game: Game, after_checkpoint_id: int) -> None:
|
||||
"""Delete the checkpoints (and their events) created after a checkpoint — the
|
||||
abandoned timeline when a new action is taken from a rolled-back state."""
|
||||
for e in db.exec(
|
||||
select(GameEvent).where(
|
||||
GameEvent.game_id == game.id,
|
||||
GameEvent.checkpoint_id > after_checkpoint_id,
|
||||
)
|
||||
).all():
|
||||
db.delete(e)
|
||||
for c in db.exec(
|
||||
select(Checkpoint).where(
|
||||
Checkpoint.game_id == game.id,
|
||||
Checkpoint.id > after_checkpoint_id,
|
||||
)
|
||||
).all():
|
||||
db.delete(c)
|
||||
|
||||
|
||||
def latest_checkpoint_id(db: Session, game_id: str) -> Optional[int]:
|
||||
"""The newest checkpoint id for a game (None if there are none). During live play
|
||||
this is the point the game is at — the frontend hides the rollback button on the
|
||||
event sitting at the current position, since rolling there would be a no-op."""
|
||||
cp = db.exec(
|
||||
select(Checkpoint).where(Checkpoint.game_id == game_id)
|
||||
.order_by(Checkpoint.id.desc())
|
||||
).first()
|
||||
return cp.id if cp else None
|
||||
|
||||
|
||||
def maintain_rollback_history(db: Session, game: Game) -> None:
|
||||
"""Called after every successful mutation: snapshot the state while a scene is in
|
||||
progress, otherwise clear the (now-irrelevant) rollback history. This is the only
|
||||
decision the middleware makes; kept here so it can be unit-tested without HTTP."""
|
||||
if game.phase != "scene":
|
||||
seal_and_purge(db, game)
|
||||
return
|
||||
|
||||
# A new action taken from a rolled-back state commits the new timeline: discard the
|
||||
# abandoned future first (its events are tagged with checkpoints past the head),
|
||||
# then clear the head and bump the version so the frontend drops those events.
|
||||
# (SQLite may reuse a discarded checkpoint's id for the next capture; that's safe
|
||||
# since every event referencing it was just deleted.)
|
||||
head = game.rollback_head_checkpoint_id
|
||||
if head is not None:
|
||||
_discard_future(db, game, head)
|
||||
game.rollback_head_checkpoint_id = None
|
||||
game.rollback_timeline_version += 1
|
||||
db.add(game)
|
||||
db.commit()
|
||||
|
||||
capture_checkpoint(db, game)
|
||||
|
||||
|
||||
def rollback_to_checkpoint(
|
||||
db: Session, game: Game, player: Player, checkpoint_id: int
|
||||
) -> Tuple[bool, str]:
|
||||
"""Restore the game to the given checkpoint and point the head at it.
|
||||
|
||||
Non-destructive: later checkpoints/events are kept (the frontend greys them) so the
|
||||
rollback can be undone by rolling forward again; the same call serves both rollback
|
||||
and redo. Rolling to the latest checkpoint clears the head (back to live play).
|
||||
|
||||
Server-enforced: only during a scene, only by an Admin or a Deep player, and only
|
||||
to a checkpoint belonging to this game (which guarantees the current scene, since
|
||||
older scenes' checkpoints have been purged)."""
|
||||
if game.phase != "scene":
|
||||
return False, "Rollback is only available during a scene."
|
||||
if not (player.is_admin or player.role == "deep"):
|
||||
return False, "Only an Admin or a Deep player can roll back the game."
|
||||
|
||||
cp = db.get(Checkpoint, checkpoint_id)
|
||||
if not cp or cp.game_id != game.id:
|
||||
return False, "That rollback point is no longer available."
|
||||
|
||||
apply_game_state(db, game, cp.state_json)
|
||||
|
||||
latest = db.exec(
|
||||
select(Checkpoint).where(Checkpoint.game_id == game.id)
|
||||
.order_by(Checkpoint.id.desc())
|
||||
).first()
|
||||
game.rollback_head_checkpoint_id = None if checkpoint_id == latest.id else checkpoint_id
|
||||
db.add(game)
|
||||
db.commit()
|
||||
return True, "Rolled back."
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
from typing import Tuple, Dict, Any
|
||||
from sqlmodel import Session
|
||||
from .models import Game, Player, Obstacle
|
||||
@@ -7,12 +7,21 @@ from . import cards
|
||||
from .crud_base import (
|
||||
get_player, get_game, get_player_hand, set_player_hand,
|
||||
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
||||
change_player_rank, set_captain, evaluate_card_play
|
||||
change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS,
|
||||
is_eligible_nominee
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- Scene Setup Operations ---
|
||||
|
||||
VALID_ROLES = ("pirat", "deep")
|
||||
|
||||
def update_scene_role(db: Session, player_id: str, role: str):
|
||||
# Constrain to the two real roles so the endpoint can't stash an arbitrary
|
||||
# string in the column (defensive: it only ever drives gameplay branches).
|
||||
if role not in VALID_ROLES:
|
||||
return
|
||||
player = get_player(db, player_id)
|
||||
if not player:
|
||||
return
|
||||
@@ -33,13 +42,20 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
||||
if not game:
|
||||
return False, "Game not found."
|
||||
|
||||
if not has_min_players(game):
|
||||
return False, f"You need at least {MIN_PLAYERS} players to start a scene."
|
||||
|
||||
players = game.players
|
||||
|
||||
# Default any unselected roles to 'pirat'
|
||||
# Default any unselected roles to 'pirat'. Pending recruits have no Pi-Rat
|
||||
# yet, so they stay roleless and spectate until the next between-scenes.
|
||||
for p in players:
|
||||
if p.role is None:
|
||||
if p.role is None and not p.needs_reroll:
|
||||
p.role = "pirat"
|
||||
db.add(p)
|
||||
elif p.needs_reroll:
|
||||
p.role = None
|
||||
db.add(p)
|
||||
db.commit()
|
||||
|
||||
if game.current_scene_number == 1:
|
||||
@@ -63,7 +79,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
||||
# 2. Check single eligible Deep player
|
||||
# If only one player is eligible to play the Deep, they must play the Deep.
|
||||
if game.current_scene_number > 1:
|
||||
eligible_deeps = [p for p in players if p.previous_role != "deep"]
|
||||
eligible_deeps = [p for p in players if p.previous_role != "deep" and not p.needs_reroll]
|
||||
if len(eligible_deeps) == 1:
|
||||
must_be_deep_player = eligible_deeps[0]
|
||||
if must_be_deep_player.role != "deep":
|
||||
@@ -134,6 +150,8 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
||||
# players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw.
|
||||
if game.current_scene_number == 1:
|
||||
for p in players:
|
||||
if p.needs_reroll:
|
||||
continue
|
||||
max_size = calculate_max_hand_size(p, players, game.captain_player_id)
|
||||
current_hand = get_player_hand(p)
|
||||
while len(current_hand) < max_size and deck:
|
||||
@@ -160,6 +178,10 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
|
||||
players = game.players
|
||||
allowed = ["pirat", "deep"]
|
||||
|
||||
if player.needs_reroll:
|
||||
# Pending recruits spectate until their Pi-Rat is created between scenes
|
||||
return []
|
||||
|
||||
if game.current_scene_number == 1:
|
||||
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either.
|
||||
if len(players) >= 2:
|
||||
@@ -174,7 +196,7 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
|
||||
allowed.remove("deep")
|
||||
|
||||
# Rule 2: If only 1 eligible Deep, they MUST play Deep
|
||||
eligible_deeps = [p for p in players if p.previous_role != "deep"]
|
||||
eligible_deeps = [p for p in players if p.previous_role != "deep" and not p.needs_reroll]
|
||||
if len(eligible_deeps) == 1 and eligible_deeps[0].id == player_id:
|
||||
if "pirat" in allowed:
|
||||
allowed.remove("pirat")
|
||||
@@ -292,33 +314,6 @@ def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) ->
|
||||
|
||||
def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool):
|
||||
"""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)
|
||||
if not player:
|
||||
return
|
||||
@@ -326,12 +321,34 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
||||
if not game:
|
||||
return
|
||||
|
||||
if obj_type.startswith("crew_"):
|
||||
if obj_type == "crew_1":
|
||||
# Stealing a ship no longer auto-anoints a Captain — the crew has to
|
||||
# earn and choose one themselves (Crew Objective 2 + the Captaincy UI).
|
||||
game.completed_crew_1 = status
|
||||
elif obj_type == "crew_2":
|
||||
game.completed_crew_2 = status
|
||||
elif obj_type == "crew_3":
|
||||
game.completed_crew_3 = status
|
||||
db.add(game)
|
||||
db.commit()
|
||||
|
||||
obj_name = obj_type.replace('_', ' ').title()
|
||||
status_text = "completed" if status else "unmarked"
|
||||
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
|
||||
return
|
||||
|
||||
|
||||
|
||||
rank_diff = 0
|
||||
if obj_type == "personal_1":
|
||||
if status and not player.completed_personal_1:
|
||||
rank_diff = 1
|
||||
player.needs_gat_description = True
|
||||
elif not status and player.completed_personal_1:
|
||||
rank_diff = -1
|
||||
player.needs_gat_description = False
|
||||
player.gat_description = ""
|
||||
player.completed_personal_1 = status
|
||||
|
||||
elif obj_type == "personal_2":
|
||||
@@ -366,53 +383,24 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
||||
status_text = "completed" if status else "unmarked"
|
||||
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]:
|
||||
obstacle = db.get(Obstacle, obstacle_id)
|
||||
if not obstacle:
|
||||
return False, "Obstacle not found."
|
||||
|
||||
played_list = json.loads(obstacle.played_cards)
|
||||
if not played_list:
|
||||
return False, "No cards to roll back."
|
||||
|
||||
last_play = played_list.pop()
|
||||
card_code = last_play["card"]
|
||||
player_id = last_play["player_id"]
|
||||
|
||||
# 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"]
|
||||
def grant_story_bonus_rank(db: Session, game: Game, granter: Player, target):
|
||||
"""A Pi-Rat who finished their story (3rd Personal Objective) grants another
|
||||
eligible Pi-Rat +1 Rank. `target=None` forfeits the bonus (e.g. no eligible
|
||||
crewmate); either way the needs_rank_3_bonus prompt clears. Naming an
|
||||
ineligible target is refused so the prompt stays up rather than waste the Rank."""
|
||||
if not granter.needs_rank_3_bonus:
|
||||
return False, "There is no story Rank bonus to grant."
|
||||
if target is not None and not is_eligible_nominee(granter, target):
|
||||
return False, "Pick a living crewmate who isn't this scene's Deep."
|
||||
if target is not None:
|
||||
change_player_rank(db, game, target, 1)
|
||||
add_game_event(db, game.id, f"{granter.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.", kind="rank")
|
||||
else:
|
||||
obstacle.current_value = cards.get_obstacle_info(obstacle.original_card)["initial_value"]
|
||||
|
||||
obstacle.played_cards = json.dumps(played_list)
|
||||
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)
|
||||
|
||||
add_game_event(db, game.id, f"{granter.name} completed their story, but had no crewmate to pass a Rank to.", kind="rank")
|
||||
granter.needs_rank_3_bonus = False
|
||||
db.add(granter)
|
||||
db.commit()
|
||||
return True, "Rolled back the last played card."
|
||||
return True, "Rank granted."
|
||||
|
||||
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
||||
obstacle = db.get(Obstacle, obstacle_id)
|
||||
@@ -430,3 +418,4 @@ def finish_game(db: Session, game_id: str):
|
||||
db.add(game)
|
||||
db.commit()
|
||||
add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉", kind="victory")
|
||||
logger.info("Game %s ended after %s scene(s)", game_id, game.current_scene_number)
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import logging
|
||||
import random
|
||||
from typing import List, Tuple
|
||||
from sqlmodel import Session, select
|
||||
from .models import Game, Vote
|
||||
from .models import Game, Player, Vote
|
||||
from .crud_base import (
|
||||
get_player, get_game, get_player_hand, set_player_hand,
|
||||
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
|
||||
calculate_max_hand_size, change_player_rank
|
||||
calculate_max_hand_size, change_player_rank, set_captain, is_eligible_nominee
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- Between Scenes Operations ---
|
||||
|
||||
def eligible_vote_nominees(game: Game, voter) -> list:
|
||||
"""The crewmates `voter` may nominate to rank up. An empty list means the
|
||||
voter has no valid pick and skips voting entirely (no deadlock)."""
|
||||
return [p for p in game.players if is_eligible_nominee(voter, p)]
|
||||
|
||||
def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Every Player (including last scene's Deep Players) nominates one *other* Pi-Rat
|
||||
Player to Rank up. Deep Players from the previous scene cannot be nominated.
|
||||
Player to Rank up. Deep Players from the previous scene cannot be nominated,
|
||||
nor can dead or awaiting-recruit Pi-Rats. A voter whose only options are
|
||||
ineligible skips voting (the frontend lets them ready up without a vote).
|
||||
"""
|
||||
voter = get_player(db, voter_id)
|
||||
nominee = get_player(db, nominated_id)
|
||||
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
|
||||
return False, "Player not found."
|
||||
if voter_id == nominated_id:
|
||||
return False, "You cannot nominate yourself. Nice try."
|
||||
if nominee.role == "deep":
|
||||
return False, "Deep Players from the previous scene cannot be nominated."
|
||||
if not is_eligible_nominee(voter, nominee):
|
||||
return False, "You can only nominate a living crewmate who isn't this scene's Deep — and not yourself."
|
||||
|
||||
# Remove any existing vote by this voter for this game
|
||||
existing = db.exec(
|
||||
@@ -41,21 +49,43 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
|
||||
db.commit()
|
||||
return True, "Vote submitted."
|
||||
|
||||
def unchallenged_pirats(game: Game):
|
||||
"""Living Pi-Rats who have not yet been the target of a Deep Challenge this scene."""
|
||||
challenged_ids = {c.target_player_id for c in game.challenges if c.challenge_type == "deep"}
|
||||
return [p for p in game.players
|
||||
if p.role == "pirat" and not p.is_dead and p.id not in challenged_ids]
|
||||
|
||||
def end_scene_and_transition(db: Session, game_id: str):
|
||||
"""Moves game phase to between_scenes."""
|
||||
"""Moves game phase to between_scenes.
|
||||
|
||||
Unless Dev Mode is on, the scene can't end until every living Pi-Rat has
|
||||
faced at least one Deep Challenge, or the Obstacle List has been cleared.
|
||||
(Whether each Pi-Rat had a chance at their Objective is left to table talk.)
|
||||
"""
|
||||
game = get_game(db, game_id)
|
||||
if not game:
|
||||
return
|
||||
return False, "Game not found."
|
||||
|
||||
if not game.dev_mode and game.obstacles:
|
||||
waiting = unchallenged_pirats(game)
|
||||
if waiting:
|
||||
names = ", ".join(p.name for p in waiting)
|
||||
return False, (
|
||||
f"Every Pi-Rat must face a Challenge before the scene can end "
|
||||
f"(still waiting on: {names}). Clear the Obstacle List to end early."
|
||||
)
|
||||
|
||||
game.phase = "between_scenes"
|
||||
|
||||
|
||||
# Clear ready flags for players
|
||||
for p in game.players:
|
||||
p.is_ready = False
|
||||
db.add(p)
|
||||
db.add(game)
|
||||
db.commit()
|
||||
|
||||
|
||||
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
|
||||
return True, "Scene ended."
|
||||
|
||||
def process_between_scenes_votes(db: Session, game: Game):
|
||||
"""
|
||||
@@ -63,24 +93,30 @@ def process_between_scenes_votes(db: Session, game: Game):
|
||||
Then sets players who were Deep to discard and redraw.
|
||||
"""
|
||||
votes = game.votes
|
||||
# Reset the previous scene's winner before tallying this one.
|
||||
game.last_rank_up_player_id = None
|
||||
db.add(game)
|
||||
if not votes:
|
||||
db.commit()
|
||||
return
|
||||
|
||||
|
||||
# Count votes
|
||||
counts = {}
|
||||
for v in votes:
|
||||
counts[v.nominated_player_id] = counts.get(v.nominated_player_id, 0) + 1
|
||||
|
||||
|
||||
if counts:
|
||||
max_votes = max(counts.values())
|
||||
winners = [pid for pid, val in counts.items() if val == max_votes]
|
||||
|
||||
|
||||
# If there is a clear winner (or random in case of tie)
|
||||
if winners:
|
||||
winner_id = random.choice(winners)
|
||||
winner = get_player(db, winner_id)
|
||||
if winner:
|
||||
change_player_rank(db, game, winner, 1)
|
||||
game.last_rank_up_player_id = winner.id
|
||||
db.add(game)
|
||||
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank")
|
||||
|
||||
# Clear all votes
|
||||
@@ -88,38 +124,59 @@ def process_between_scenes_votes(db: Session, game: Game):
|
||||
db.delete(v)
|
||||
db.commit()
|
||||
|
||||
def begin_next_scene_setup(db: Session, game: Game):
|
||||
"""Advances to the next scene's setup: bump the scene counter and clear roles."""
|
||||
game.current_scene_number += 1
|
||||
game.phase = "scene_setup"
|
||||
game.last_rank_up_player_id = None # Stale once we leave the post-voting screen
|
||||
for p in game.players:
|
||||
p.role = None
|
||||
p.is_ready = False
|
||||
db.add(p)
|
||||
db.add(game)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
||||
|
||||
def advance_after_upkeep(db: Session, game: Game):
|
||||
"""After votes (and any resting-Deep hand refresh), detour through recruit
|
||||
creation if anyone is waiting on a new Pi-Rat; otherwise straight to setup."""
|
||||
if any(p.needs_reroll for p in game.players):
|
||||
from .crud_character import start_recruit_creation
|
||||
start_recruit_creation(db, game)
|
||||
else:
|
||||
begin_next_scene_setup(db, game)
|
||||
|
||||
def maybe_transition_to_deep_upkeep(db: Session, game: Game):
|
||||
"""In between_scenes, move on once every player has readied up. Safe to call
|
||||
after a roster change to unblock the phase."""
|
||||
if game.players and all(p.is_ready for p in game.players):
|
||||
transition_to_deep_upkeep(db, game.id)
|
||||
|
||||
def transition_to_deep_upkeep(db: Session, game_id: str):
|
||||
game = get_game(db, game_id)
|
||||
if not game:
|
||||
return
|
||||
|
||||
|
||||
# Process votes & rank up winner
|
||||
process_between_scenes_votes(db, game)
|
||||
|
||||
|
||||
# Reset ready flags
|
||||
for p in game.players:
|
||||
p.is_ready = False
|
||||
db.add(p)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Check if we have resting Deep players (previous_role was 'deep')
|
||||
has_resting_deep = any(p.previous_role == "deep" for p in game.players)
|
||||
|
||||
|
||||
if has_resting_deep:
|
||||
game.phase = "deep_upkeep"
|
||||
db.add(game)
|
||||
db.commit()
|
||||
add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
|
||||
else:
|
||||
# Fallback in case there were no Deep players
|
||||
# Just advance directly to scene_setup
|
||||
game.current_scene_number += 1
|
||||
game.phase = "scene_setup"
|
||||
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
||||
for p in game.players:
|
||||
p.role = None
|
||||
p.is_ready = False
|
||||
db.add(p)
|
||||
|
||||
db.add(game)
|
||||
db.commit()
|
||||
# No resting Deep players: skip straight past the deep_upkeep phase
|
||||
advance_after_upkeep(db, game)
|
||||
|
||||
def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
|
||||
player = get_player(db, player_id)
|
||||
@@ -155,18 +212,95 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
|
||||
player.is_ready = True
|
||||
db.add(player)
|
||||
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"]
|
||||
if all(p.is_ready for p in resting_deep):
|
||||
# All resting deep players have refreshed their hands!
|
||||
# Transition to scene_setup!
|
||||
game.current_scene_number += 1
|
||||
game.phase = "scene_setup"
|
||||
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
|
||||
for p in game.players:
|
||||
p.is_ready = False
|
||||
p.role = None
|
||||
db.add(p)
|
||||
db.add(game)
|
||||
db.commit()
|
||||
advance_after_upkeep(db, game)
|
||||
|
||||
# --- Kicking a player ---
|
||||
|
||||
def recheck_phase_completion(db: Session, game: Game):
|
||||
"""Re-evaluate the current phase's "everyone is done" gate and advance if it
|
||||
is now satisfied. Called after the roster changes (a kick) so a vanished
|
||||
player can't leave the table stuck on a step they'll never complete."""
|
||||
from . import crud_character as cc
|
||||
phase = game.phase
|
||||
if phase == "character_creation":
|
||||
cc.maybe_trigger_technique_swap(db, game)
|
||||
elif phase == "swap_techniques":
|
||||
cc.maybe_finish_technique_swap(db, game)
|
||||
elif phase == "assign_techniques":
|
||||
cc.maybe_finish_assign_techniques(db, game)
|
||||
elif phase == "between_scenes":
|
||||
maybe_transition_to_deep_upkeep(db, game)
|
||||
elif phase == "deep_upkeep":
|
||||
maybe_advance_after_deep_upkeep(db, game)
|
||||
elif phase == "recruit_creation":
|
||||
cc.maybe_finish_recruit_creation(db, game)
|
||||
|
||||
def _remove_player(db: Session, game: Game, target: Player, event_message: str,
|
||||
captain_reason: str, event_kind: str = "warning"):
|
||||
"""Shared mechanics for taking a player out of a game (a kick or a voluntary
|
||||
leave): drop their captaincy, votes and Challenges, repair everyone else's
|
||||
references to them, then delete the row. Their hand and any PvP temp card
|
||||
simply stop being "in play", so reshuffle_discard_pile folds them back into
|
||||
the deck as discards — no hand clearing needed. Finally re-check the current
|
||||
phase's completion gate so a departed blocker can't stall the table."""
|
||||
from . import crud_character as cc
|
||||
target_id = target.id
|
||||
logger.info("Removing player %s (%r) from game %s (%s)", target_id, target.name, game.id, event_kind)
|
||||
|
||||
if game.captain_player_id == target_id:
|
||||
set_captain(db, game, None, reason=captain_reason)
|
||||
|
||||
for v in list(game.votes):
|
||||
if target_id in (v.voter_player_id, v.nominated_player_id):
|
||||
db.delete(v)
|
||||
for ch in list(game.challenges):
|
||||
if target_id in (ch.target_player_id, ch.acting_player_id, ch.challenger_player_id, ch.tax_target_id):
|
||||
db.delete(ch)
|
||||
db.commit()
|
||||
|
||||
cc.repair_references_to_removed_player(db, game, target_id)
|
||||
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
db.refresh(game)
|
||||
|
||||
add_game_event(db, game.id, event_message, kind=event_kind)
|
||||
recheck_phase_completion(db, game)
|
||||
|
||||
def kick_player(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
|
||||
"""An Admin removes a player mid-game (e.g. one who vanished) so the table
|
||||
isn't left waiting on them."""
|
||||
if target.game_id != game.id:
|
||||
return False, "That player isn't in this crew."
|
||||
admin_count = sum(1 for p in game.players if p.is_admin)
|
||||
if target.is_admin and admin_count <= 1:
|
||||
return False, "You can't kick the last Admin. Grant someone else Admin first."
|
||||
name = target.name
|
||||
_remove_player(db, game, target,
|
||||
f"{name} was kicked from the crew. Their cards return to the discard.",
|
||||
f"{name} was kicked")
|
||||
return True, f"{name} was kicked from the crew."
|
||||
|
||||
def leave_game(db: Session, game: Game, target: Player) -> Tuple[bool, str]:
|
||||
"""A player removes themselves from the game. Same mechanics as a kick; the
|
||||
last Admin must hand off Admin first so the table isn't left without one."""
|
||||
if target.game_id != game.id:
|
||||
return False, "You're not in this crew."
|
||||
admin_count = sum(1 for p in game.players if p.is_admin)
|
||||
if target.is_admin and admin_count <= 1:
|
||||
return False, "You're the last Admin — grant someone else Admin before you leave."
|
||||
name = target.name
|
||||
_remove_player(db, game, target,
|
||||
f"{name} left the crew. Their cards return to the discard.",
|
||||
f"{name} left", event_kind="join")
|
||||
return True, f"{name} left the crew."
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from sqlmodel import SQLModel, create_engine, Session
|
||||
import os
|
||||
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
|
||||
sqlite_url = os.environ.get("DATABASE_URL")
|
||||
if not sqlite_url:
|
||||
@@ -11,17 +15,29 @@ if not sqlite_url:
|
||||
# Use connect_args={"check_same_thread": False} for SQLite in FastAPI
|
||||
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
|
||||
|
||||
def create_db_and_tables():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
# Ensure crew_name column exists (migration for existing DBs)
|
||||
from sqlalchemy import text
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
# The revision that matches the schema produced by the pre-Alembic
|
||||
# create_all + ad-hoc ALTER TABLE era.
|
||||
BASELINE_REVISION = "0001"
|
||||
|
||||
|
||||
def _alembic_config() -> Config:
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
|
||||
cfg.attributes["engine"] = engine
|
||||
return cfg
|
||||
|
||||
|
||||
def _upgrade_legacy_db() -> None:
|
||||
"""Bring a pre-Alembic database up to the baseline (0001) schema.
|
||||
|
||||
This is the old ad-hoc migration list, kept only so existing databases
|
||||
can be stamped at the baseline. New columns go in Alembic revisions,
|
||||
not here.
|
||||
"""
|
||||
with Session(engine) as session:
|
||||
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 = [
|
||||
("game", "crew_name", "VARCHAR"),
|
||||
("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"),
|
||||
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
|
||||
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
|
||||
@@ -31,17 +47,37 @@ def create_db_and_tables():
|
||||
("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
|
||||
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
|
||||
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
|
||||
("player", "player_name", "VARCHAR DEFAULT ''"),
|
||||
("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
|
||||
]
|
||||
|
||||
for table, col, def_type in columns:
|
||||
try:
|
||||
session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pass # Column already exists
|
||||
|
||||
# Backfill: pre-existing players used a single name for both player and Pi-Rat
|
||||
session.execute(
|
||||
text("UPDATE player SET player_name = name WHERE player_name = '' OR player_name IS NULL")
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
"""Migrate the database to the latest schema.
|
||||
|
||||
Fresh databases are built entirely by Alembic. Databases that predate
|
||||
Alembic (tables exist but no alembic_version) are first normalized to
|
||||
the baseline schema, stamped, and then upgraded normally.
|
||||
"""
|
||||
cfg = _alembic_config()
|
||||
inspector = inspect(engine)
|
||||
if inspector.has_table("game") and not inspector.has_table("alembic_version"):
|
||||
_upgrade_legacy_db()
|
||||
command.stamp(cfg, BASELINE_REVISION)
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
@@ -1,27 +1,236 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter
|
||||
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlmodel import Session
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import re
|
||||
import sys
|
||||
import uvicorn
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from . import crud
|
||||
from .database import create_db_and_tables, get_session
|
||||
from . import maintenance
|
||||
from .database import run_migrations, get_session, engine
|
||||
from .validation import sanitize_name
|
||||
from .ws import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EVENT_PAGE_SIZE = 50
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
|
||||
_logging_configured = False
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Set up backend logging: always to stderr, plus a rotating file when
|
||||
PIRATS_LOG_FILE is set (the NixOS service points it at /var/log/pirats/).
|
||||
Level comes from PIRATS_LOG_LEVEL (default INFO). Idempotent so it's safe to
|
||||
call from both the CLI entrypoint and the app's startup lifespan."""
|
||||
global _logging_configured
|
||||
if _logging_configured:
|
||||
return
|
||||
_logging_configured = True
|
||||
|
||||
level_name = os.environ.get("PIRATS_LOG_LEVEL", "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
stderr_handler = logging.StreamHandler(sys.stderr)
|
||||
stderr_handler.setFormatter(fmt)
|
||||
root.addHandler(stderr_handler)
|
||||
|
||||
log_file = os.environ.get("PIRATS_LOG_FILE")
|
||||
if log_file:
|
||||
try:
|
||||
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(fmt)
|
||||
root.addHandler(file_handler)
|
||||
logger.info("Logging to %s", log_file)
|
||||
except OSError:
|
||||
# A bad/unwritable path mustn't take the server down — keep stderr.
|
||||
logger.exception("Could not open log file %s; logging to stderr only", log_file)
|
||||
|
||||
DEFAULT_MAX_BODY_BYTES = 1024 * 1024 # 1 MiB — far above any legitimate form post
|
||||
|
||||
def max_body_bytes() -> int:
|
||||
"""Largest request body to accept, from PIRATS_MAX_BODY_BYTES (default 1 MiB)."""
|
||||
val = os.environ.get("PIRATS_MAX_BODY_BYTES")
|
||||
if val is None:
|
||||
return DEFAULT_MAX_BODY_BYTES
|
||||
try:
|
||||
return max(int(val), 0)
|
||||
except ValueError:
|
||||
logger.warning("Invalid PIRATS_MAX_BODY_BYTES=%r; using default", val)
|
||||
return DEFAULT_MAX_BODY_BYTES
|
||||
|
||||
|
||||
class _BodyTooLarge(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LimitRequestBodyMiddleware:
|
||||
"""Reject request bodies larger than `max_bytes` with HTTP 413 before they are
|
||||
buffered into memory. The app is on the open internet, so this caps the damage
|
||||
a single oversized POST can do. The declared Content-Length is checked up front
|
||||
(the common case); the bytes actually streamed are then counted too, so a
|
||||
chunked or length-omitting client can't slip past the header check."""
|
||||
|
||||
def __init__(self, app, max_bytes: int):
|
||||
self.app = app
|
||||
self.max_bytes = max_bytes
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
for name, value in scope.get("headers", []):
|
||||
if name == b"content-length":
|
||||
try:
|
||||
declared = int(value)
|
||||
except ValueError:
|
||||
break
|
||||
if declared > self.max_bytes:
|
||||
await self._send_413(send)
|
||||
return
|
||||
break
|
||||
|
||||
received = 0
|
||||
response_started = False
|
||||
|
||||
async def capped_receive():
|
||||
nonlocal received
|
||||
message = await receive()
|
||||
if message["type"] == "http.request":
|
||||
received += len(message.get("body", b""))
|
||||
if received > self.max_bytes:
|
||||
raise _BodyTooLarge()
|
||||
return message
|
||||
|
||||
async def watched_send(message):
|
||||
nonlocal response_started
|
||||
if message["type"] == "http.response.start":
|
||||
response_started = True
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, capped_receive, watched_send)
|
||||
except _BodyTooLarge:
|
||||
# The handler reads the whole body before responding, so nothing has
|
||||
# been sent yet; if a response somehow already started, we can only stop.
|
||||
if not response_started:
|
||||
await self._send_413(send)
|
||||
|
||||
async def _send_413(self, send):
|
||||
body = b'{"error":"Request body too large."}'
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 413,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(body)).encode()),
|
||||
],
|
||||
})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
create_db_and_tables()
|
||||
configure_logging()
|
||||
logger.info(
|
||||
"Starting Rats with Gats backend (db=%s, dev_mode_default=%s)",
|
||||
engine.url.render_as_string(hide_password=True),
|
||||
crud.dev_mode_default(),
|
||||
)
|
||||
run_migrations()
|
||||
logger.info("Database migrations applied")
|
||||
|
||||
purge_cfg = maintenance.purge_config()
|
||||
purge_task = None
|
||||
if purge_cfg.enabled:
|
||||
purge_task = asyncio.create_task(maintenance.purge_loop(purge_cfg))
|
||||
else:
|
||||
logger.info("Game purge task disabled (PIRATS_PURGE_ENABLED)")
|
||||
|
||||
yield
|
||||
|
||||
if purge_task is not None:
|
||||
purge_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await purge_task
|
||||
logger.info("Shutting down")
|
||||
|
||||
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)
|
||||
api = APIRouter()
|
||||
|
||||
# Every state mutation is a POST under /api/game/{game_id}/..., so one
|
||||
# middleware can notify a game's websocket clients after any successful write.
|
||||
GAME_PATH_RE = re.compile(r"^/api/game/([^/]+)/")
|
||||
# The full-state rollback endpoint manages its own history, so it's the one mutation
|
||||
# we don't checkpoint. Matched precisely (not a bare "/rollback" suffix) so it stays
|
||||
# unambiguous if other "*/rollback" endpoints are ever added.
|
||||
STATE_ROLLBACK_PATH_RE = re.compile(r"/player/[^/]+/rollback$")
|
||||
|
||||
def _checkpoint_after_mutation(game_id: str):
|
||||
"""After a successful mutation, maintain rollback history: snapshot the state
|
||||
while a scene is in progress, otherwise clear any scene's history. Runs in its own
|
||||
session (the request's has already committed and closed by the time this fires)."""
|
||||
with Session(engine) as db:
|
||||
game = crud.get_game(db, game_id)
|
||||
if game:
|
||||
crud.maintain_rollback_history(db, game)
|
||||
|
||||
@app.middleware("http")
|
||||
async def broadcast_state_changes(request, call_next):
|
||||
response = await call_next(request)
|
||||
if request.method == "POST" and response.status_code < 400:
|
||||
match = GAME_PATH_RE.match(request.url.path)
|
||||
if match:
|
||||
game_id = match.group(1)
|
||||
# The full-state rollback endpoint manages its own state; don't checkpoint it.
|
||||
if not STATE_ROLLBACK_PATH_RE.search(request.url.path):
|
||||
try:
|
||||
await run_in_threadpool(_checkpoint_after_mutation, game_id)
|
||||
except Exception:
|
||||
# A checkpointing failure must not fail the user's action, which
|
||||
# has already committed; the next mutation will checkpoint again.
|
||||
logger.exception("Checkpoint capture failed for game %s", game_id)
|
||||
await manager.broadcast(game_id, {"type": "state_changed"})
|
||||
return response
|
||||
|
||||
# Added last so it wraps outermost: oversized bodies are rejected before any
|
||||
# other middleware or the route handler buffers them.
|
||||
app.add_middleware(LimitRequestBodyMiddleware, max_bytes=max_body_bytes())
|
||||
|
||||
@app.websocket("/api/game/{game_id}/ws")
|
||||
async def game_websocket(websocket: WebSocket, game_id: str):
|
||||
await manager.connect(game_id, websocket)
|
||||
try:
|
||||
while True:
|
||||
# Clients don't send anything meaningful; this just detects disconnects.
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.disconnect(game_id, websocket)
|
||||
|
||||
# --- API Core Route Handlers ---
|
||||
|
||||
@api.post("/game")
|
||||
@@ -29,10 +238,18 @@ def create_game_route(
|
||||
crew_name: str = Form(...),
|
||||
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}
|
||||
|
||||
@api.post("/game/{game_id}/join")
|
||||
def join_game_submit(
|
||||
game_id: str,
|
||||
@@ -45,9 +262,21 @@ def join_game_submit(
|
||||
|
||||
# First player is automatically the creator
|
||||
is_creator = len(game.players) == 0
|
||||
player = crud.add_player(db, game_id, name.strip(), is_creator=is_creator)
|
||||
player = crud.add_player(db, game_id, sanitize_name(name), is_creator=is_creator)
|
||||
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")
|
||||
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
|
||||
game = crud.get_game(db, game_id)
|
||||
@@ -58,14 +287,19 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi
|
||||
events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
|
||||
|
||||
return {
|
||||
"game": game.model_dump(exclude={"admin_key"}),
|
||||
# admin_key stays hidden except from Admins, who need it to open the admin panel
|
||||
"game": game.model_dump(exclude=(set() if player.is_admin else {"admin_key"})),
|
||||
"player": player.model_dump(),
|
||||
"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],
|
||||
"challenges": [c.model_dump() for c in game.challenges],
|
||||
"votes": [v.model_dump() for v in game.votes],
|
||||
"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")
|
||||
@@ -94,6 +328,7 @@ from .routes_scene import router as scene_router
|
||||
from .routes_challenge import router as challenge_router
|
||||
from .routes_upkeep import router as upkeep_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(character_router)
|
||||
@@ -101,16 +336,23 @@ api.include_router(scene_router)
|
||||
api.include_router(challenge_router)
|
||||
api.include_router(upkeep_router)
|
||||
api.include_router(admin_router)
|
||||
api.include_router(rollback_router)
|
||||
|
||||
# Mount API at /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
|
||||
static_dir = BASE_DIR / "static"
|
||||
if os.path.exists(static_dir):
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||
|
||||
def main():
|
||||
configure_logging()
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Run the Rats with Gats web app")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host address to bind to")
|
||||
|
||||
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 uuid
|
||||
import secrets
|
||||
from typing import Optional, List
|
||||
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):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||
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)
|
||||
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)
|
||||
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", ...]
|
||||
rollback_timeline_version: int = Field(default=0) # Bumped whenever a rollback truncates events; lets the frontend reset its accumulated log. NEVER snapshotted (see crud_rollback.SNAPSHOT_EXCLUDE)
|
||||
rollback_head_checkpoint_id: Optional[int] = Field(default=None) # When set, the game is viewing a rolled-back state at this checkpoint; events after it are the greyed, still-undoable future. None = live at the latest. NEVER snapshotted.
|
||||
completed_crew_1: bool = Field(default=False) # Steal a Ship
|
||||
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
||||
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
||||
captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship
|
||||
teaching_deep_player_id: Optional[str] = Field(default=None) # Teaching mode: an admin who seeds themselves as the forced Rank-3 Deep for the first scene (set in the lobby, honored when starting ranks are rolled)
|
||||
last_rank_up_player_id: Optional[str] = Field(default=None) # Winner of the most recent rank-up vote, shown on the post-voting upkeep screen; cleared when the next scene begins
|
||||
|
||||
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
|
||||
obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True)
|
||||
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
|
||||
events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True)
|
||||
challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True)
|
||||
checkpoints: List["Checkpoint"] = Relationship(back_populates="game", cascade_delete=True)
|
||||
|
||||
class Player(SQLModel, table=True):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||
game_id: str = Field(foreign_key="game.id", index=True)
|
||||
name: str = Field(default="")
|
||||
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)
|
||||
rank: int = Field(default=1)
|
||||
hand_cards: str = Field(default="[]") # JSON string of list of card codes
|
||||
role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
||||
previous_role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
||||
is_creator: bool = Field(default=False)
|
||||
is_admin: bool = Field(default=False) # Admin privileges; the creator starts with them and can grant them to others
|
||||
|
||||
# Character sheet questions
|
||||
avatar_look: str = Field(default="")
|
||||
@@ -47,7 +63,10 @@ class Player(SQLModel, table=True):
|
||||
|
||||
# Techniques
|
||||
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
|
||||
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_queen: 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_3: bool = Field(default=False) # Die like a Pirate
|
||||
|
||||
gat_description: str = Field(default="") # Free-text description of the Pi-Rat's Gat, entered via a modal when they earn one
|
||||
# States for Milestones & Death
|
||||
needs_name: bool = Field(default=False)
|
||||
needs_gat_description: bool = Field(default=False) # Prompts the gat-description modal after earning a Gat objective
|
||||
needs_rank_3_bonus: bool = Field(default=False)
|
||||
is_dead: bool = Field(default=False)
|
||||
is_ghost: bool = Field(default=False)
|
||||
tax_banned: bool = Field(default=False) # Failed a refused Gat/Name Tax; cannot initiate one again this scene
|
||||
|
||||
# Recruit creation (death re-roll, or joining after initial character creation)
|
||||
needs_reroll: bool = Field(default=False) # Waiting on the between-scenes recruit_creation phase
|
||||
incoming_techniques: str = Field(default="[]") # JSON list of {"from_id", "text"}: techniques crewmates write for this recruit
|
||||
|
||||
# Relationships
|
||||
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
|
||||
obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges)
|
||||
temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
|
||||
stakes: str = Field(default="")
|
||||
stakes: str = Field(default="") # Deprecated single-field stakes; kept for old rollback-snapshot compatibility. New challenges use stakes_success/stakes_failure.
|
||||
stakes_success: str = Field(default="") # What the Deep promises happens if the Pi-Rat beats the Challenge
|
||||
stakes_failure: str = Field(default="") # What the Deep threatens happens if the Pi-Rat fails
|
||||
plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
|
||||
status: str = Field(default="open") # "open", "succeeded", "failed"
|
||||
|
||||
@@ -122,5 +149,21 @@ class GameEvent(SQLModel, table=True):
|
||||
timestamp: float = Field(default_factory=time.time)
|
||||
message: str = Field(...)
|
||||
kind: str = Field(default="info") # category for frontend icons/styling: "join", "phase", "scene", "captain", "challenge", "card", "tax", "objective", "vote", "obstacle", "joker", "victory", "warning", "rank", "info"
|
||||
|
||||
# Links an event to the Checkpoint captured by its action (see crud_rollback):
|
||||
# None = in-flight (created since the last capture; next capture tags it)
|
||||
# > 0 = a live rollback target within the current scene
|
||||
# 0 = sealed/historical (a past scene or non-scene event; never rollback-able)
|
||||
checkpoint_id: Optional[int] = Field(default=None, index=True)
|
||||
|
||||
game: Optional[Game] = Relationship(back_populates="events")
|
||||
|
||||
class Checkpoint(SQLModel, table=True):
|
||||
"""A full snapshot of a game's gameplay state, captured after each action during
|
||||
a scene so it can be rolled back to. The event log itself is NOT snapshotted (it
|
||||
is an append-only spine); rollback truncates it separately. See crud_rollback."""
|
||||
id: Optional[int] = Field(default=None, primary_key=True) # autoincrement; the rollback target the frontend sends
|
||||
game_id: str = Field(foreign_key="game.id", index=True)
|
||||
created_at: float = Field(default_factory=time.time)
|
||||
state_json: str = Field(...) # serialized gameplay snapshot (Game minus control cols + Players/Obstacles/Challenges/Votes)
|
||||
|
||||
game: Optional[Game] = Relationship(back_populates="checkpoints")
|
||||
|
||||
@@ -1,10 +1,108 @@
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
import json
|
||||
from fastapi import APIRouter, Request, Form, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
from .database import get_session
|
||||
from . import crud
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _get_admin(db: Session, game_id: str, player_id: str):
|
||||
"""Resolves (game, player), requiring the player to be an Admin."""
|
||||
game = crud.get_game(db, game_id)
|
||||
player = crud.get_player(db, player_id)
|
||||
if not game or not player or player.game_id != game.id:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if not player.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only an Admin can do that.")
|
||||
return game, player
|
||||
|
||||
# Admin toggles Dev Mode for the whole table (reveals testing shortcuts)
|
||||
@router.post("/game/{game_id}/player/{player_id}/dev-mode")
|
||||
def set_dev_mode(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
enabled: bool = Form(...),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
game, player = _get_admin(db, game_id, player_id)
|
||||
if game.dev_mode != enabled:
|
||||
game.dev_mode = enabled
|
||||
db.add(game)
|
||||
db.commit()
|
||||
crud.add_game_event(db, game.id, f"🛠 Dev Mode turned {'ON' if enabled else 'OFF'} by {player.name}.", kind="info")
|
||||
return {"status": "ok", "dev_mode": game.dev_mode}
|
||||
|
||||
# Teaching mode: an admin volunteers to be the forced Rank-3 Deep for scene 1.
|
||||
# Only meaningful before starting ranks are rolled (i.e. before scene setup).
|
||||
_PRE_RANK_PHASES = {"lobby", "character_creation", "swap_techniques", "assign_techniques"}
|
||||
|
||||
@router.post("/game/{game_id}/player/{player_id}/teaching-mode")
|
||||
def set_teaching_mode(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
enabled: bool = Form(...),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
game, player = _get_admin(db, game_id, player_id)
|
||||
if game.phase not in _PRE_RANK_PHASES:
|
||||
raise HTTPException(status_code=400, detail="Starting ranks have already been rolled.")
|
||||
if enabled:
|
||||
game.teaching_deep_player_id = player.id
|
||||
elif game.teaching_deep_player_id == player.id:
|
||||
game.teaching_deep_player_id = None
|
||||
else:
|
||||
# Disabling someone else's teaching mode shouldn't silently clear it.
|
||||
return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id}
|
||||
db.add(game)
|
||||
db.commit()
|
||||
crud.add_game_event(
|
||||
db, game.id,
|
||||
f"📚 {player.name} {'will teach as the Deep' if enabled else 'cancelled teaching mode'} for the first scene.",
|
||||
kind="info",
|
||||
)
|
||||
return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id}
|
||||
|
||||
# Dev Mode shortcut: auto-complete character creation for everyone and move on
|
||||
@router.post("/game/{game_id}/player/{player_id}/skip-character-creation")
|
||||
def skip_character_creation_route(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
fills: str = Form("{}"), # JSON: player_id -> suggested values for free-text fields
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
game, player = _get_admin(db, game_id, player_id)
|
||||
if not game.dev_mode:
|
||||
raise HTTPException(status_code=403, detail="Dev Mode must be on to skip character creation.")
|
||||
try:
|
||||
fills_dict = json.loads(fills) if fills else {}
|
||||
if not isinstance(fills_dict, dict):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="fills must be a JSON object.")
|
||||
crud.add_game_event(db, game.id, f"🛠 {player.name} is skipping the rest of character creation (Dev Mode).", kind="info")
|
||||
ok, msg = crud.skip_character_creation(db, game, fills_dict)
|
||||
if not ok:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
return {"status": "ok"}
|
||||
|
||||
# Admin escape hatch during technique distribution: randomly distribute every
|
||||
# technique to an eligible player and auto-assign J/Q/K (no Dev Mode required)
|
||||
@router.post("/game/{game_id}/player/{player_id}/auto-assign-techniques")
|
||||
def auto_assign_techniques_route(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
game, player = _get_admin(db, game_id, player_id)
|
||||
if game.phase not in ("swap_techniques", "assign_techniques"):
|
||||
return JSONResponse({"error": "Technique distribution isn't in progress."}, status_code=400)
|
||||
crud.add_game_event(db, game.id, f"🗝 {player.name} is auto-assigning everyone's techniques.", kind="info")
|
||||
ok, msg = crud.auto_assign_techniques(db, game)
|
||||
if not ok:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
return {"status": "ok"}
|
||||
|
||||
# Creator Admin Panel
|
||||
@router.get("/game/{game_id}/admin")
|
||||
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
|
||||
@@ -21,10 +119,13 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
|
||||
players_data.append({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"player_name": p.player_name,
|
||||
"rank": p.rank,
|
||||
"is_dead": p.is_dead,
|
||||
"is_ghost": p.is_ghost,
|
||||
"role": p.role,
|
||||
"is_creator": p.is_creator,
|
||||
"is_admin": p.is_admin,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -32,9 +133,60 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
|
||||
"game": {
|
||||
"id": game.id,
|
||||
"crew_name": game.crew_name,
|
||||
"join_code": game.join_code,
|
||||
"phase": game.phase,
|
||||
"admin_key": game.admin_key
|
||||
},
|
||||
"players": players_data,
|
||||
"base_url": base_url
|
||||
}
|
||||
|
||||
# Grant or revoke another player's Admin privileges (admin-key authenticated, like the panel itself)
|
||||
@router.post("/game/{game_id}/admin/set-admin")
|
||||
def set_player_admin(
|
||||
game_id: str,
|
||||
key: str = Form(...),
|
||||
target_player_id: str = Form(...),
|
||||
is_admin: bool = Form(...),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
game = crud.get_game(db, game_id)
|
||||
if not game:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
if game.admin_key != key:
|
||||
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
|
||||
target = crud.get_player(db, target_player_id)
|
||||
if not target or target.game_id != game.id:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
if target.is_creator and not is_admin:
|
||||
return JSONResponse({"error": "The game creator's Admin privileges can't be revoked."}, status_code=400)
|
||||
if target.is_admin != is_admin:
|
||||
target.is_admin = is_admin
|
||||
db.add(target)
|
||||
db.commit()
|
||||
verb = "granted" if is_admin else "revoked"
|
||||
crud.add_game_event(db, game.id, f"🗝 Admin privileges {verb} for {target.name}.", kind="info")
|
||||
return {"status": "ok", "is_admin": target.is_admin}
|
||||
|
||||
# Kick a player out of the game (admin-key authenticated). Admins can kick
|
||||
# anyone — including other admins — except the last remaining Admin, so the
|
||||
# table is never locked out. The kicked player's cards return to the discard.
|
||||
@router.post("/game/{game_id}/admin/kick")
|
||||
def kick_player_route(
|
||||
game_id: str,
|
||||
key: str = Form(...),
|
||||
target_player_id: str = Form(...),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
game = crud.get_game(db, game_id)
|
||||
if not game:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
if game.admin_key != key:
|
||||
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
|
||||
target = crud.get_player(db, target_player_id)
|
||||
if not target or target.game_id != game.id:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
ok, msg = crud.kick_player(db, game, target)
|
||||
if not ok:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
from .database import get_session
|
||||
from . import crud
|
||||
from .validation import sanitize_text
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -14,7 +15,8 @@ def create_challenge_route(
|
||||
player_id: str,
|
||||
target_player_id: str = Form(...),
|
||||
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)
|
||||
):
|
||||
try:
|
||||
@@ -22,7 +24,8 @@ def create_challenge_route(
|
||||
assert isinstance(ids, list)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
|
||||
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, 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:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlmodel import Session
|
||||
from .database import get_session
|
||||
from . import crud
|
||||
from .cards import TECHNIQUE_SUGGESTIONS
|
||||
from .validation import sanitize_text, sanitize_name
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -27,13 +28,16 @@ def player_save_basic_details(
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
player.avatar_look = avatar_look.strip()
|
||||
player.avatar_smell = avatar_smell.strip()
|
||||
player.first_words = first_words.strip()
|
||||
player.good_at_math = good_at_math.strip()
|
||||
player.avatar_look = sanitize_text(avatar_look)
|
||||
player.avatar_smell = sanitize_name(avatar_smell) # seeds the "Recruit {smell}" name
|
||||
player.first_words = sanitize_text(first_words)
|
||||
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.commit()
|
||||
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates
|
||||
@@ -85,28 +89,6 @@ def player_delegate_question(
|
||||
crud.delegate_question(db, player_id, question_type, target_player_id)
|
||||
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
|
||||
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
||||
def player_submit_delegated_answer(
|
||||
@@ -117,7 +99,7 @@ def player_submit_delegated_answer(
|
||||
answer: str = Form(...),
|
||||
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"}
|
||||
|
||||
# Submit 3 Secret Pirate Techniques
|
||||
@@ -130,11 +112,89 @@ def player_submit_techniques(
|
||||
tech3: str = Form(...),
|
||||
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:
|
||||
return JSONResponse({"error": error}, status_code=400)
|
||||
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)
|
||||
@router.post("/game/{game_id}/player/{player_id}/assign-face-techniques")
|
||||
def player_assign_face_techniques(
|
||||
@@ -147,28 +207,52 @@ def player_assign_face_techniques(
|
||||
):
|
||||
if len({jack, queen, king}) < 3:
|
||||
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"}
|
||||
|
||||
# Roll a new character after death
|
||||
@router.post("/game/{game_id}/player/{player_id}/roll-new-character")
|
||||
def roll_new_character_route(
|
||||
# Dead Pi-Rat opts to come back as a fresh recruit (created between scenes)
|
||||
@router.post("/game/{game_id}/player/{player_id}/choose-reroll")
|
||||
def choose_reroll_route(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
avatar_look: str = Form(...),
|
||||
avatar_smell: str = Form(...),
|
||||
first_words: str = Form(...),
|
||||
good_at_math: str = Form(...),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
crud.roll_new_character(
|
||||
db,
|
||||
game_id,
|
||||
player_id,
|
||||
avatar_look=avatar_look,
|
||||
avatar_smell=avatar_smell,
|
||||
first_words=first_words,
|
||||
good_at_math=good_at_math
|
||||
)
|
||||
ok, msg = crud.choose_reroll(db, player_id)
|
||||
if not ok:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
return {"status": "ok"}
|
||||
|
||||
# Write (or revise) one of your assigned technique slots for a pending recruit
|
||||
@router.post("/game/{game_id}/player/{player_id}/contribute-technique")
|
||||
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"}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
from .database import get_session
|
||||
from . import crud
|
||||
@@ -10,6 +11,8 @@ def lobby_start_character_creation(game_id: str, db: Session = Depends(get_sessi
|
||||
game = crud.get_game(db, game_id)
|
||||
if not game:
|
||||
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"
|
||||
db.add(game)
|
||||
db.commit()
|
||||
|
||||
28
src/pirats/routes_rollback.py
Normal file
28
src/pirats/routes_rollback.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter, Form, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
from .database import get_session
|
||||
from . import crud
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Roll the game back to a checkpoint. Player-id authenticated; the actual permission
|
||||
# check (Admin or Deep player, during a scene) lives in crud.rollback_to_checkpoint.
|
||||
# The "/rollback" path suffix is how the broadcast middleware knows to skip capturing
|
||||
# a checkpoint for this request.
|
||||
@router.post("/game/{game_id}/player/{player_id}/rollback")
|
||||
def rollback_route(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
checkpoint_id: int = Form(...),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
game = crud.get_game(db, game_id)
|
||||
player = crud.get_player(db, player_id)
|
||||
if not game or not player or player.game_id != game.id:
|
||||
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||
ok, msg = crud.rollback_to_checkpoint(db, game, player, checkpoint_id)
|
||||
if not ok:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
return {"status": "ok"}
|
||||
@@ -3,9 +3,22 @@ from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
from .database import get_session
|
||||
from . import crud
|
||||
from .cards import OBSTACLES_DATA
|
||||
from .validation import sanitize_text, sanitize_name
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# The rulebook's obstacle tables: what every card represents when drawn as an
|
||||
# Obstacle (used by the frontend for card tooltips)
|
||||
@router.get("/obstacles")
|
||||
def obstacle_table():
|
||||
return {
|
||||
"obstacles": {
|
||||
suit: {val: {"title": t, "description": d} for val, (t, d) in table.items()}
|
||||
for suit, table in OBSTACLES_DATA.items()
|
||||
}
|
||||
}
|
||||
|
||||
# Set Role during Scene Setup
|
||||
@router.post("/game/{game_id}/player/{player_id}/set-role")
|
||||
def player_set_role(
|
||||
@@ -77,35 +90,31 @@ def toggle_objective_route(
|
||||
):
|
||||
if type.startswith("crew_"):
|
||||
game = crud.get_game(db, game_id)
|
||||
if not game: raise HTTPException(status_code=404)
|
||||
if not game:
|
||||
raise HTTPException(status_code=404)
|
||||
current_status = False
|
||||
if type == "crew_1": current_status = game.completed_crew_1
|
||||
elif type == "crew_2": current_status = game.completed_crew_2
|
||||
elif type == "crew_3": current_status = game.completed_crew_3
|
||||
if type == "crew_1":
|
||||
current_status = game.completed_crew_1
|
||||
elif type == "crew_2":
|
||||
current_status = game.completed_crew_2
|
||||
elif type == "crew_3":
|
||||
current_status = game.completed_crew_3
|
||||
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
||||
else:
|
||||
player = crud.get_player(db, player_id)
|
||||
if not player: raise HTTPException(status_code=404, detail="Player not found")
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
current_status = False
|
||||
if type == "personal_1": current_status = player.completed_personal_1
|
||||
elif type == "personal_2": current_status = player.completed_personal_2
|
||||
elif type == "personal_3": current_status = player.completed_personal_3
|
||||
if type == "personal_1":
|
||||
current_status = player.completed_personal_1
|
||||
elif type == "personal_2":
|
||||
current_status = player.completed_personal_2
|
||||
elif type == "personal_3":
|
||||
current_status = player.completed_personal_3
|
||||
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
# Deep rollbacks a card play
|
||||
@router.post("/game/{game_id}/scene/rollback")
|
||||
def rollback_card_play_route(
|
||||
game_id: str,
|
||||
obstacle_id: str = Form(...),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
ok, msg = crud.rollback_card_play(db, obstacle_id)
|
||||
if not ok:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
return {"status": "ok"}
|
||||
|
||||
# Pi-Rat sets a new name
|
||||
@router.post("/game/{game_id}/player/{player_id}/set-name")
|
||||
def set_name_route(
|
||||
@@ -116,29 +125,45 @@ def set_name_route(
|
||||
):
|
||||
player = crud.get_player(db, player_id)
|
||||
if player and player.needs_name:
|
||||
player.name = new_name
|
||||
player.name = sanitize_name(new_name)
|
||||
player.needs_name = False
|
||||
db.add(player)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
# Pi-Rat bonus rank up for another player
|
||||
# Pi-Rat describes the Gat they just earned
|
||||
@router.post("/game/{game_id}/player/{player_id}/set-gat-description")
|
||||
def set_gat_description_route(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
description: str = Form(...),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
player = crud.get_player(db, player_id)
|
||||
if player and player.needs_gat_description:
|
||||
player.gat_description = sanitize_text(description)
|
||||
player.needs_gat_description = False
|
||||
db.add(player)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
# A Pi-Rat who finished their story (3rd Personal Objective) grants another Pi-Rat a
|
||||
# bonus Rank. An empty target_player_id forfeits the bonus (e.g. no eligible crewmate).
|
||||
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
|
||||
def bonus_rank_up_route(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
target_player_id: str = Form(...),
|
||||
target_player_id: str = Form(""),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
player = crud.get_player(db, player_id)
|
||||
target = crud.get_player(db, target_player_id)
|
||||
game = crud.get_game(db, game_id)
|
||||
if player and target and game and player.needs_rank_3_bonus:
|
||||
player.needs_rank_3_bonus = False
|
||||
db.add(player)
|
||||
db.commit()
|
||||
crud.change_player_rank(db, game, target, 1)
|
||||
crud.add_game_event(db, game_id, f"{player.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.")
|
||||
player = crud.get_player(db, player_id)
|
||||
if not game or not player:
|
||||
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||
target = crud.get_player(db, target_player_id) if target_player_id else None
|
||||
ok, msg = crud.grant_story_bonus_rank(db, game, player, target)
|
||||
if not ok:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
return {"status": "ok"}
|
||||
|
||||
# Pi-Rat becomes a ghost
|
||||
@@ -152,6 +177,7 @@ def become_ghost_route(
|
||||
if player and player.is_dead:
|
||||
player.is_ghost = True
|
||||
player.is_dead = False
|
||||
player.needs_reroll = False # in case they queued a re-roll first, ghosthood wins
|
||||
db.add(player)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -159,7 +185,9 @@ def become_ghost_route(
|
||||
# Deep ends the scene
|
||||
@router.post("/game/{game_id}/scene/end")
|
||||
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
|
||||
crud.end_scene_and_transition(db, game_id)
|
||||
ok, msg = crud.end_scene_and_transition(db, game_id)
|
||||
if not ok:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
return {"status": "ok"}
|
||||
|
||||
# Deep clears a completed obstacle
|
||||
|
||||
@@ -33,9 +33,9 @@ def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_ses
|
||||
|
||||
# Check if all players in the game are ready. If so, transition to deep upkeep!
|
||||
game = crud.get_game(db, game_id)
|
||||
if game and all(p.is_ready for p in game.players):
|
||||
crud.transition_to_deep_upkeep(db, game_id)
|
||||
|
||||
if game:
|
||||
crud.maybe_transition_to_deep_upkeep(db, game)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
# Confirm Hand Refresh for Deep player
|
||||
|
||||
1562
src/pirats/rules.html
Normal file
1562
src/pirats/rules.html
Normal file
File diff suppressed because it is too large
Load Diff
41
src/pirats/validation.py
Normal file
41
src/pirats/validation.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Light input hardening for free-text fields submitted by players.
|
||||
|
||||
The app is deliberately cooperative and light on auth (see the trust model), but
|
||||
it's exposed on the open internet, so player-authored free text gets a basic
|
||||
scrub before it is stored: drop control characters and unpaired surrogates that
|
||||
could corrupt JSON or the display, trim surrounding whitespace, and cap the
|
||||
length. SQL injection is already handled by SQLModel's parameterized queries —
|
||||
this is only about content hygiene and bounding payload size.
|
||||
|
||||
Only fields whose value is authored-and-stored go through here. Identifiers
|
||||
(player/obstacle ids), enums (role, objective type), and values matched against
|
||||
existing stored text (a swap offer, a face-card technique assignment) are left
|
||||
untouched so they still compare equal.
|
||||
"""
|
||||
import unicodedata
|
||||
|
||||
MAX_NAME_LENGTH = 120 # crew / player / Pi-Rat names and the smell that seeds them
|
||||
MAX_TEXT_LENGTH = 2000 # everything else: catchphrases, techniques, stakes, descriptions
|
||||
|
||||
|
||||
def sanitize_text(value: str, max_length: int = MAX_TEXT_LENGTH) -> str:
|
||||
"""`value` with control characters and unpaired surrogates removed,
|
||||
surrounding whitespace trimmed, and length capped at `max_length`.
|
||||
Tabs and newlines are kept; emoji (incl. ZWJ sequences) survive."""
|
||||
if not value:
|
||||
return ""
|
||||
cleaned = "".join(
|
||||
ch for ch in value
|
||||
if ch in ("\t", "\n")
|
||||
or (unicodedata.category(ch) != "Cc" and not 0xD800 <= ord(ch) <= 0xDFFF)
|
||||
)
|
||||
cleaned = cleaned.strip()
|
||||
if len(cleaned) > max_length:
|
||||
cleaned = cleaned[:max_length].rstrip()
|
||||
return cleaned
|
||||
|
||||
|
||||
def sanitize_name(value: str) -> str:
|
||||
"""Sanitize a single-line, name-like field: the tighter name cap, plus
|
||||
collapsing any internal whitespace runs to single spaces."""
|
||||
return " ".join(sanitize_text(value, max_length=MAX_NAME_LENGTH).split())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user