Compare commits
45 Commits
3bb4940df7
...
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 |
21
AGENTS.md
21
AGENTS.md
@@ -11,4 +11,25 @@ After editing `src/pirats/models.py`, generate a migration and sanity-check it:
|
|||||||
.venv/bin/alembic upgrade head # or just start the app
|
.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.
|
Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list exists only to upgrade pre-Alembic databases to the baseline and must not grow.
|
||||||
|
|
||||||
|
## Learning during testing
|
||||||
|
|
||||||
|
When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions.
|
||||||
|
|
||||||
|
- If Alembic autogeneration says the target database is not up to date, create a temporary database with `DATABASE_URL=sqlite:////tmp/<name>.db .venv/bin/alembic upgrade head`, then run the autogeneration command with that same `DATABASE_URL`.
|
||||||
|
|
||||||
|
## Versioning & changelog
|
||||||
|
|
||||||
|
The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
|
||||||
|
|
||||||
|
- Bump `VERSION` by one on **every** commit.
|
||||||
|
- When a commit changes something players can see, add an entry to the **top** of `CHANGELOG` (`{ version, date, changes: [...] }`) describing it in player-facing terms. Group everything shipping under one version into a single entry.
|
||||||
|
- The changelog is for players: skip refactors, tests, tooling, and other internal-only changes. A commit with no user-facing change bumps `VERSION` but adds no entry.
|
||||||
|
|
||||||
|
## Work order
|
||||||
|
|
||||||
|
You will be working on tasks in [TODO.md](./TODO.md]. Work on at most two tasks at a time (one is preferable, but two is fine if they dove tail really nicely), and after you finish each task, make a git commit and update the TODO list to check off the completed task.
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -61,6 +61,20 @@ uvicorn pirats.main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
|
|
||||||
Once running, access the web UI at [http://localhost:8000](http://localhost:8000).
|
Once running, access the web UI at [http://localhost:8000](http://localhost:8000).
|
||||||
|
|
||||||
|
#### Configuration (environment variables)
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `DATABASE_URL` | `sqlite:///./rats_with_gats.db` | SQLAlchemy database URL. |
|
||||||
|
| `PIRATS_LOG_FILE` | _(unset)_ | If set, also write logs to this file (rotating, 10 MB × 5 backups). Logs always go to stderr regardless. |
|
||||||
|
| `PIRATS_LOG_LEVEL` | `INFO` | Minimum log severity (`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`). |
|
||||||
|
| `PIRATS_DEV_MODE` | _(unset = on)_ | Default Dev Mode for new games. Unset is treated as a local checkout (on); set to `0`/`false` to disable. |
|
||||||
|
| `PIRATS_PURGE_ENABLED` | `true` | Periodically purge old finished/inactive games. |
|
||||||
|
| `PIRATS_PURGE_INTERVAL_HOURS` | `24` | How often the purge task runs. |
|
||||||
|
| `PIRATS_PURGE_FINISHED_DAYS` | `14` | Purge games that finished more than this many days ago. |
|
||||||
|
| `PIRATS_PURGE_INACTIVE_DAYS` | `30` | Purge games with no activity for this many days. |
|
||||||
|
| `PIRATS_MAX_BODY_BYTES` | `1048576` | Reject request bodies larger than this (HTTP 413). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Option 2: Using Nix Flakes
|
### Option 2: Using Nix Flakes
|
||||||
@@ -99,10 +113,22 @@ services.pirats = {
|
|||||||
host = "127.0.0.1";
|
host = "127.0.0.1";
|
||||||
port = 8000;
|
port = 8000;
|
||||||
databasePath = "/var/lib/pirats/rats_with_gats.db";
|
databasePath = "/var/lib/pirats/rats_with_gats.db";
|
||||||
|
logFile = "/var/log/pirats/pirats.log"; # rotating; stderr also goes to the journal
|
||||||
|
logLevel = "info";
|
||||||
openFirewall = false; # Set to true to open ports in the firewall
|
openFirewall = false; # Set to true to open ports in the firewall
|
||||||
|
purge = {
|
||||||
|
enable = true; # periodically remove old finished/inactive games
|
||||||
|
intervalHours = 24;
|
||||||
|
finishedDays = 14; # purge games finished more than 14 days ago
|
||||||
|
inactiveDays = 30; # ...or with no activity for 30 days
|
||||||
|
};
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The service writes its log to `logFile` (under the systemd-managed `/var/log/pirats`
|
||||||
|
`LogsDirectory`, so the sandboxed `DynamicUser` can write to it) and additionally
|
||||||
|
to the systemd journal via stderr (`journalctl -u pirats`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
@@ -147,8 +173,16 @@ pirats/
|
|||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
|
|
||||||
Run the backend (`pirats --reload`) and the Vite dev server side by side; Vite proxies `/api` to port 8000:
|
Start the backend and Vite dev server together from the repository root. Pressing Ctrl-C stops both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To run only the frontend, start the backend separately on port 8000, then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Vite proxies `/api` to the backend on port 8000.
|
||||||
|
|||||||
24
TODO.md
24
TODO.md
@@ -1,17 +1,15 @@
|
|||||||
## Major features
|
## Polish
|
||||||
- [x] **Rollback of game state.** Full-state JSON snapshots in a `Checkpoint` table, captured per action by the broadcast middleware, restored by `crud_rollback.rollback_to_checkpoint`; scene-confined; server-enforced for Admins and Deep players. Rolling back is non-destructive — it moves a head pointer (`Game.rollback_head_checkpoint_id`), so later log entries grey out and can be redone (roll forward); the abandoned future is only discarded when a new action is taken. UI is the per-event rollback/redo buttons + greyed entries + "rolled back" banner in `EventLog.svelte`.
|
|
||||||
|
- [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
|
## Words Words Words
|
||||||
|
|
||||||
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
|
|
||||||
- [ ] **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.
|
|
||||||
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
|
- [ ] **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.
|
||||||
## Security
|
|
||||||
|
|
||||||
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates.
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
|
|
||||||
- [ ] **Look for dead code.** While implementing the rollback feature, we discovered /scene/rollback, an unused and buggy single-card-play rollback route that was added but never hooked up to the UI. Look for other code that matches that pattern -- orphan functions and routes that never get called.
|
|
||||||
- [ ] **Clean up ruff E701 warnings**
|
|
||||||
|
|||||||
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
|
||||||
56
flake.nix
56
flake.nix
@@ -112,6 +112,27 @@
|
|||||||
default = "/var/lib/pirats/rats_with_gats.db";
|
default = "/var/lib/pirats/rats_with_gats.db";
|
||||||
description = "Path to the SQLite database file.";
|
description = "Path to the SQLite database file.";
|
||||||
};
|
};
|
||||||
|
logFile = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "/var/log/pirats/pirats.log";
|
||||||
|
description = ''
|
||||||
|
Path to the server log file (rotating, 10 MB x 5 backups). stderr is
|
||||||
|
also captured by the systemd journal. The default lives under the
|
||||||
|
service's LogsDirectory (/var/log/pirats), which the sandboxed
|
||||||
|
DynamicUser can write to; a custom path elsewhere must be made
|
||||||
|
writable by the service separately.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
logLevel = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "debug" "info" "warning" "error" "critical" ];
|
||||||
|
default = "info";
|
||||||
|
description = "Minimum severity of log messages to record.";
|
||||||
|
};
|
||||||
|
maxBodyBytes = lib.mkOption {
|
||||||
|
type = lib.types.ints.positive;
|
||||||
|
default = 1048576;
|
||||||
|
description = "Reject request bodies larger than this many bytes (HTTP 413).";
|
||||||
|
};
|
||||||
openFirewall = lib.mkOption {
|
openFirewall = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
@@ -122,6 +143,28 @@
|
|||||||
default = false;
|
default = false;
|
||||||
description = "Whether new games start with Dev Mode (testing shortcuts) enabled.";
|
description = "Whether new games start with Dev Mode (testing shortcuts) enabled.";
|
||||||
};
|
};
|
||||||
|
purge = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Periodically purge old finished/inactive games.";
|
||||||
|
};
|
||||||
|
intervalHours = lib.mkOption {
|
||||||
|
type = lib.types.numbers.positive;
|
||||||
|
default = 24;
|
||||||
|
description = "How often (in hours) the purge task runs.";
|
||||||
|
};
|
||||||
|
finishedDays = lib.mkOption {
|
||||||
|
type = lib.types.numbers.positive;
|
||||||
|
default = 14;
|
||||||
|
description = "Purge games that finished more than this many days ago.";
|
||||||
|
};
|
||||||
|
inactiveDays = lib.mkOption {
|
||||||
|
type = lib.types.numbers.positive;
|
||||||
|
default = 30;
|
||||||
|
description = "Purge games with no activity for this many days.";
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
@@ -137,15 +180,26 @@
|
|||||||
# Unset means "local checkout" and defaults Dev Mode on, so the
|
# Unset means "local checkout" and defaults Dev Mode on, so the
|
||||||
# service must always set it explicitly.
|
# service must always set it explicitly.
|
||||||
PIRATS_DEV_MODE = lib.boolToString cfg.devModeDefault;
|
PIRATS_DEV_MODE = lib.boolToString cfg.devModeDefault;
|
||||||
|
PIRATS_LOG_FILE = cfg.logFile;
|
||||||
|
PIRATS_LOG_LEVEL = cfg.logLevel;
|
||||||
|
PIRATS_PURGE_ENABLED = lib.boolToString cfg.purge.enable;
|
||||||
|
PIRATS_PURGE_INTERVAL_HOURS = toString cfg.purge.intervalHours;
|
||||||
|
PIRATS_PURGE_FINISHED_DAYS = toString cfg.purge.finishedDays;
|
||||||
|
PIRATS_PURGE_INACTIVE_DAYS = toString cfg.purge.inactiveDays;
|
||||||
|
PIRATS_MAX_BODY_BYTES = toString cfg.maxBodyBytes;
|
||||||
};
|
};
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${cfg.package}/bin/pirats --host ${cfg.host} --port ${toString cfg.port}";
|
ExecStart = "${cfg.package}/bin/pirats --host ${cfg.host} --port ${toString cfg.port}";
|
||||||
Restart = "always";
|
Restart = "always";
|
||||||
|
|
||||||
# Sandboxing and security
|
# Sandboxing and security
|
||||||
DynamicUser = true;
|
DynamicUser = true;
|
||||||
StateDirectory = "pirats";
|
StateDirectory = "pirats";
|
||||||
|
# Creates /var/log/pirats owned by the dynamic user and adds it to
|
||||||
|
# ReadWritePaths, so the default logFile under it is writable
|
||||||
|
# despite ProtectSystem=strict.
|
||||||
|
LogsDirectory = "pirats";
|
||||||
WorkingDirectory = "/var/lib/pirats";
|
WorkingDirectory = "/var/lib/pirats";
|
||||||
|
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
|
|||||||
@@ -23,6 +23,83 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-summary-details {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 10.5rem;
|
||||||
|
padding: 0.85rem 1.1rem 0.95rem;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--well));
|
||||||
|
color: var(--text);
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
font: inherit;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 20%, var(--well));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code.copied {
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.admin-summary {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-join-code {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -51,6 +128,16 @@
|
|||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-copy-action > .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 2.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Game over --- */
|
/* --- Game over --- */
|
||||||
.gameover-container {
|
.gameover-container {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
@@ -75,20 +162,3 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crew-epitaph-row {
|
|
||||||
background: var(--well);
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crew-epitaph-row .objectives-summary {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ input[type="checkbox"] {
|
|||||||
margin: 0 auto;
|
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) {
|
@media (max-width: 600px) {
|
||||||
.dashboard-container {
|
.dashboard-container {
|
||||||
padding: 3.5rem 1rem 4rem;
|
padding: 3.5rem 1rem 4rem;
|
||||||
|
|||||||
@@ -104,25 +104,27 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Crew creation progress rows */
|
|
||||||
.status-row {
|
|
||||||
background: var(--well);
|
|
||||||
border-left: 4px solid var(--edge);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-row.is-ready {
|
|
||||||
border-left-color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Character sheet layout --- */
|
/* --- Character sheet layout --- */
|
||||||
.character-sheet-details {
|
.character-sheet-details {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.character-sheet-details {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.sheet-main-column {
|
||||||
|
flex: 2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sheet-side-column {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.techniques-list-sheet {
|
.techniques-list-sheet {
|
||||||
|
|||||||
@@ -167,6 +167,10 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-label:has(input:disabled) {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.objectives-checklist {
|
.objectives-checklist {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -289,49 +293,6 @@
|
|||||||
color: color-mix(in srgb, var(--danger) 75%, var(--text));
|
color: color-mix(in srgb, var(--danger) 75%, var(--text));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Player chips --- */
|
|
||||||
.player-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip {
|
|
||||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
|
||||||
border: 1px solid var(--edge);
|
|
||||||
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: color-mix(in srgb, var(--accent) 14%, transparent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.voted-chip {
|
|
||||||
border-color: var(--success);
|
|
||||||
background-color: color-mix(in srgb, var(--success) 6%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.not-voted-chip {
|
|
||||||
border-color: var(--edge);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-chips {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-chips .player-chip {
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deep-chip {
|
.deep-chip {
|
||||||
border-color: var(--deep);
|
border-color: var(--deep);
|
||||||
background: color-mix(in srgb, var(--deep) 5%, transparent);
|
background: color-mix(in srgb, var(--deep) 5%, transparent);
|
||||||
@@ -342,24 +303,6 @@
|
|||||||
background: color-mix(in srgb, var(--pirat) 5%, transparent);
|
background: color-mix(in srgb, var(--pirat) 5%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-chip.is-ghost {
|
|
||||||
border-color: var(--ghost);
|
|
||||||
color: var(--ghost);
|
|
||||||
background: color-mix(in srgb, var(--ghost) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.is-dead {
|
|
||||||
border-color: var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-chip.is-captain {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Badges --- */
|
/* --- Badges --- */
|
||||||
.creator-badge {
|
.creator-badge {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -398,25 +341,6 @@
|
|||||||
color: var(--ghost);
|
color: var(--ghost);
|
||||||
}
|
}
|
||||||
|
|
||||||
.captain-badge {
|
|
||||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
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-badge.vacant {
|
|
||||||
background: color-mix(in srgb, var(--text) 4%, transparent);
|
|
||||||
border: 1px dashed var(--edge);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Waiting indicator --- */
|
/* --- Waiting indicator --- */
|
||||||
.waiting-box, .waiting-indicator {
|
.waiting-box, .waiting-indicator {
|
||||||
@@ -428,3 +352,138 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Modal overlay (shared by popups) --- */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
position: relative;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.75rem;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box.sheet-modal {
|
||||||
|
max-width: 520px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box.character-sheet-modal {
|
||||||
|
max-width: 1260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Graphical tooltip (lib/tooltip.js action) --- */
|
||||||
|
.tooltip-pop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3000;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--edge-accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(3px);
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-card {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-card.tt-red { color: var(--suit-red); }
|
||||||
|
.tooltip-pop .tt-card.tt-black { color: var(--suit-black); }
|
||||||
|
.tooltip-pop .tt-card.tt-joker { color: var(--accent); }
|
||||||
|
|
||||||
|
.tooltip-pop .tt-color {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-color-red {
|
||||||
|
color: var(--suit-red);
|
||||||
|
background: color-mix(in srgb, var(--suit-red) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-color-black {
|
||||||
|
color: var(--suit-black);
|
||||||
|
background: color-mix(in srgb, var(--suit-black) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-theme {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-row {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pop .tt-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,15 +4,27 @@
|
|||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lobby-status-box {
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-box {
|
.links-box {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teaching-box {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teaching-box .checkbox-label.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teaching-box .info-text {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.link-item {
|
.link-item {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -22,6 +34,11 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lobby-join-code-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.copy-container {
|
.copy-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -1,8 +1,48 @@
|
|||||||
/* --- Scene board layout --- */
|
/* --- Scene board layout --- */
|
||||||
.scene-view-layout {
|
.scene-view-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.4fr 1fr;
|
grid-template-columns: 180px minmax(0, 1.7fr) minmax(300px, 1fr);
|
||||||
gap: 2rem;
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The middle column stacks hand / challenge / obstacle cards. */
|
||||||
|
.table-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stretch the log column to the full row height so its sticky panel can travel. */
|
||||||
|
.log-column {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When the inline log is collapsed, reclaim its column (3-col layout only). */
|
||||||
|
@media (min-width: 1101px) {
|
||||||
|
.scene-view-layout.log-collapsed {
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner button that toggles the inline Event Log without moving. */
|
||||||
|
.log-reopen-btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.log-reopen-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
@@ -11,6 +51,157 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Crew bubble column --- */
|
||||||
|
.crew-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubbles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.1rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-left: 4px solid var(--pirat);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--surface-raised));
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-static:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-deep {
|
||||||
|
border-left-color: var(--deep);
|
||||||
|
background: color-mix(in srgb, var(--deep) 12%, var(--surface-raised));
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-you {
|
||||||
|
border-color: var(--accent);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-ghost { opacity: 0.7; filter: grayscale(0.6); }
|
||||||
|
.crew-bubble.is-dead { opacity: 0.6; filter: grayscale(0.8); }
|
||||||
|
|
||||||
|
/* Challenge-progress badge the Deep sees floating just outside the bubble */
|
||||||
|
.challenge-check {
|
||||||
|
position: absolute;
|
||||||
|
left: -0.6rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
color: var(--text-muted);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-check.done {
|
||||||
|
background: var(--success);
|
||||||
|
border-color: var(--success);
|
||||||
|
color: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble .bubble-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble .bubble-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble .bubble-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crew Objectives toggle at the end of the roster */
|
||||||
|
.crew-objectives-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-objectives-toggle:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-objectives-toggle .co-count {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-objectives-detail {
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the crew roster vertical at every width, matching the other phases. */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.crew-bubble {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.crew-objectives-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -32,44 +223,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scene status banner (captain + roster) */
|
|
||||||
.scene-status-banner {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--well);
|
|
||||||
border: 1px solid var(--edge);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster-chips {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster-chips .roster-label {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster-chips .player-chip {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Obstacles --- */
|
/* --- Obstacles --- */
|
||||||
.obstacles-container {
|
.obstacles-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -90,7 +243,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-item.in-challenge {
|
.obstacle-item.in-challenge {
|
||||||
box-shadow: 0 0 0 2px var(--danger);
|
box-shadow: 0 0 0 2px var(--deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -109,10 +262,40 @@
|
|||||||
.suit-c, .suit-s { border-left: 4px solid var(--suit-black); }
|
.suit-c, .suit-s { border-left: 4px solid var(--suit-black); }
|
||||||
.suit-h, .suit-d { border-left: 4px solid var(--suit-red); }
|
.suit-h, .suit-d { border-left: 4px solid var(--suit-red); }
|
||||||
|
|
||||||
.obstacle-card-wrapper {
|
/* Overlapping card column for an Obstacle. Cards stack with a heavy negative
|
||||||
|
margin so each earlier card shows only its top strip (rank + suit); the most
|
||||||
|
recent play sits fully visible at the bottom. Rings: accent = the original
|
||||||
|
card, green/red = a play that did / didn't beat the difficulty. */
|
||||||
|
.obstacle-stack {
|
||||||
|
--peek: 34px; /* visible strip of each earlier card */
|
||||||
|
--card-h: 112px; /* medium card height (card.css) */
|
||||||
|
align-self: start;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 2px; /* room for the rings, which overflow:hidden would clip */
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-card + .stack-card {
|
||||||
|
margin-top: calc(var(--peek) - var(--card-h));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-card.is-original {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-card.is-success {
|
||||||
|
box-shadow: 0 0 0 2px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-card.is-failure {
|
||||||
|
box-shadow: 0 0 0 2px var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.suit-badge {
|
.suit-badge {
|
||||||
@@ -135,8 +318,15 @@
|
|||||||
font-size: 1.15rem;
|
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 {
|
.obstacle-details .in-challenge-tag {
|
||||||
color: var(--danger);
|
color: var(--deep);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +336,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-value-display {
|
.obstacle-value-display {
|
||||||
|
align-self: start;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -157,6 +348,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.obstacle-successes-display {
|
.obstacle-successes-display {
|
||||||
|
align-self: start;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -185,43 +377,29 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.played-column {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
border-top: 1px solid var(--edge-soft);
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-obstacle-row {
|
.clear-obstacle-row {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Challenges --- */
|
/* --- 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 {
|
.challenge-item {
|
||||||
background: color-mix(in srgb, var(--danger) 5%, transparent);
|
|
||||||
border: 1px solid var(--danger);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.challenge-item + .challenge-item {
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.challenge-item h4 {
|
.challenge-item h4 {
|
||||||
color: var(--danger);
|
color: var(--deep);
|
||||||
font-size: 1.15rem;
|
font-size: 1.25rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,37 +430,31 @@
|
|||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Deep control panel --- */
|
/* --- Challenge area (middle column) --- */
|
||||||
.deep-panel-card {
|
/* Obstacles applied to an active Challenge, pulled up into the Challenge panel */
|
||||||
max-height: 80vh;
|
.challenge-obstacles {
|
||||||
overflow-y: auto;
|
|
||||||
border-color: color-mix(in srgb, var(--deep) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deep-divider {
|
|
||||||
border: 0;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(to right, transparent, color-mix(in srgb, var(--deep) 40%, transparent), transparent);
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.obstacles-checkboxes {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 1rem;
|
||||||
background: var(--well);
|
margin-top: 0.75rem;
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
max-height: 150px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--edge);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.objective-player-block {
|
/* The Deep's call-a-challenge + end-scene controls live below any open Challenges */
|
||||||
background: var(--well);
|
.deep-challenge-controls {
|
||||||
padding: 0.5rem 0.75rem;
|
margin-top: 0.5rem;
|
||||||
border-radius: var(--radius-sm);
|
}
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
|
/* When a Challenge is active the rest of the Obstacle list collapses out of the way */
|
||||||
|
.obstacle-collapse > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-collapse[open] > summary {
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-upkeep-controls {
|
.scene-upkeep-controls {
|
||||||
@@ -292,6 +464,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- Hand --- */
|
/* --- Hand --- */
|
||||||
|
.hand-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.hand-flex {
|
.hand-flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setup-grid.single {
|
||||||
|
grid-template-columns: minmax(0, 700px);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.setup-grid {
|
.setup-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -63,19 +68,3 @@
|
|||||||
border: 1px dashed var(--edge);
|
border: 1px dashed var(--edge);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.roster-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
background: var(--well);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.6rem 0.9rem;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Between scenes there's only the voting/roster panel — center it. */
|
||||||
|
.between-grid.single {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 640px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.between-grid {
|
.between-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
82
frontend/src/components/AboutModal.svelte
Normal file
82
frontend/src/components/AboutModal.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { VERSION, CHANGELOG } from '../lib/changelog';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
function close() { dispatch('close'); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||||
|
|
||||||
|
<div class="modal-backdrop" on:click|self={close}>
|
||||||
|
<div class="modal-box sheet-modal glass-panel about-modal">
|
||||||
|
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||||
|
<h3>About</h3>
|
||||||
|
<p class="about-tagline">A remote-play companion for <em>Rats with Gats</em>.</p>
|
||||||
|
<p class="about-version">Version {VERSION}</p>
|
||||||
|
|
||||||
|
<h4 class="about-heading">What's New</h4>
|
||||||
|
<ul class="changelog">
|
||||||
|
{#each CHANGELOG as entry (entry.version)}
|
||||||
|
<li class="changelog-entry">
|
||||||
|
<div class="changelog-head">
|
||||||
|
<span class="changelog-ver">v{entry.version}</span>
|
||||||
|
<span class="changelog-date text-muted">{entry.date}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="changelog-changes">
|
||||||
|
{#each entry.changes as change}
|
||||||
|
<li>{change}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.about-tagline {
|
||||||
|
margin: 0.25rem 0 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.about-version {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.about-heading {
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
border-top: 1px solid var(--edge-soft);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
.changelog {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.changelog-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.changelog-ver {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.changelog-date {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.changelog-changes {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, obstacleTable } from '../lib/cards';
|
import { SUIT_EMOJI, isJoker, cardValue, cardSuit, cardTooltip, cardTooltipHtml, obstacleTable } from '../lib/cards';
|
||||||
|
import { tooltip } from '../lib/tooltip';
|
||||||
|
|
||||||
export let card; // card code, e.g. "10D" or "Joker1"
|
export let card; // card code, e.g. "10D" or "Joker1"
|
||||||
export let size = 'large'; // 'large' | 'medium' | 'mini'
|
export let size = 'large'; // 'large' | 'medium' | 'mini'
|
||||||
@@ -7,7 +8,7 @@
|
|||||||
export let rotated = false; // mini: rendered as a played (rotated) column card
|
export let rotated = false; // mini: rendered as a played (rotated) column card
|
||||||
export let success = null; // mini: true/false adds success/failure styling
|
export let success = null; // mini: true/false adds success/failure styling
|
||||||
export let owner = ''; // mini: short label of who played the card
|
export let owner = ''; // mini: short label of who played the card
|
||||||
export let title = '';
|
export let title = ''; // explicit override; otherwise the card describes itself
|
||||||
export let techs = null; // large: {J, Q, K} technique names for the face-card overlay
|
export let techs = null; // large: {J, Q, K} technique names for the face-card overlay
|
||||||
export let style = '';
|
export let style = '';
|
||||||
|
|
||||||
@@ -16,20 +17,23 @@
|
|||||||
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
|
$: suit = joker ? '' : SUIT_EMOJI[cardSuit(card)];
|
||||||
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
|
$: suitClass = joker ? 'joker' : cardSuit(card).toLowerCase();
|
||||||
$: techName = techs && !joker ? techs[cardValue(card)] : null;
|
$: techName = techs && !joker ? techs[cardValue(card)] : null;
|
||||||
// Tooltip: explicit title wins; otherwise the suit's theme plus what the
|
// Tooltip: an explicit title wins (plain text); otherwise a rich graphical
|
||||||
// card would represent as an Obstacle (recomputes once the table loads)
|
// tooltip describing the card itself (suit theme + Obstacle meaning), which
|
||||||
$: tooltip = title || cardTooltip(card, $obstacleTable);
|
// recomputes once the obstacle table loads.
|
||||||
|
$: tipContent = title ? title : { html: cardTooltipHtml(card, $obstacleTable) };
|
||||||
|
$: ariaLabel = title || cardTooltip(card, $obstacleTable);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if size === 'mini'}
|
{#if size === 'mini'}
|
||||||
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}" title={tooltip}>
|
<div class="card-mini {rotated ? 'played-card rotated' : 'base-card'} {success === true ? 'success' : success === false ? 'failure' : ''}"
|
||||||
|
use:tooltip={tipContent} aria-label={ariaLabel}>
|
||||||
<span class="val">{val}</span>
|
<span class="val">{val}</span>
|
||||||
<span class="suit">{suit}</span>
|
<span class="suit">{suit}</span>
|
||||||
{#if owner}<span class="owner">{owner}</span>{/if}
|
{#if owner}<span class="owner">{owner}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
|
<div class="card-{size} suit-{suitClass} {joker ? 'joker-card' : ''}"
|
||||||
title={tooltip} {draggable} {style}
|
use:tooltip={tipContent} aria-label={ariaLabel} {draggable} {style}
|
||||||
on:dragstart on:dragend on:click>
|
on:dragstart on:dragend on:click>
|
||||||
<div class="card-corner top-left">
|
<div class="card-corner top-left">
|
||||||
<span class="val">{val}</span>
|
<span class="val">{val}</span>
|
||||||
@@ -44,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if techName}
|
{#if techName}
|
||||||
<div class="card-tech-overlay">
|
<div class="card-tech-overlay">
|
||||||
<div class="tech-tag" title="Automatic success when played">{cardValue(card)}: "{techName}"</div>
|
<div class="tech-tag" use:tooltip={`Automatic success when played\n\n"${techName}"`}>{cardValue(card)}: "{techName}"</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="card-corner bottom-right">
|
<div class="card-corner bottom-right">
|
||||||
|
|||||||
@@ -40,6 +40,11 @@
|
|||||||
editingBasic = true;
|
editingBasic = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discard any edits made since clicking Revise and return to the saved view.
|
||||||
|
function cancelReviseBasic() {
|
||||||
|
editingBasic = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Like/Hate tasks are assigned automatically when character creation starts.
|
// Like/Hate tasks are assigned automatically when character creation starts.
|
||||||
// This is a fallback for games that entered the phase before assignments existed.
|
// This is a fallback for games that entered the phase before assignments existed.
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -63,6 +68,10 @@
|
|||||||
let tech1 = '', tech2 = '', tech3 = '';
|
let tech1 = '', tech2 = '', tech3 = '';
|
||||||
let savingTechs = false;
|
let savingTechs = false;
|
||||||
let techError = '';
|
let techError = '';
|
||||||
|
// True while editing previously-submitted techniques (vs. first-time entry),
|
||||||
|
// so we know to offer a Cancel button. techSnapshot holds the saved values.
|
||||||
|
let revisingTechs = false;
|
||||||
|
let techSnapshot = [];
|
||||||
|
|
||||||
async function submitTechniques() {
|
async function submitTechniques() {
|
||||||
savingTechs = true;
|
savingTechs = true;
|
||||||
@@ -71,6 +80,7 @@
|
|||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||||
tech1, tech2, tech3
|
tech1, tech2, tech3
|
||||||
});
|
});
|
||||||
|
revisingTechs = false;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
techError = e.message;
|
techError = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -79,12 +89,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reviseTechniques() {
|
async function reviseTechniques() {
|
||||||
|
techSnapshot = [...createdTechniques];
|
||||||
[tech1 = '', tech2 = '', tech3 = ''] = createdTechniques;
|
[tech1 = '', tech2 = '', tech3 = ''] = createdTechniques;
|
||||||
|
revisingTechs = true;
|
||||||
techError = '';
|
techError = '';
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unsubmit-techniques`, 'POST');
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unsubmit-techniques`, 'POST');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
techError = e.message;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +158,9 @@
|
|||||||
let jackTech = '', queenTech = '', kingTech = '';
|
let jackTech = '', queenTech = '', kingTech = '';
|
||||||
let assigningFace = false;
|
let assigningFace = false;
|
||||||
let faceError = '';
|
let faceError = '';
|
||||||
|
// True while revising a previously-assigned J/Q/K layout, so we offer Cancel.
|
||||||
|
let revisingFace = false;
|
||||||
|
let faceSnapshot = {};
|
||||||
|
|
||||||
async function assignFaceTechniques() {
|
async function assignFaceTechniques() {
|
||||||
assigningFace = true;
|
assigningFace = true;
|
||||||
@@ -137,6 +171,7 @@
|
|||||||
queen: queenTech,
|
queen: queenTech,
|
||||||
king: kingTech
|
king: kingTech
|
||||||
});
|
});
|
||||||
|
revisingFace = false;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
faceError = e.message;
|
faceError = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -145,14 +180,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reviseFaceTechniques() {
|
async function reviseFaceTechniques() {
|
||||||
|
faceSnapshot = {
|
||||||
|
jack: state.player.tech_jack,
|
||||||
|
queen: state.player.tech_queen,
|
||||||
|
king: state.player.tech_king
|
||||||
|
};
|
||||||
jackTech = state.player.tech_jack;
|
jackTech = state.player.tech_jack;
|
||||||
queenTech = state.player.tech_queen;
|
queenTech = state.player.tech_queen;
|
||||||
kingTech = state.player.tech_king;
|
kingTech = state.player.tech_king;
|
||||||
|
revisingFace = true;
|
||||||
faceError = '';
|
faceError = '';
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unassign-face-techniques`, 'POST');
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unassign-face-techniques`, 'POST');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
faceError = e.message;
|
faceError = e.message;
|
||||||
|
revisingFace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw away edits and re-assign the J/Q/K layout as it was before Revise.
|
||||||
|
async function cancelReviseFaceTechniques() {
|
||||||
|
assigningFace = true;
|
||||||
|
faceError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/assign-face-techniques`, 'POST', {
|
||||||
|
jack: faceSnapshot.jack,
|
||||||
|
queen: faceSnapshot.queen,
|
||||||
|
king: faceSnapshot.king
|
||||||
|
});
|
||||||
|
revisingFace = false;
|
||||||
|
} catch(e) {
|
||||||
|
faceError = e.message;
|
||||||
|
} finally {
|
||||||
|
assigningFace = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +305,12 @@
|
|||||||
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
<div class="input-row mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
||||||
|
{#if basicComplete}
|
||||||
|
<button type="button" class="btn btn-secondary" disabled={savingBasic} on:click={cancelReviseBasic}>Cancel</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -350,6 +415,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
||||||
|
{#if revisingTechs}
|
||||||
|
<button type="button" class="btn btn-secondary btn-full mt-4" disabled={savingTechs} on:click={cancelReviseTechniques}>Cancel — keep my original techniques</button>
|
||||||
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
{:else if phase === 'character_creation'}
|
{:else if phase === 'character_creation'}
|
||||||
<div class="submitted-techniques text-center mt-4">
|
<div class="submitted-techniques text-center mt-4">
|
||||||
@@ -470,6 +538,13 @@
|
|||||||
>
|
>
|
||||||
{assigningFace ? 'Assigning...' : 'Assign to Cards'}
|
{assigningFace ? 'Assigning...' : 'Assign to Cards'}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -491,39 +566,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SECTION 5: Crew Creation Progress -->
|
|
||||||
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
|
|
||||||
<h3>V. Crew Creation Status</h3>
|
|
||||||
<p class="section-desc">See the creation progress of the entire crew in real time.</p>
|
|
||||||
<div class="crew-status-list">
|
|
||||||
<ul class="space-y-2 mt-4">
|
|
||||||
{#each state.players as p}
|
|
||||||
<li class="status-row {p.tech_jack ? 'is-ready' : ''}">
|
|
||||||
<span>{displayName(p)}</span>
|
|
||||||
<span class="text-sm">
|
|
||||||
{#if p.needs_reroll}
|
|
||||||
<span class="text-muted">Spectating</span>
|
|
||||||
{:else if phase === 'swap_techniques'}
|
|
||||||
{#if p.is_ready}
|
|
||||||
<span class="text-success">Done Swapping</span>
|
|
||||||
{:else}
|
|
||||||
{@const ownLeft = JSON.parse(p.held_techniques || '[]').filter(t => t.creator_id === p.id).length}
|
|
||||||
<span class="text-warning">Swapping ({ownLeft} of their own left)</span>
|
|
||||||
{/if}
|
|
||||||
{:else if p.tech_jack}
|
|
||||||
<span class="text-success">Ready</span>
|
|
||||||
{:else if phase === 'assign_techniques'}
|
|
||||||
<span class="text-warning">Assigning Face Cards</span>
|
|
||||||
{:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3}
|
|
||||||
<span class="text-warning">Techniques Submitted</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted">Writing Sheet</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import AboutModal from './AboutModal.svelte';
|
||||||
import { extraMenuLinks } from '../lib/menu';
|
import { extraMenuLinks } from '../lib/menu';
|
||||||
import { theme, toggleTheme } from '../lib/theme';
|
import { theme, toggleTheme } from '../lib/theme';
|
||||||
|
|
||||||
let open = false;
|
let open = false;
|
||||||
|
let showAbout = false;
|
||||||
|
|
||||||
$: themeLink = $theme === 'dark'
|
$: themeLink = $theme === 'dark'
|
||||||
? { label: '☀️ Light Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the light theme' }
|
? { label: '☀️ Light Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the light theme' }
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
$: baseLinks = [
|
$: baseLinks = [
|
||||||
{ label: '📖 Rules', href: '/rules', external: true, title: 'Open the rulebook in a new tab' },
|
{ label: '📖 Rules', href: '/rules', external: true, title: 'Open the rulebook in a new tab' },
|
||||||
themeLink,
|
themeLink,
|
||||||
|
{ label: 'ℹ️ About', action: () => (showAbout = true), title: 'Version and changelog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
$: links = [...$extraMenuLinks, ...baseLinks];
|
$: links = [...$extraMenuLinks, ...baseLinks];
|
||||||
@@ -52,6 +55,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showAbout}
|
||||||
|
<AboutModal on:close={() => (showAbout = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.corner-menu {
|
.corner-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
97
frontend/src/components/CrewSidebar.svelte
Normal file
97
frontend/src/components/CrewSidebar.svelte
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script>
|
||||||
|
import { displayName, crewLabel } from '../lib/cards';
|
||||||
|
import CharacterSheet from './scene/CharacterSheet.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let openTargetId = null;
|
||||||
|
|
||||||
|
$: captainId = state.game.captain_player_id;
|
||||||
|
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
||||||
|
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
||||||
|
|
||||||
|
function iconFor(p) {
|
||||||
|
if (p.role === 'deep') return '🌊';
|
||||||
|
if (p.is_ghost) return '👻';
|
||||||
|
if (p.is_dead) return '💀';
|
||||||
|
return '🐀';
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeArray(value) {
|
||||||
|
try { return JSON.parse(value || '[]'); } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFor(p) {
|
||||||
|
const phase = state.game.phase;
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (p.is_creator) return 'Creator';
|
||||||
|
if (p.is_admin) return 'Admin';
|
||||||
|
return 'Crew member';
|
||||||
|
}
|
||||||
|
if (phase === 'character_creation') {
|
||||||
|
if (p.needs_reroll) return 'Spectating';
|
||||||
|
return safeArray(p.created_techniques).length === 3 ? 'Techniques submitted' : 'Writing sheet';
|
||||||
|
}
|
||||||
|
if (phase === 'swap_techniques') {
|
||||||
|
if (p.is_ready) return 'Done swapping';
|
||||||
|
const ownLeft = safeArray(p.held_techniques).filter(t => t.creator_id === p.id).length;
|
||||||
|
return `Swapping · ${ownLeft} own left`;
|
||||||
|
}
|
||||||
|
if (phase === 'assign_techniques') return p.tech_jack ? 'Ready' : 'Assigning face cards';
|
||||||
|
if (phase === 'scene_setup') {
|
||||||
|
if (p.needs_reroll) return 'Spectating';
|
||||||
|
if (p.role === 'deep') return 'The Deep';
|
||||||
|
if (p.role === 'pirat') return `Pi-Rat · Rank ${p.rank}`;
|
||||||
|
return 'Choosing a role…';
|
||||||
|
}
|
||||||
|
if (phase === 'between_scenes') {
|
||||||
|
const voted = (state.votes || []).some(v => v.voter_player_id === p.id);
|
||||||
|
const canVote = state.players.some(candidate =>
|
||||||
|
candidate.id !== p.id && candidate.role !== 'deep' && !candidate.is_dead && !candidate.needs_reroll
|
||||||
|
);
|
||||||
|
return `Rank ${p.rank} · ${voted ? 'Nomination done' : canVote ? 'Nominating…' : 'No vote'}`;
|
||||||
|
}
|
||||||
|
if (phase === 'deep_upkeep') return `Rank ${p.rank} · ${p.is_ready ? 'Ready' : 'Finishing upkeep…'}`;
|
||||||
|
if (phase === 'recruit_creation') return p.needs_reroll ? 'Creating a new Pi-Rat' : `Rank ${p.rank} · Helping recruits`;
|
||||||
|
if (phase === 'ended') {
|
||||||
|
const story = p.completed_personal_3 ? 'Story complete' : 'Still sailing';
|
||||||
|
return `Rank ${p.rank} · ${story}`;
|
||||||
|
}
|
||||||
|
return `Rank ${p.rank}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="crew-column phase-crew-column" aria-label="Crew roster">
|
||||||
|
<div class="crew-bubbles">
|
||||||
|
{#each crew as p (p.id)}
|
||||||
|
{#if state.game.phase === 'lobby'}
|
||||||
|
<div
|
||||||
|
class="crew-bubble is-static"
|
||||||
|
class:is-you={p.id === state.player.id}
|
||||||
|
>
|
||||||
|
<span class="bubble-icon">{iconFor(p)}</span>
|
||||||
|
<span class="bubble-name">{displayName(p)}</span>
|
||||||
|
<span class="bubble-meta">{statusFor(p)}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="crew-bubble"
|
||||||
|
class:is-you={p.id === state.player.id}
|
||||||
|
class:is-deep={p.role === 'deep'}
|
||||||
|
class:is-dead={p.is_dead}
|
||||||
|
class:is-ghost={p.is_ghost}
|
||||||
|
title="View {p.name}'s character sheet"
|
||||||
|
on:click={() => openTargetId = p.id}
|
||||||
|
>
|
||||||
|
<span class="bubble-icon">{iconFor(p)}</span>
|
||||||
|
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||||
|
<span class="bubble-meta">{statusFor(p)}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{#if openTarget}
|
||||||
|
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
||||||
|
{/if}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { tick } from 'svelte';
|
import { tick, onMount, createEventDispatcher } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
// Inline mode pins the log open as a column (scene phase) instead of the
|
||||||
|
// floating, collapsible corner panel used in every other phase.
|
||||||
|
export let inline = false;
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
||||||
@@ -27,6 +32,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let open = false;
|
let open = false;
|
||||||
|
// The content is visible whenever the floating log is open OR it's pinned inline.
|
||||||
|
$: shown = open || inline;
|
||||||
let logEl;
|
let logEl;
|
||||||
let events = []; // ascending by timestamp; accumulated across polls + history loads
|
let events = []; // ascending by timestamp; accumulated across polls + history loads
|
||||||
let seenIds = new Set();
|
let seenIds = new Set();
|
||||||
@@ -35,6 +42,11 @@
|
|||||||
let atBottom = true;
|
let atBottom = true;
|
||||||
let error = '';
|
let error = '';
|
||||||
let timelineVersion = null;
|
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).
|
// Rollback buttons appear during a scene, for Admins and Deep players (server-enforced too).
|
||||||
$: canRollback = state.game?.phase === 'scene'
|
$: canRollback = state.game?.phase === 'scene'
|
||||||
@@ -57,29 +69,50 @@
|
|||||||
// action from a rolled-back state truncates the abandoned future and bumps the
|
// 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.
|
// version, which tells us to drop what we'd accumulated and reseed.
|
||||||
const version = state.game?.rollback_timeline_version ?? 0;
|
const version = state.game?.rollback_timeline_version ?? 0;
|
||||||
|
let reset = false;
|
||||||
if (timelineVersion !== null && version !== timelineVersion) {
|
if (timelineVersion !== null && version !== timelineVersion) {
|
||||||
events = [];
|
events = [];
|
||||||
seenIds = new Set();
|
seenIds = new Set();
|
||||||
|
reset = true;
|
||||||
}
|
}
|
||||||
timelineVersion = version;
|
timelineVersion = version;
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
||||||
}
|
}
|
||||||
let added = false;
|
let added = false;
|
||||||
|
let newest = null;
|
||||||
for (const e of polled) {
|
for (const e of polled) {
|
||||||
if (!seenIds.has(e.id)) {
|
if (!seenIds.has(e.id)) {
|
||||||
seenIds.add(e.id);
|
seenIds.add(e.id);
|
||||||
events.push(e);
|
events.push(e);
|
||||||
added = true;
|
added = true;
|
||||||
|
newest = e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (added) {
|
if (added) {
|
||||||
events = events;
|
events = events;
|
||||||
if (open && atBottom) {
|
if (shown && atBottom) {
|
||||||
await tick();
|
await tick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
// Toast only for genuinely new events (not the initial seed or a
|
||||||
|
// rollback reseed) and only while the floating log is closed.
|
||||||
|
if (initialized && !reset && !shown && newest) {
|
||||||
|
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An open/inline log makes the preview toast redundant.
|
||||||
|
$: if (shown) toast = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (inline) scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearToast(id) {
|
||||||
|
if (toast && toast.id === id) toast = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
@@ -148,12 +181,32 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="floating-event-log {open ? 'open' : ''}">
|
<div class="floating-event-log {shown ? 'open' : ''} {inline ? 'inline' : ''}">
|
||||||
<button class="toggle-log-btn" on:click={toggleOpen}>
|
{#if toast && !shown}
|
||||||
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
{#key toast.id}
|
||||||
</button>
|
<button
|
||||||
|
class="event-toast log-kind-{toast.kind}"
|
||||||
|
title="Open the Event Log"
|
||||||
|
on:click={toggleOpen}
|
||||||
|
on:animationend={() => clearToast(toast.id)}
|
||||||
|
>
|
||||||
|
<span class="log-icon">{KIND_ICONS[toast.kind] || KIND_ICONS.info}</span>
|
||||||
|
<span class="toast-message">{toast.message}</span>
|
||||||
|
</button>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
{#if inline}
|
||||||
|
<div class="log-header">
|
||||||
|
📜 Event Log
|
||||||
|
<button class="log-hide-btn" title="Collapse the event log" on:click={() => dispatch('collapse')}>✕</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="toggle-log-btn" on:click={toggleOpen}>
|
||||||
|
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if open}
|
{#if shown}
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="log-error">{error}</div>
|
<div class="log-error">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -218,6 +271,52 @@
|
|||||||
.floating-event-log:not(.open) {
|
.floating-event-log:not(.open) {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
/* Inline mode: pinned as the scene's third column instead of floating. The
|
||||||
|
top offset matches .dashboard-container's top padding (clears the fixed
|
||||||
|
corner menu) so the panel doesn't shift as the page scrolls; the height
|
||||||
|
fills to ~1rem above the viewport bottom, so the panel never runs past
|
||||||
|
the screen and its .log-content scrolls internally instead. */
|
||||||
|
.floating-event-log.inline {
|
||||||
|
position: sticky;
|
||||||
|
top: 3.5rem;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 4.5rem);
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.floating-event-log.inline {
|
||||||
|
position: static;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.log-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
border-bottom: 1px solid var(--edge-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.log-hide-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 6px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.log-hide-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--text) 12%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
.toggle-log-btn {
|
.toggle-log-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -352,6 +451,48 @@
|
|||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast preview of a fresh event, anchored above the button */
|
||||||
|
.event-toast {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
width: 320px;
|
||||||
|
max-width: 80vw;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
cursor: pointer;
|
||||||
|
transform-origin: bottom right;
|
||||||
|
animation: event-toast-into-button 3.4s ease-in forwards;
|
||||||
|
}
|
||||||
|
.event-toast .toast-message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
@keyframes event-toast-into-button {
|
||||||
|
0% { opacity: 0; transform: translateY(-6px) scale(0.96); }
|
||||||
|
8% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
75% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
100% { opacity: 0; transform: translateY(40px) scale(0.4); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.event-toast { animation-duration: 2.5s; animation-timing-function: linear; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Accent colors by event kind */
|
/* Accent colors by event kind */
|
||||||
.log-kind-challenge { border-left-color: var(--danger); }
|
.log-kind-challenge { border-left-color: var(--danger); }
|
||||||
.log-kind-card { border-left-color: var(--deep); }
|
.log-kind-card { border-left-color: var(--deep); }
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { displayName } from '../lib/cards';
|
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
$: pirats = state.players;
|
|
||||||
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
|
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -29,24 +26,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sheet-group">
|
|
||||||
<h3 class="text-accent">The Crew</h3>
|
|
||||||
{#each pirats as p}
|
|
||||||
<div class="crew-epitaph-row">
|
|
||||||
<span>
|
|
||||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if}
|
|
||||||
<strong>{displayName(p)}</strong> (Rank {p.rank})
|
|
||||||
{#if p.id === state.game.captain_player_id}<span class="text-accent"> ⭐ Captain</span>{/if}
|
|
||||||
</span>
|
|
||||||
<span class="objectives-summary">
|
|
||||||
{p.completed_personal_1 ? '🔫 Gat' : '— no gat'} ·
|
|
||||||
{p.completed_personal_2 ? '📛 Named' : '— nameless'} ·
|
|
||||||
{p.completed_personal_3 ? '⚰️ Story complete' : '⛵ Still sailing'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="info-text" style="margin-top: 2rem;">Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!</p>
|
<p class="info-text" style="margin-top: 2rem;">Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
88
frontend/src/components/GatModal.svelte
Normal file
88
frontend/src/components/GatModal.svelte
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let descInput = '';
|
||||||
|
let error = '';
|
||||||
|
let submitting = false;
|
||||||
|
|
||||||
|
async function submitDescription() {
|
||||||
|
if (!descInput.trim() || submitting) return;
|
||||||
|
error = '';
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-gat-description`, 'POST', {
|
||||||
|
description: descInput.trim()
|
||||||
|
});
|
||||||
|
descInput = '';
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.key === 'Enter') submitDescription();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if state.player.needs_gat_description}
|
||||||
|
<div class="modal-backdrop">
|
||||||
|
<div class="modal-box glass-panel">
|
||||||
|
<h3>🔫 You Got a Gat!</h3>
|
||||||
|
<p class="info-text">
|
||||||
|
Every Pi-Rat's Gat is one of a kind. What does yours look like?
|
||||||
|
</p>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={descInput}
|
||||||
|
class="input-field"
|
||||||
|
placeholder="e.g. A pearl-handled flintlock that smells of cheese"
|
||||||
|
on:keydown={onKeydown}
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-gold btn-full"
|
||||||
|
on:click={submitDescription}
|
||||||
|
disabled={!descInput.trim() || submitting}
|
||||||
|
>
|
||||||
|
Claim Gat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.75rem;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.modal-box h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.modal-box .input-field {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,27 +24,42 @@
|
|||||||
starting = false;
|
starting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: teaching = state.game.teaching_deep_player_id === state.player.id;
|
||||||
|
$: teachingTakenByOther = state.game.teaching_deep_player_id && !teaching;
|
||||||
|
|
||||||
|
async function toggleTeaching() {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/teaching-mode`, 'POST', {
|
||||||
|
enabled: !teaching
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to toggle teaching mode:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="lobby-view text-center">
|
<div class="lobby-view text-center">
|
||||||
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
|
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
|
||||||
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
||||||
|
|
||||||
<div class="lobby-status-box glass-panel">
|
|
||||||
<h3>Joined Crew members</h3>
|
|
||||||
<div class="player-chips" id="lobby-player-list">
|
|
||||||
{#each state.players as p}
|
|
||||||
<div class="player-chip {p.id === state.player.id ? 'current-user' : ''}">
|
|
||||||
<span class="avatar-icon">🐀</span>
|
|
||||||
<span class="name">{p.name}</span>
|
|
||||||
{#if p.is_creator}<span class="creator-badge">Creator</span>
|
|
||||||
{:else if p.is_admin}<span class="creator-badge">Admin</span>{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="links-box glass-panel">
|
<div class="links-box glass-panel">
|
||||||
|
{#if state.player.is_admin}
|
||||||
|
<div class="link-item lobby-join-code-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-join-code"
|
||||||
|
class:copied={copiedLink === 'code'}
|
||||||
|
aria-label="Copy join code {state.game.join_code}"
|
||||||
|
on:click={() => copyLink('code', state.game.join_code)}
|
||||||
|
>
|
||||||
|
<span class="admin-join-code-label">Join Code</span>
|
||||||
|
<strong>{state.game.join_code}</strong>
|
||||||
|
<span class="admin-join-code-hint">{copiedLink === 'code' ? '✓ Copied!' : 'Click to copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="link-item">
|
<div class="link-item">
|
||||||
<h4>Share Join Link:</h4>
|
<h4>Share Join Link:</h4>
|
||||||
<div class="copy-container">
|
<div class="copy-container">
|
||||||
@@ -72,6 +87,22 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if state.player.is_admin}
|
||||||
|
<div class="teaching-box glass-panel">
|
||||||
|
<label class="checkbox-label" class:disabled={teachingTakenByOther}>
|
||||||
|
<input type="checkbox" checked={teaching} disabled={teachingTakenByOther} on:change={toggleTeaching}>
|
||||||
|
<span>📚 <strong>Teaching mode:</strong> I'll be the Deep for the first scene</span>
|
||||||
|
</label>
|
||||||
|
<p class="info-text">
|
||||||
|
{#if teachingTakenByOther}
|
||||||
|
Another admin has already volunteered to teach as the Deep.
|
||||||
|
{:else}
|
||||||
|
Seeds you as the Rank-3 player who must play the Deep in scene 1, so you can run the table for new crews. Otherwise it's assigned at random.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="lobby-action">
|
<div class="lobby-action">
|
||||||
{#if state.player.is_admin}
|
{#if state.player.is_admin}
|
||||||
{#if state.players.length >= 3 || state.game.dev_mode}
|
{#if state.players.length >= 3 || state.game.dev_mode}
|
||||||
|
|||||||
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>
|
||||||
@@ -56,6 +56,11 @@
|
|||||||
editingBasic = true;
|
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);
|
$: basicComplete = !!(me.avatar_look && me.avatar_smell && me.first_words && me.good_at_math);
|
||||||
$: likeDone = !me.other_like_from_player_id || !!me.other_like;
|
$: likeDone = !me.other_like_from_player_id || !!me.other_like;
|
||||||
$: hateDone = !me.other_hate_from_player_id || !!me.other_hate;
|
$: hateDone = !me.other_hate_from_player_id || !!me.other_hate;
|
||||||
@@ -130,16 +135,6 @@
|
|||||||
techSlots: incomingTechs(p).map((s, i) => ({ ...s, slot: i })).filter(s => s.from_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);
|
})).filter(t => t.like || t.hate || t.techSlots.length > 0);
|
||||||
|
|
||||||
function recruitProgress(p) {
|
|
||||||
const techs = incomingTechs(p);
|
|
||||||
const parts = [];
|
|
||||||
parts.push(p.avatar_look && p.avatar_smell && p.first_words && p.good_at_math ? '✅ Records' : '❌ Records');
|
|
||||||
const likeOk = !p.other_like_from_player_id || p.other_like;
|
|
||||||
const hateOk = !p.other_hate_from_player_id || p.other_hate;
|
|
||||||
parts.push(likeOk && hateOk ? '✅ Like/Hate' : '❌ Like/Hate');
|
|
||||||
parts.push(`${techs.filter(s => s.text).length}/3 Techniques`);
|
|
||||||
return parts.join(' · ');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="recruit-phase-view">
|
<div class="recruit-phase-view">
|
||||||
@@ -191,7 +186,12 @@
|
|||||||
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
|
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
<div class="input-row mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
||||||
|
{#if basicComplete}
|
||||||
|
<button type="button" class="btn btn-secondary" disabled={savingBasic} on:click={cancelReviseBasic}>Cancel</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -311,22 +311,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RECRUIT PROGRESS -->
|
|
||||||
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
|
|
||||||
<h3>{iAmRecruit ? 'V.' : 'II.'} Recruit Progress</h3>
|
|
||||||
<div class="crew-status-list">
|
|
||||||
<ul class="space-y-2 mt-4">
|
|
||||||
{#each recruits as p}
|
|
||||||
<li class="status-row">
|
|
||||||
<span>{displayName(p)}</span>
|
|
||||||
<span class="text-sm text-muted">{recruitProgress(p)}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
{#if recruits.length === 0}
|
|
||||||
<li class="status-row"><span class="text-success">All recruits are aboard! Heading to the next scene...</span></li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,78 +1,33 @@
|
|||||||
<script>
|
<script>
|
||||||
import Card from './Card.svelte';
|
import Card from './Card.svelte';
|
||||||
import { displayName } from '../lib/cards';
|
|
||||||
import ChallengePanel from './scene/ChallengePanel.svelte';
|
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||||
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||||
import DeepControlPanel from './scene/DeepControlPanel.svelte';
|
import CrewColumn from './scene/CrewColumn.svelte';
|
||||||
import CharacterSheet from './scene/CharacterSheet.svelte';
|
import EventLog from './EventLog.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
|
// The inline Event Log can be toggled from a fixed corner button to declutter.
|
||||||
|
let logOpen = true;
|
||||||
|
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
|
|
||||||
$: isCaptain = state.game.captain_player_id === state.player.id;
|
|
||||||
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
// The Challenge area is shown when something is happening there, or to the Deep
|
||||||
|
// (who calls Challenges and ends the scene from it).
|
||||||
|
$: showChallengeArea = isDeep || openChallenges.length > 0;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
<div class="scene-view-layout" class:log-collapsed={!logOpen} id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||||
<!-- LEFT COLUMN: The Shared Table -->
|
<!-- LEFT COLUMN: The Crew -->
|
||||||
|
<CrewColumn {state} />
|
||||||
|
|
||||||
|
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
|
||||||
<div class="table-column">
|
<div class="table-column">
|
||||||
|
{#if !isDeep}
|
||||||
<!-- 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">
|
|
||||||
<!-- Captain Badge -->
|
|
||||||
<div>
|
|
||||||
{#if captain}
|
|
||||||
<span class="captain-badge">
|
|
||||||
🏴☠️ Captain: {displayName(captain)} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="captain-badge vacant">
|
|
||||||
🏴☠️ Captain: None{state.game.completed_crew_1 ? '' : ' (steal a ship first!)'}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Active Scene Roster Mini-list -->
|
|
||||||
<div class="roster-chips">
|
|
||||||
<span class="roster-label">Active Roster:</span>
|
|
||||||
{#each state.players as p}
|
|
||||||
{#if p.role === 'deep'}
|
|
||||||
<span class="player-chip deep-chip">
|
|
||||||
🌊 {displayName(p)} (Deep)
|
|
||||||
</span>
|
|
||||||
{:else if p.role === 'pirat'}
|
|
||||||
<span class="player-chip pirat-chip {p.is_ghost ? 'is-ghost' : ''} {p.is_dead ? 'is-dead' : ''} {p.id === state.game.captain_player_id ? 'is-captain' : ''}">
|
|
||||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {displayName(p)} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChallengePanel {state} />
|
|
||||||
<ObstacleBoard {state} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
|
||||||
<div class="private-column">
|
|
||||||
{#if state.player.role === "deep"}
|
|
||||||
<DeepControlPanel {state} />
|
|
||||||
{:else}
|
|
||||||
<!-- Player Hand Card -->
|
|
||||||
<div class="card glass-panel hand-card">
|
<div class="card glass-panel hand-card">
|
||||||
<h3>🃏 Your Hand</h3>
|
<span class="hand-label">🃏 Your Hand</span>
|
||||||
<p class="section-desc">Keep your cards secret! 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.</p>
|
|
||||||
|
|
||||||
<div class="hand-flex">
|
<div class="hand-flex">
|
||||||
{#each hand as card}
|
{#each hand as card}
|
||||||
@@ -90,8 +45,35 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CharacterSheet {state} />
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showChallengeArea}
|
||||||
|
<div class="card glass-panel challenge-area-card">
|
||||||
|
<ChallengePanel {state} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card glass-panel obstacle-list-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌊 The Obstacle List</h3>
|
||||||
|
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
|
||||||
|
</div>
|
||||||
|
<ObstacleBoard {state} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: The Event Log (collapsible to a corner button) -->
|
||||||
|
{#if logOpen}
|
||||||
|
<div class="log-column">
|
||||||
|
<EventLog {state} inline on:collapse={() => (logOpen = false)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="log-reopen-btn"
|
||||||
|
title={logOpen ? 'Hide the event log' : 'Show the event log'}
|
||||||
|
on:click={() => (logOpen = !logOpen)}
|
||||||
|
>
|
||||||
|
{logOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { displayName } from '../lib/cards';
|
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="setup-grid">
|
<div class="setup-grid single">
|
||||||
<!-- Role Selection Card -->
|
<!-- Role Selection Card -->
|
||||||
<div class="card glass-panel role-selection-card">
|
<div class="card glass-panel role-selection-card">
|
||||||
<h3>Select Your Role</h3>
|
<h3>Select Your Role</h3>
|
||||||
@@ -107,24 +106,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live Roster Card -->
|
|
||||||
<div class="card glass-panel roster-card">
|
|
||||||
<h3>Live Roster Selection</h3>
|
|
||||||
<div class="roster-list" id="scene-roster-list">
|
|
||||||
{#each state.players as p}
|
|
||||||
<div class="roster-item">
|
|
||||||
<span class="player-name">{displayName(p)} <span class="text-sm text-muted">(Rank {p.rank})</span></span>
|
|
||||||
{#if p.role === 'pirat'}
|
|
||||||
<span class="role-badge pirat">Pi-Rat</span>
|
|
||||||
{:else if p.role === 'deep'}
|
|
||||||
<span class="role-badge deep">The Deep</span>
|
|
||||||
{:else}
|
|
||||||
<span class="role-badge unassigned">Choosing...</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-box margin-top">
|
<div class="action-box margin-top">
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
|
// Winner of the rank-up vote, surfaced on the post-voting (deep_upkeep) screen.
|
||||||
|
$: rankUpWinner = state.game.last_rank_up_player_id
|
||||||
|
? state.players.find(p => p.id === state.game.last_rank_up_player_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
// A nominee must be another living, in-play Pi-Rat: not the voter, not a
|
// 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
|
// previous-scene Deep, and not dead / awaiting-recruit (they'd reset to
|
||||||
@@ -194,7 +198,7 @@
|
|||||||
<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>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="between-grid">
|
<div class="between-grid" class:single={state.game.phase !== 'deep_upkeep'}>
|
||||||
<!-- Ranks and Voting Card -->
|
<!-- Ranks and Voting Card -->
|
||||||
<div class="card glass-panel voting-card">
|
<div class="card glass-panel voting-card">
|
||||||
<h3>1. Pirate Ranking (Voting)</h3>
|
<h3>1. Pirate Ranking (Voting)</h3>
|
||||||
@@ -202,7 +206,11 @@
|
|||||||
|
|
||||||
{#if state.game.phase === 'deep_upkeep'}
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
<div class="voted-confirmation-box glass-panel">
|
<div class="voted-confirmation-box glass-panel">
|
||||||
<p class="success-text">✔️ Voting complete! Results tallied.</p>
|
{#if rankUpWinner}
|
||||||
|
<p class="success-text">🏆 {displayName(rankUpWinner)} won the vote and ranked up to Rank {rankUpWinner.rank}!</p>
|
||||||
|
{:else}
|
||||||
|
<p class="success-text">✔️ Voting complete! No one ranked up this time.</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if hasVoted}
|
{:else if hasVoted}
|
||||||
<div class="voted-confirmation-box glass-panel">
|
<div class="voted-confirmation-box glass-panel">
|
||||||
@@ -226,35 +234,31 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Voting Roster Status -->
|
|
||||||
{#if state.game.phase !== 'deep_upkeep'}
|
|
||||||
<div class="vote-status-table margin-top">
|
|
||||||
<h4>Nomination Progress:</h4>
|
|
||||||
<div class="player-chips list-chips" id="voting-roster">
|
|
||||||
{#each state.players as p}
|
|
||||||
{@const voted = state.votes.some(v => v.voter_player_id === p.id)}
|
|
||||||
{@const skips = eligibleNomineesFor(p).length === 0}
|
|
||||||
<div class="player-chip {voted || skips ? 'voted-chip' : 'not-voted-chip'}">
|
|
||||||
<span class="name">{displayName(p)}</span>
|
|
||||||
<span class="status-badge">{voted ? 'Done' : skips ? 'No vote' : 'Voting...'}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resting Deep Player Card -->
|
<!-- Resting Deep Player Card — only the actual hand refresh, during deep_upkeep -->
|
||||||
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
<div class="card glass-panel deep-rest-card">
|
<div class="card glass-panel deep-rest-card">
|
||||||
<h3>2. Resting Deep Upkeep</h3>
|
<h3>2. Resting Deep Upkeep</h3>
|
||||||
|
|
||||||
{#if state.player.role === "deep"}
|
{#if state.player.role === "deep"}
|
||||||
<div class="deep-rest-panel glass-panel">
|
<div class="deep-rest-panel glass-panel">
|
||||||
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p>
|
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p>
|
||||||
|
|
||||||
{#if state.game.phase === 'between_scenes'}
|
{#if state.player.is_ready}
|
||||||
<p class="info-text text-center gold-text" style="margin-top: 1rem;">Voting is currently in progress. Hand refresh will begin after voting concludes.</p>
|
<!-- Confirmation: the refresh went through; show the freshly-drawn hand. -->
|
||||||
{:else if state.game.phase === 'deep_upkeep'}
|
<div class="voted-confirmation-box glass-panel" style="margin-top: 1rem;">
|
||||||
|
<p class="success-text">✔️ Hand refreshed! You're holding {hand.length} card{hand.length === 1 ? '' : 's'} for the next scene.</p>
|
||||||
|
</div>
|
||||||
|
<div class="upkeep-flex" style="justify-content: center; margin-top: 1rem;">
|
||||||
|
{#each hand as card}
|
||||||
|
<Card {card} />
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text text-center w-full">No cards in hand.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="info-text text-center" style="margin-top: 1rem;">Waiting for the rest of the crew to finish upkeep...</p>
|
||||||
|
{:else}
|
||||||
<p class="info-text" style="margin-top: 1rem;">
|
<p class="info-text" style="margin-top: 1rem;">
|
||||||
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
|
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
|
||||||
Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
|
Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
|
||||||
@@ -308,31 +312,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if state.game.phase === 'between_scenes'}
|
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
|
||||||
<p class="info-text text-center">Your Pi-Rat was active. Your hand cards carry over to the next scene.</p>
|
|
||||||
{:else if state.game.phase === 'deep_upkeep'}
|
|
||||||
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="roster-sheet-summary margin-top text-left glass-panel">
|
|
||||||
<h4>Crew Rank Ledger:</h4>
|
|
||||||
<ul class="ranks-ledger">
|
|
||||||
{#each state.players as p}
|
|
||||||
<li>
|
|
||||||
<strong>{displayName(p)}:</strong> Rank {p.rank}
|
|
||||||
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Next Scene Ready Up -->
|
<!-- Next Scene Ready Up -->
|
||||||
<div class="action-box margin-top">
|
<div class="action-box margin-top">
|
||||||
{#if state.game.phase === 'deep_upkeep'}
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
{#if state.player.role === 'deep'}
|
{#if state.player.role === 'deep' && !state.player.is_ready}
|
||||||
<p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
|
<p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="waiting-box">
|
<div class="waiting-box">
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltip, obstacleTable } from '../../lib/cards';
|
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltipHtml, obstacleTable } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
|
import ObstacleItem from './ObstacleItem.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
let error = '';
|
let error = '';
|
||||||
let taxTargetId = '';
|
let taxTargetId = '';
|
||||||
|
// Deep: call-a-challenge form (moved here from the old Deep Control Panel)
|
||||||
|
let showCreate = false;
|
||||||
|
let challengeTargetId = '';
|
||||||
|
let stakesSuccess = '';
|
||||||
|
let stakesFailure = '';
|
||||||
|
let selectedObstacles = {};
|
||||||
|
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||||
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
|
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
|
||||||
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
|
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
|
||||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
|
||||||
function playerName(id) {
|
function playerName(id) {
|
||||||
return lookupName(state.players, id);
|
return lookupName(state.players, id);
|
||||||
@@ -53,6 +63,31 @@
|
|||||||
taxTargetId = '';
|
taxTargetId = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createChallenge() {
|
||||||
|
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
|
||||||
|
if (await post('challenge/create', {
|
||||||
|
target_player_id: challengeTargetId,
|
||||||
|
obstacle_ids: JSON.stringify(ids),
|
||||||
|
stakes_success: stakesSuccess,
|
||||||
|
stakes_failure: stakesFailure,
|
||||||
|
})) {
|
||||||
|
challengeTargetId = '';
|
||||||
|
stakesSuccess = '';
|
||||||
|
stakesFailure = '';
|
||||||
|
selectedObstacles = {};
|
||||||
|
showCreate = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endScene() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -60,7 +95,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each openChallenges as ch}
|
{#each openChallenges as ch}
|
||||||
{@const chPlays = JSON.parse(ch.plays || '[]')}
|
|
||||||
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
|
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
|
||||||
<div class="challenge-item">
|
<div class="challenge-item">
|
||||||
<div class="challenge-head">
|
<div class="challenge-head">
|
||||||
@@ -78,26 +112,31 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if ch.stakes}
|
{#if ch.stakes_success}
|
||||||
|
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>✅ On success:</strong> {ch.stakes_success}</p>
|
||||||
|
{/if}
|
||||||
|
{#if ch.stakes_failure}
|
||||||
|
<p class="info-text" style="margin: 0.25rem 0 0 0;"><strong>❌ On failure:</strong> {ch.stakes_failure}</p>
|
||||||
|
{/if}
|
||||||
|
{#if ch.stakes && !ch.stakes_success && !ch.stakes_failure}
|
||||||
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
|
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if ch.challenge_type === 'pvp'}
|
{#if ch.challenge_type === 'pvp'}
|
||||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||||
Temporary Obstacle: <strong title={cardTooltip(ch.temp_card, $obstacleTable)}>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable, true) }}>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
<div class="challenge-obstacles">
|
||||||
Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'}
|
{#each chObstacleIds as oid}
|
||||||
— attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
|
{@const obs = state.obstacles.find(o => o.id === oid)}
|
||||||
Other Pi-Rats may assist (assistants don't draw back).
|
{#if obs}
|
||||||
</p>
|
<ObstacleItem {state} {obs} embedded />
|
||||||
|
{:else}
|
||||||
|
<p class="info-text" style="margin: 0;">An applied Obstacle was discarded.</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if chPlays.length > 0}
|
|
||||||
<p class="info-text" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;">
|
|
||||||
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Tax: pending request aimed at me -->
|
<!-- Tax: pending request aimed at me -->
|
||||||
{#if ch.tax_state === 'requested'}
|
{#if ch.tax_state === 'requested'}
|
||||||
{#if myTaxRequest && myTaxRequest.id === ch.id}
|
{#if myTaxRequest && myTaxRequest.id === ch.id}
|
||||||
@@ -134,10 +173,53 @@
|
|||||||
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
||||||
<div class="challenge-actions">
|
<div class="challenge-actions">
|
||||||
{#each hand.filter(c => !isJoker(c)) as card}
|
{#each hand.filter(c => !isJoker(c)) as card}
|
||||||
<button class="btn btn-secondary" title={cardTooltip(card, $obstacleTable)} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
<button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable, true) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Deep controls: call a Challenge, and end the scene when none is active -->
|
||||||
|
<!-- The Deep calls Challenges and ends the scene only when none is active. -->
|
||||||
|
{#if isDeep && openChallenges.length === 0}
|
||||||
|
<div class="deep-challenge-controls">
|
||||||
|
<button class="btn {showCreate ? 'btn-secondary' : 'btn-primary'} btn-full" on:click={() => showCreate = !showCreate}>
|
||||||
|
{showCreate ? '✕ Cancel' : '⚔️ Call a Challenge'}
|
||||||
|
</button>
|
||||||
|
{#if showCreate}
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
|
||||||
|
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<option value="">Challenge which Pi-Rat?</option>
|
||||||
|
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||||
|
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<div style="margin-bottom: 0.5rem;">
|
||||||
|
{#each state.obstacles as obs}
|
||||||
|
{@const taken = challengedObstacleIds.has(obs.id)}
|
||||||
|
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
|
||||||
|
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
|
||||||
|
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<input type="text" class="input-field" placeholder="On success (optional): what they win…" bind:value={stakesSuccess} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<input type="text" class="input-field" placeholder="On failure (optional): what it costs them…" bind:value={stakesFailure} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<button class="btn btn-primary btn-full" on:click={createChallenge}
|
||||||
|
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
|
||||||
|
Call Challenge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="scene-upkeep-controls text-center">
|
||||||
|
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
|
||||||
|
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
|
||||||
|
End Scene & Proceed to Ranking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,193 +1,213 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
import { getCardDisplay, isJoker, displayName, cardObstacleInfo, obstacleTable } from '../../lib/cards';
|
import { getCardDisplay, isJoker, displayName, crewLabel, captainName, cardObstacleInfo, pvpObstacleInfo, obstacleTable } from '../../lib/cards';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
export let target; // the player whose sheet is being viewed
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
function close() { dispatch('close'); }
|
||||||
|
|
||||||
let error = '';
|
let error = '';
|
||||||
let newNameInput = '';
|
|
||||||
let pvpOpponentId = '';
|
|
||||||
let pvpCard = '';
|
let pvpCard = '';
|
||||||
|
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: viewer = state.player;
|
||||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
$: isSelf = target.id === viewer.id;
|
||||||
|
$: isDeep = viewer.role === 'deep';
|
||||||
|
$: isCaptain = target.id === state.game.captain_player_id;
|
||||||
|
// The Deep manages captaincy and ticks personal objectives from a Pi-Rat's sheet
|
||||||
|
// (both moved here from the old Deep Control Panel).
|
||||||
|
$: canManageCaptain = isDeep && target.role === 'pirat' && !target.is_dead;
|
||||||
|
$: canManageObjectives = isDeep && target.role === 'pirat';
|
||||||
|
// The viewer throws one of their OWN cards in a duel.
|
||||||
|
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
|
||||||
|
$: canDuel = !isSelf
|
||||||
|
&& viewer.role === 'pirat' && !viewer.is_dead && !viewer.needs_reroll
|
||||||
|
&& target.role === 'pirat' && !target.is_dead;
|
||||||
|
// What the chosen duel card becomes as a temporary Obstacle for the defender
|
||||||
|
$: pvpCardObstacle = pvpObstacleInfo(pvpCard);
|
||||||
|
|
||||||
function getPlayerName(id) {
|
function getPlayerName(id) {
|
||||||
if (!id) return 'a crewmate';
|
if (!id) return 'a crewmate';
|
||||||
const p = state.players.find(pl => pl.id === id);
|
const p = state.players.find(pl => pl.id === id);
|
||||||
return p ? displayName(p) : 'Unknown';
|
return p ? displayName(p) : 'Unknown';
|
||||||
}
|
}
|
||||||
$: duelTargets = scenePirats.filter(p => p.id !== state.player.id && !p.is_dead);
|
|
||||||
// What the chosen duel card becomes as a temporary Obstacle for the defender
|
|
||||||
$: pvpCardObstacle = cardObstacleInfo(pvpCard, $obstacleTable);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createPvp() {
|
async function createPvp() {
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
|
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/challenge/pvp/create`, 'POST', {
|
||||||
defender_id: pvpOpponentId,
|
defender_id: target.id,
|
||||||
card_code: pvpCard
|
card_code: pvpCard
|
||||||
});
|
});
|
||||||
pvpOpponentId = '';
|
|
||||||
pvpCard = '';
|
pvpCard = '';
|
||||||
} catch(e) {
|
close();
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setCaptain(makeCaptain) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/set-captain`, 'POST', {
|
||||||
|
target_player_id: makeCaptain ? target.id : ''
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleObjective(type) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${target.id}/objective/toggle`, 'POST', { type });
|
||||||
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card glass-panel sheet-card">
|
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||||
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
|
||||||
{#if state.player.player_name && state.player.player_name !== state.player.name}
|
|
||||||
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
|
|
||||||
Played by {state.player.player_name}{#if !state.player.completed_personal_2} — they'll go by their smell until they earn a Name!{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="character-sheet-details">
|
<div class="modal-backdrop" on:click|self={close}>
|
||||||
{#if error}
|
<div class="modal-box sheet-modal character-sheet-modal glass-panel">
|
||||||
<div class="alert alert-danger">{error}</div>
|
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
{#if target.role === 'deep'}🌊{:else if target.is_ghost}👻{:else if target.is_dead}💀{:else}🐀{/if}
|
||||||
|
{target.id === state.game.captain_player_id ? captainName(target.name) : target.name}'s Character Sheet
|
||||||
|
</h3>
|
||||||
|
{#if target.player_name && target.player_name !== target.name}
|
||||||
|
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
|
||||||
|
Played by {target.player_name}{#if !target.completed_personal_2} — they'll go by their smell until they earn a Name!{/if}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if state.player.needs_reroll}
|
<div class="character-sheet-details">
|
||||||
<div class="prompt-box accent text-center">
|
<div class="sheet-main-column">
|
||||||
<h4>🐀 Recruit Incoming!</h4>
|
{#if error}
|
||||||
<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 class="alert alert-danger">{error}</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if state.player.is_dead}
|
{#if isSelf && state.player.needs_reroll}
|
||||||
<div class="prompt-box danger text-center">
|
<div class="prompt-box accent text-center">
|
||||||
<h4>💀 You have Died / Retired!</h4>
|
<h4>🐀 Recruit Incoming!</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>
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You don't have a Pi-Rat in this scene. Sit back, enjoy the chaos, and you'll create your recruit with the crew between scenes.</p>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if state.player.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 state.player.needs_name}
|
|
||||||
<div class="prompt-box accent">
|
|
||||||
<h4>🏴☠️ 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 class="input-row">
|
|
||||||
<input type="text" bind:value={newNameInput} class="input-field" placeholder="Enter new name...">
|
|
||||||
<button class="btn btn-gold" on:click={submitNewName} disabled={!newNameInput.trim()}>Claim Name</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if state.player.tax_banned}
|
{#if isSelf && state.player.is_dead}
|
||||||
<div class="prompt-box danger text-center">
|
<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>
|
<h4>💀 You have Died / Retired!</h4>
|
||||||
</div>
|
<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>
|
||||||
{/if}
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="sheet-group">
|
{#if isSelf && state.player.is_ghost}
|
||||||
<h4>Description:</h4>
|
<div class="prompt-box ghostly text-center">
|
||||||
<p><strong>Look:</strong> {state.player.avatar_look}</p>
|
<h4>👻 Playing as a Ghost</h4>
|
||||||
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You are in the Ghost World (grayscale view). You cannot interact well with the living but you can still help them resolve challenges!</p>
|
||||||
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
{#if state.player.other_like || state.player.other_hate}
|
{#if isSelf && state.player.tax_banned}
|
||||||
<div class="sheet-group margin-top">
|
<div class="prompt-box danger text-center">
|
||||||
<h4>What the Crew Thinks:</h4>
|
<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>
|
||||||
{#if state.player.other_like}
|
</div>
|
||||||
<p><strong>👍 Likes:</strong> "{state.player.other_like}" <span class="text-muted">— {getPlayerName(state.player.other_like_from_player_id)}</span></p>
|
{/if}
|
||||||
{/if}
|
|
||||||
{#if state.player.other_hate}
|
<div class="sheet-group">
|
||||||
<p><strong>👎 Hates:</strong> "{state.player.other_hate}" <span class="text-muted">— {getPlayerName(state.player.other_hate_from_player_id)}</span></p>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
{#if target.other_like || target.other_hate}
|
||||||
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
<div class="sheet-group margin-top">
|
||||||
<ul class="techniques-list-sheet">
|
<h4>What the Crew Thinks:</h4>
|
||||||
<li><strong>Jack (J):</strong> "{state.player.tech_jack}"</li>
|
{#if target.other_like}
|
||||||
<li><strong>Queen (Q):</strong> "{state.player.tech_queen}"</li>
|
<p><strong>👍 Likes:</strong> "{target.other_like}" <span class="text-muted">— {getPlayerName(target.other_like_from_player_id)}</span></p>
|
||||||
<li><strong>King (K):</strong> "{state.player.tech_king}"</li>
|
{/if}
|
||||||
</ul>
|
{#if target.other_hate}
|
||||||
</div>
|
<p><strong>👎 Hates:</strong> "{target.other_hate}" <span class="text-muted">— {getPlayerName(target.other_hate_from_player_id)}</span></p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Duel another Pi-Rat -->
|
<!-- Secret techniques are visible only on your own sheet -->
|
||||||
{#if !state.player.is_dead && !state.player.needs_reroll && duelTargets.length > 0}
|
{#if isSelf}
|
||||||
<div class="sheet-group margin-top">
|
<div class="sheet-group margin-top">
|
||||||
<h4>⚔️ Duel a Pi-Rat</h4>
|
<h4>Assigned Secret Techniques (J/Q/K):</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>
|
<ul class="techniques-list-sheet">
|
||||||
<select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
|
<li><strong>Jack (J):</strong> "{target.tech_jack}"</li>
|
||||||
<option value="">Challenge whom?</option>
|
<li><strong>Queen (Q):</strong> "{target.tech_queen}"</li>
|
||||||
{#each duelTargets as p}
|
<li><strong>King (K):</strong> "{target.tech_king}"</li>
|
||||||
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
</ul>
|
||||||
{/each}
|
</div>
|
||||||
</select>
|
{/if}
|
||||||
<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={cardObstacleInfo(card, $obstacleTable) ? `${cardObstacleInfo(card, $obstacleTable).title} — ${cardObstacleInfo(card, $obstacleTable).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={!pvpOpponentId || !pvpCard}>Throw Down!</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
<!-- Duel this Pi-Rat (target is fixed to this sheet) -->
|
||||||
<h4>Crew Objectives</h4>
|
{#if canDuel}
|
||||||
<div class="objectives-checklist">
|
<div class="sheet-group margin-top">
|
||||||
<label class="checkbox-label">
|
<h4>⚔️ Duel {crewLabel(target, state.game.captain_player_id)}</h4>
|
||||||
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
|
<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>
|
||||||
<span>Steal a Ship</span>
|
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
</label>
|
<option value="">Throw which card?</option>
|
||||||
<label class="checkbox-label">
|
{#each hand.filter(c => !isJoker(c)) as card}
|
||||||
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
|
<option value={card} title={pvpObstacleInfo(card) ? `${pvpObstacleInfo(card).title} — ${pvpObstacleInfo(card).description}` : ''}>{getCardDisplay(card)}</option>
|
||||||
<span>Choose a Captain</span>
|
{/each}
|
||||||
</label>
|
</select>
|
||||||
<label class="checkbox-label">
|
{#if pvpCardObstacle}
|
||||||
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
|
<p class="info-text" style="font-size: 0.8rem; margin: -0.25rem 0 0.5rem 0;">
|
||||||
<span>Commit Piracy</span>
|
As an Obstacle, {getCardDisplay(pvpCard)} is <strong>{pvpCardObstacle.title}</strong>: {pvpCardObstacle.description}
|
||||||
</label>
|
</p>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpCard}>Throw Down!</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
{#if canManageCaptain}
|
||||||
<h4>Personal Objectives</h4>
|
<div class="sheet-group margin-top">
|
||||||
<div class="objectives-checklist">
|
<h4>🏴☠️ Captaincy</h4>
|
||||||
<label class="checkbox-label">
|
{#if isCaptain}
|
||||||
<input type="checkbox" checked={state.player.completed_personal_1} disabled>
|
<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>
|
||||||
<span>1. Gat</span>
|
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(false)}>Remove as Captain</button>
|
||||||
</label>
|
{:else}
|
||||||
<label class="checkbox-label">
|
<p class="info-text" style="font-size: 0.85rem;">Hand this Pi-Rat the Captaincy (e.g. after a leadership duel or resignation).</p>
|
||||||
<input type="checkbox" checked={state.player.completed_personal_2} disabled>
|
<button class="btn btn-secondary btn-full" on:click={() => setCaptain(true)}>Make Captain</button>
|
||||||
<span>2. Name</span>
|
{/if}
|
||||||
</label>
|
</div>
|
||||||
<label class="checkbox-label">
|
{/if}
|
||||||
<input type="checkbox" checked={state.player.completed_personal_3} disabled>
|
</div> <!-- end sheet-main-column -->
|
||||||
<span>3. Die/Retire</span>
|
|
||||||
</label>
|
<div class="sheet-side-column">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
115
frontend/src/components/scene/CrewColumn.svelte
Normal file
115
frontend/src/components/scene/CrewColumn.svelte
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script>
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
import { crewLabel } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
|
import CharacterSheet from './CharacterSheet.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let openTargetId = null;
|
||||||
|
let showObjectives = false;
|
||||||
|
let objError = '';
|
||||||
|
|
||||||
|
$: isDeep = state.player.role === 'deep';
|
||||||
|
|
||||||
|
// Track which Pi-Rats have faced a Deep Challenge this scene so the Deep can
|
||||||
|
// see who still needs one before ending the scene.
|
||||||
|
$: challengedIds = new Set(
|
||||||
|
(state.challenges || [])
|
||||||
|
.filter(c => c.challenge_type === 'deep')
|
||||||
|
.map(c => c.target_player_id)
|
||||||
|
);
|
||||||
|
function hasBeenChallenged(p) {
|
||||||
|
return p.role === 'pirat' && challengedIds.has(p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Deep ticks Crew Objectives off here (moved from the old Deep panel).
|
||||||
|
async function toggleCrew(type) {
|
||||||
|
objError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/objective/toggle`, 'POST', { type });
|
||||||
|
} catch (e) {
|
||||||
|
objError = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pi-Rats first, the Deep last; otherwise keep join order.
|
||||||
|
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
||||||
|
$: captainId = state.game.captain_player_id;
|
||||||
|
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
||||||
|
$: crewDone = [state.game.completed_crew_1, state.game.completed_crew_2, state.game.completed_crew_3].filter(Boolean).length;
|
||||||
|
|
||||||
|
function handSize(p) {
|
||||||
|
try { return JSON.parse(p.hand_cards || '[]').length; } catch { return 0; }
|
||||||
|
}
|
||||||
|
function roleIcon(p) {
|
||||||
|
if (p.role === 'deep') return '🌊';
|
||||||
|
if (p.is_ghost) return '👻';
|
||||||
|
if (p.is_dead) return '💀';
|
||||||
|
return '🐀';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crew-column">
|
||||||
|
<div class="crew-bubbles">
|
||||||
|
{#each crew as p (p.id)}
|
||||||
|
<button
|
||||||
|
class="crew-bubble"
|
||||||
|
class:is-you={p.id === state.player.id}
|
||||||
|
class:is-deep={p.role === 'deep'}
|
||||||
|
class:is-dead={p.is_dead}
|
||||||
|
class:is-ghost={p.is_ghost}
|
||||||
|
title="View {p.name}'s character sheet"
|
||||||
|
on:click={() => openTargetId = p.id}
|
||||||
|
>
|
||||||
|
{#if isDeep && p.role === 'pirat'}
|
||||||
|
<span
|
||||||
|
class="challenge-check"
|
||||||
|
class:done={hasBeenChallenged(p)}
|
||||||
|
use:tooltip={hasBeenChallenged(p)
|
||||||
|
? `${p.name} has faced a Challenge this scene.`
|
||||||
|
: `${p.name} hasn't faced a Challenge yet — the scene can't end until they do (or the Obstacle List is cleared).`}
|
||||||
|
>{hasBeenChallenged(p) ? '✓' : '○'}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="bubble-icon">{roleIcon(p)}</span>
|
||||||
|
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||||
|
<span class="bubble-meta">
|
||||||
|
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Crew Objectives sits at the end of the roster and slides open in place -->
|
||||||
|
<button
|
||||||
|
class="crew-objectives-toggle"
|
||||||
|
aria-expanded={showObjectives}
|
||||||
|
on:click={() => showObjectives = !showObjectives}
|
||||||
|
>
|
||||||
|
<span class="co-label">{showObjectives ? '▾' : '▸'} Crew Objectives</span>
|
||||||
|
<span class="co-count">{crewDone}/3</span>
|
||||||
|
</button>
|
||||||
|
{#if showObjectives}
|
||||||
|
<div class="crew-objectives-detail" transition:slide>
|
||||||
|
{#if objError}<div class="alert alert-danger">{objError}</div>{/if}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_1} disabled={!isDeep} on:change={() => toggleCrew('crew_1')}>
|
||||||
|
<span>Steal a Ship</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_2} disabled={!isDeep} on:change={() => toggleCrew('crew_2')}>
|
||||||
|
<span>Choose a Captain</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_3} disabled={!isDeep} on:change={() => toggleCrew('crew_3')}>
|
||||||
|
<span>Commit Piracy</span>
|
||||||
|
</label>
|
||||||
|
{#if isDeep}<p class="info-text" style="font-size: 0.75rem; margin: 0.25rem 0 0;">As the Deep, tick these off as the crew earns them.</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if openTarget}
|
||||||
|
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
||||||
|
{/if}
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { apiRequest } from '../../lib/api';
|
|
||||||
import { displayName } from '../../lib/cards';
|
|
||||||
|
|
||||||
export let state;
|
|
||||||
|
|
||||||
let error = '';
|
|
||||||
let challengeTargetId = '';
|
|
||||||
let challengeStakes = '';
|
|
||||||
let selectedObstacles = {};
|
|
||||||
let captainSelectId = '';
|
|
||||||
|
|
||||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
|
||||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
|
||||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
|
||||||
|
|
||||||
async function createChallenge() {
|
|
||||||
error = '';
|
|
||||||
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', {
|
|
||||||
target_player_id: challengeTargetId,
|
|
||||||
obstacle_ids: JSON.stringify(ids),
|
|
||||||
stakes: challengeStakes
|
|
||||||
});
|
|
||||||
challengeTargetId = '';
|
|
||||||
challengeStakes = '';
|
|
||||||
selectedObstacles = {};
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setCaptain() {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-captain`, 'POST', {
|
|
||||||
target_player_id: captainSelectId
|
|
||||||
});
|
|
||||||
captainSelectId = '';
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleObjective(targetPlayerId, type) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', { type });
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function endScene() {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="card glass-panel deep-panel-card">
|
|
||||||
<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>⚔️ Call a Challenge</h4>
|
|
||||||
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
|
|
||||||
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
|
|
||||||
<option value="">Challenge which Pi-Rat?</option>
|
|
||||||
{#each scenePirats.filter(p => !p.is_dead) as p}
|
|
||||||
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<div style="margin-bottom: 0.5rem;">
|
|
||||||
{#each state.obstacles as obs}
|
|
||||||
{@const taken = challengedObstacleIds.has(obs.id)}
|
|
||||||
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
|
|
||||||
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
|
|
||||||
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<input type="text" class="input-field" 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>🏴☠️ Captaincy</h4>
|
|
||||||
<p class="info-text" style="font-size: 0.85rem;">The Captain holds power until they step down, lose a duel, die, or the ship is lost. Reassign after a leadership duel or resignation.</p>
|
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
|
||||||
<select class="select-field" bind:value={captainSelectId} style="flex: 1;">
|
|
||||||
<option value="">No Captain</option>
|
|
||||||
{#each scenePirats.filter(p => !p.is_dead) as p}
|
|
||||||
<option value={p.id}>{displayName(p)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-secondary" on:click={setCaptain}>Set</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4>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>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 class="objective-player-block">
|
|
||||||
<strong>{displayName(p)}</strong>
|
|
||||||
<div class="objectives-checklist" style="margin-top: 0.25rem;">
|
|
||||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_1} on:change={() => toggleObjective(p.id, 'personal_1')}> <span>1. Gat</span></label>
|
|
||||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_2} on:change={() => toggleObjective(p.id, 'personal_2')}> <span>2. Name</span></label>
|
|
||||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_3} on:change={() => toggleObjective(p.id, 'personal_3')}> <span>3. Die/Retire</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- End Scene Button -->
|
|
||||||
<div class="scene-upkeep-controls text-center">
|
|
||||||
<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,130 +1,35 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../../lib/api';
|
import ObstacleItem from './ObstacleItem.svelte';
|
||||||
import { getCardDisplay, isJoker } from '../../lib/cards';
|
|
||||||
import Card from '../Card.svelte';
|
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
let error = '';
|
|
||||||
|
|
||||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||||
|
// When a Challenge is active, the involved Obstacles move up into the Challenge
|
||||||
// A face-card Obstacle is worth the challenged Rat's Rank. When the Obstacle is
|
// panel; the rest of the list collapses so attention stays on the Challenge.
|
||||||
// part of an open Challenge we know who that is — return their rank, else null.
|
$: challengeActive = openChallenges.length > 0;
|
||||||
function challengedRankFor(obstacleId) {
|
$: looseObstacles = state.obstacles.filter(o => !challengedObstacleIds.has(o.id));
|
||||||
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obstacleId));
|
|
||||||
if (!ch) return null;
|
|
||||||
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playCard(obstacleId, cardCode) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
|
|
||||||
obstacle_id: obstacleId,
|
|
||||||
card_code: cardCode
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playJoker(obstacleId, cardCode) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-joker`, 'POST', {
|
|
||||||
obstacle_id: obstacleId,
|
|
||||||
card_code: cardCode
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearObstacle(obstacleId) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
|
|
||||||
} catch(e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if error}
|
{#if challengeActive}
|
||||||
<div class="alert alert-danger">{error}</div>
|
{#if looseObstacles.length}
|
||||||
{/if}
|
<details class="obstacle-collapse">
|
||||||
|
<summary>🌊 {looseObstacles.length} other Obstacle{looseObstacles.length === 1 ? '' : 's'} on the list — show</summary>
|
||||||
{#each state.obstacles as obs}
|
<div class="obstacles-container">
|
||||||
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
|
{#each looseObstacles as obs (obs.id)}
|
||||||
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card}
|
<ObstacleItem {state} {obs} />
|
||||||
{@const success_count = column_cards.filter(c => c.success).length}
|
|
||||||
{@const is_completed = success_count >= state.players.length}
|
|
||||||
{@const in_challenge = challengedObstacleIds.has(obs.id)}
|
|
||||||
<div class="obstacle-item suit-{obs.suit.toLowerCase()} {is_completed ? 'completed' : ''} {in_challenge ? 'in-challenge' : ''}"
|
|
||||||
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">
|
|
||||||
<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 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">
|
|
||||||
<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">
|
|
||||||
{success_count} / {state.players.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Played Cards Column -->
|
|
||||||
<div class="played-column">
|
|
||||||
<h5>Card History (Column)</h5>
|
|
||||||
<div class="column-cards-flex">
|
|
||||||
<Card card={obs.original_card} size="mini" />
|
|
||||||
{#each column_cards as pc}
|
|
||||||
<Card card={pc.card} size="mini" rotated success={pc.success}
|
|
||||||
owner={pc.player_name.slice(0, 4)}
|
|
||||||
title="{pc.player_name} played {getCardDisplay(pc.card)}" />
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
|
{:else}
|
||||||
{#if is_completed && state.player.role === 'deep'}
|
<p class="empty-text">All Obstacles are tied up in the Challenge above.</p>
|
||||||
<div class="clear-obstacle-row text-center">
|
{/if}
|
||||||
<button class="btn btn-success btn-full" on:click={() => clearObstacle(obs.id)}>Clear Obstacle</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
<div class="obstacles-container">
|
||||||
{/each}
|
{#each state.obstacles as obs (obs.id)}
|
||||||
|
<ObstacleItem {state} {obs} />
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text">No active obstacles. The Deep can end the scene!</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
112
frontend/src/components/scene/ObstacleItem.svelte
Normal file
112
frontend/src/components/scene/ObstacleItem.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
import { isJoker, cardValue } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
|
import Card from '../Card.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
export let obs;
|
||||||
|
export let embedded = false; // rendered inside the Challenge panel — skip the "in challenge" flag
|
||||||
|
|
||||||
|
// Plain (text-presentation) suit glyphs so they inherit the title's suit color,
|
||||||
|
// unlike SUIT_EMOJI which renders as fixed-color emoji.
|
||||||
|
const SUIT_CHAR = { C: '♣', S: '♠', H: '♥', D: '♦' };
|
||||||
|
const VALUE_HINT = 'The number to beat. Set by the most recent card played on the column — or the original card '
|
||||||
|
+ 'until one is played. (Color stays fixed to the original.)';
|
||||||
|
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||||
|
$: column_cards = JSON.parse(obs.played_cards || '[]');
|
||||||
|
$: active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card;
|
||||||
|
$: success_count = column_cards.filter(c => c.success).length;
|
||||||
|
$: is_completed = success_count >= state.players.length;
|
||||||
|
$: in_challenge = openChallenges.some(c => JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
|
||||||
|
$: cardCodeLabel = `${cardValue(obs.original_card)}${SUIT_CHAR[obs.suit] || ''}`;
|
||||||
|
$: is_face_active = ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1));
|
||||||
|
|
||||||
|
// A face-card Obstacle is worth the challenged Rat's Rank. When this Obstacle is
|
||||||
|
// part of an open Challenge we know who that is — return their rank, else null.
|
||||||
|
$: challengedRank = (() => {
|
||||||
|
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
|
||||||
|
if (!ch) return null;
|
||||||
|
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function post(path, data) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(path, 'POST', data);
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playCard = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-card`, { obstacle_id: obs.id, card_code: cardCode });
|
||||||
|
const playJoker = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-joker`, { obstacle_id: obs.id, card_code: cardCode });
|
||||||
|
const clearObstacle = () => post(`/game/${state.game.id}/scene/obstacle/${obs.id}/clear`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="obstacle-item suit-{obs.suit.toLowerCase()}"
|
||||||
|
class:completed={is_completed}
|
||||||
|
class:in-challenge={in_challenge && !embedded}
|
||||||
|
data-obstacle-id={obs.id}
|
||||||
|
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
||||||
|
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
||||||
|
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
|
||||||
|
on:drop={(e) => {
|
||||||
|
if (is_completed) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove("drag-over");
|
||||||
|
const cardCode = e.dataTransfer.getData('text/plain');
|
||||||
|
if (cardCode) {
|
||||||
|
if (isJoker(cardCode)) playJoker(cardCode);
|
||||||
|
else playCard(cardCode);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger" style="grid-column: 1 / -1;">{error}</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Card column: original at the back, each play stacked in front of it, the
|
||||||
|
latest fully visible and earlier cards peeking their rank + suit. -->
|
||||||
|
<div class="obstacle-stack">
|
||||||
|
<div class="stack-card is-original" use:tooltip={'Original card — fixes the Obstacle’s color and never changes.'}>
|
||||||
|
<Card card={obs.original_card} size="medium" />
|
||||||
|
</div>
|
||||||
|
{#each column_cards as pc, i (i)}
|
||||||
|
<div class="stack-card {pc.success ? 'is-success' : 'is-failure'}"
|
||||||
|
class:is-latest={i === column_cards.length - 1}
|
||||||
|
use:tooltip={`${pc.player_name} played this — ${pc.success ? 'success' : 'no success'}.`}>
|
||||||
|
<Card card={pc.card} size="medium" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="obstacle-details">
|
||||||
|
<h4><span class="obs-card-code">{cardCodeLabel}</span> — {obs.title} {#if in_challenge && !embedded}<span class="in-challenge-tag">⚔️ In Challenge</span>{/if}</h4>
|
||||||
|
<p class="desc">{obs.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Value Display -->
|
||||||
|
<div class="obstacle-value-display text-center" use:tooltip={VALUE_HINT}>
|
||||||
|
<span class="val-label">Current Difficulty</span>
|
||||||
|
<span class="val-number">
|
||||||
|
{#if is_face_active}
|
||||||
|
{challengedRank !== null ? `${challengedRank} (Rat's Rank)` : "Challenged Rat's Rank"}
|
||||||
|
{:else}
|
||||||
|
{obs.current_value}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Successes Tracker -->
|
||||||
|
<div class="obstacle-successes-display text-center">
|
||||||
|
<span class="val-label">Successes</span>
|
||||||
|
<span class="val-number">{success_count} / {state.players.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if is_completed && state.player.role === 'deep'}
|
||||||
|
<div class="clear-obstacle-row text-center">
|
||||||
|
<button class="btn btn-success btn-full" on:click={clearObstacle}>Clear Obstacle</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -17,7 +17,9 @@ export async function apiRequest(endpoint, method = 'GET', data = null) {
|
|||||||
if (errBody.error) msg = errBody.error;
|
if (errBody.error) msg = errBody.error;
|
||||||
else if (errBody.detail) msg = errBody.detail;
|
else if (errBody.detail) msg = errBody.detail;
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
throw new Error(msg);
|
const error = new Error(msg);
|
||||||
|
error.status = res.status;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { apiRequest } from './api';
|
|||||||
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
|
export const SUIT_EMOJI = { C: '♣️', S: '♠️', H: '♥️', D: '♦️' };
|
||||||
|
|
||||||
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
|
// Suit themes from the rulebook: how a Pi-Rat overcomes an Obstacle with that suit.
|
||||||
export const SUIT_THEMES = {
|
const SUIT_THEMES = {
|
||||||
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
|
C: "♣ Clubs — Shootin' and Stabbin': violence, combat, or direct confrontation",
|
||||||
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
|
D: "♦ Diamonds — Swimmin' and Sailin': nautical skills, athletics, or mobility",
|
||||||
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
|
S: "♠ Spades — Sneakin' and Schemin': stealth, deception, or cunning plans",
|
||||||
@@ -46,6 +46,7 @@ export function cardObstacleInfo(code, table = OBSTACLE_TABLE) {
|
|||||||
// Default tooltip for a card: its suit theme when played, plus what it
|
// Default tooltip for a card: its suit theme when played, plus what it
|
||||||
// represents as an Obstacle (relevant when challenging another Pi-Rat).
|
// represents as an Obstacle (relevant when challenging another Pi-Rat).
|
||||||
// Pass the obstacleTable store's value as `table` to recompute reactively.
|
// 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) {
|
export function cardTooltip(code, table = OBSTACLE_TABLE) {
|
||||||
if (!code) return '';
|
if (!code) return '';
|
||||||
if (isJoker(code)) {
|
if (isJoker(code)) {
|
||||||
@@ -58,6 +59,62 @@ export function cardTooltip(code, table = OBSTACLE_TABLE) {
|
|||||||
return `${theme}\nAs an Obstacle: ${obstacle.title} — ${obstacle.description}`;
|
return `${theme}\nAs an Obstacle: ${obstacle.title} — ${obstacle.description}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FACE_VALUES = ['J', 'Q', 'K'];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rich (HTML) tooltip describing a card — what it does when *played*, and what
|
||||||
|
// it means when it surfaces as an Obstacle. Built only from static rulebook
|
||||||
|
// data (SUIT_THEMES + the obstacle table), so the HTML is trusted. Feed the
|
||||||
|
// result to the `tooltip` action as `{ html: cardTooltipHtml(...) }`.
|
||||||
|
export function cardTooltipHtml(code, table = OBSTACLE_TABLE, isPvp = false) {
|
||||||
|
if (!code) return '';
|
||||||
|
if (isJoker(code)) {
|
||||||
|
return '<div class="tt-head"><span class="tt-card tt-joker">🃏 Joker</span></div>'
|
||||||
|
+ '<div class="tt-row"><span class="tt-label">Played</span>Discard one Obstacle (and its column) outright, then draw a replacement.</div>'
|
||||||
|
+ '<div class="tt-row"><span class="tt-label">As an Obstacle</span>Discarded — but every following scene needs one more Obstacle.</div>';
|
||||||
|
}
|
||||||
|
const suit = cardSuit(code);
|
||||||
|
const val = cardValue(code);
|
||||||
|
const isRed = suit === 'H' || suit === 'D';
|
||||||
|
const colorClass = isRed ? 'tt-red' : 'tt-black';
|
||||||
|
let html = `<div class="tt-head"><span class="tt-card ${colorClass}">${esc(val)}${SUIT_EMOJI[suit] || esc(suit)}</span>`
|
||||||
|
+ `<span class="tt-color tt-color-${isRed ? 'red' : 'black'}">${isRed ? 'Red' : 'Black'}</span></div>`;
|
||||||
|
html += `<div class="tt-theme">${esc(SUIT_THEMES[suit] || '')}</div>`;
|
||||||
|
|
||||||
|
if (isPvp) {
|
||||||
|
const themeStr = SUIT_THEMES[suit] || '';
|
||||||
|
const splitIdx = themeStr.indexOf(': ');
|
||||||
|
const title = splitIdx !== -1 ? themeStr.substring(0, splitIdx) : themeStr;
|
||||||
|
const desc = splitIdx !== -1 ? themeStr.substring(splitIdx + 2) : '';
|
||||||
|
html += `<div class="tt-row"><span class="tt-label">PvP Obstacle</span><b>${esc(title)}</b> — ${esc(desc)}</div>`;
|
||||||
|
} else if (FACE_VALUES.includes(val)) {
|
||||||
|
html += '<div class="tt-row"><span class="tt-label">Played by a Pi-Rat</span>Triggers a Secret Technique — automatic success.</div>';
|
||||||
|
html += "<div class=\"tt-row\"><span class=\"tt-label\">As an Obstacle</span>Its value equals the challenged Pi-Rat's Rank.</div>";
|
||||||
|
} else {
|
||||||
|
const obstacle = table?.[suit]?.[val];
|
||||||
|
if (obstacle) {
|
||||||
|
html += `<div class="tt-row"><span class="tt-label">As an Obstacle</span><b>${esc(obstacle.title)}</b> — ${esc(obstacle.description)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pvpObstacleInfo(code) {
|
||||||
|
if (!code || isJoker(code)) return null;
|
||||||
|
const suit = cardSuit(code);
|
||||||
|
const themeStr = SUIT_THEMES[suit];
|
||||||
|
if (!themeStr) return null;
|
||||||
|
const splitIdx = themeStr.indexOf(': ');
|
||||||
|
if (splitIdx === -1) return { title: themeStr, description: '' };
|
||||||
|
return {
|
||||||
|
title: themeStr.substring(0, splitIdx),
|
||||||
|
description: themeStr.substring(splitIdx + 2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function isJoker(code) {
|
export function isJoker(code) {
|
||||||
return !!code && code.startsWith('Joker');
|
return !!code && code.startsWith('Joker');
|
||||||
}
|
}
|
||||||
@@ -89,3 +146,20 @@ export function displayName(p) {
|
|||||||
export function playerName(players, id) {
|
export function playerName(players, id) {
|
||||||
return displayName(players.find(p => p.id === id));
|
return displayName(players.find(p => p.id === id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Pi-Rat's name with the "Recruit" honorific swapped for "Captain" (or
|
||||||
|
// "Captain" prepended once they've earned a real Name). Used to mark the Captain
|
||||||
|
// in the scene roster without a separate icon.
|
||||||
|
export function captainName(name) {
|
||||||
|
if (!name) return 'Captain';
|
||||||
|
if (name.startsWith('Recruit ')) return 'Captain ' + name.slice('Recruit '.length);
|
||||||
|
return 'Captain ' + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayName, but the Captain is shown as "Captain <name> (player)".
|
||||||
|
export function crewLabel(p, captainId) {
|
||||||
|
if (!p) return '???';
|
||||||
|
const rat = p.id === captainId ? captainName(p.name) : p.name;
|
||||||
|
if (p.player_name && p.player_name !== p.name) return `${rat} (${p.player_name})`;
|
||||||
|
return rat;
|
||||||
|
}
|
||||||
|
|||||||
160
frontend/src/lib/changelog.js
Normal file
160
frontend/src/lib/changelog.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// App version and player-facing changelog, surfaced in the ☰ menu → About
|
||||||
|
// (rendered by AboutModal.svelte).
|
||||||
|
//
|
||||||
|
// Maintenance (see AGENTS.md "Versioning & changelog"):
|
||||||
|
// - Bump VERSION by one on every commit.
|
||||||
|
// - Add a CHANGELOG entry only when a commit changes something players can
|
||||||
|
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
||||||
|
|
||||||
|
export const VERSION = 25;
|
||||||
|
|
||||||
|
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
||||||
|
export const CHANGELOG = [
|
||||||
|
{
|
||||||
|
version: 25,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Event Log now uses the same pinned column and fixed close/reopen control in every game phase.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 24,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The crew roster now stays in the same left-hand column throughout every phase of the game.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 23,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Admins can now click the join code to copy it, and the code is available directly from the lobby.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 21,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Player lists now use the same easy-to-scan vertical layout in every phase and at every screen size.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 20,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Admin Panel now gives the game’s join code a large, easy-to-share display.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 19,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Games now have short join codes that can be entered on the home page and shared from the Admin Panel.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 18,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Expired rejoin entries are now removed automatically when a game has ended or a player is no longer in the crew, with a clear explanation.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 17,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Join forms now remember your player name for the next crew while still letting you edit it.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 16,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Open and Copy controls in the Admin Panel now have matching sizes.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 15,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The Admin Panel now clearly marks Pi-Rats whose character identity has not been chosen yet.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 14,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'The corner Event Log button now stays put when the log is open, so it can be closed again without moving your mouse.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 13,
|
||||||
|
date: '2026-07-10',
|
||||||
|
changes: [
|
||||||
|
'Character sheets now make better use of the available space on wide screens.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 8,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Crew roster bubbles are now legible and stand out from the background in both light and dark mode.',
|
||||||
|
'A scene can no longer be ended until every Pi-Rat has faced a Challenge (or the Obstacle List is empty). The Deep sees a ✓/○ marker on each Pi-Rat showing who still needs one.',
|
||||||
|
'Decluttered your Hand panel — dropped the how-to-play blurb for a low-profile label.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 7,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'During a scene, the Event Log can be collapsed to a corner button to declutter your screen, and reopened from that button.',
|
||||||
|
'In the three-column layout, the Event Log now stays pinned within the screen and scrolls internally, so its newest entries are always visible.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 6,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'PvP challenge cards now display the general suit theme instead of specific obstacles in tooltips.',
|
||||||
|
'Secret Technique tooltips now show the full technique description when played.',
|
||||||
|
'Personal Objectives on the Character Sheet are now positioned in a side column on wider screens.'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 5,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Completing a Crew Objective is now announced in the Event Log.',
|
||||||
|
'Fixed a UI quirk where mousing over personal objectives incorrectly showed an interactive pointer for Pi-Rats who cannot change them.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 4,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Tidied the Challenge panel: dropped the redundant “Plays” list and the how-to-play blurb. Each Obstacle now leads with its card (e.g. “K♦”) in the title, colored by suit.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'The rulebook’s Example of Play now has card diagrams at each step, showing how each Obstacle’s column grows as the scene unfolds.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'Obstacle cards now stack as a single overlapping column — the latest play sits in full view with earlier cards peeking out behind it, matching the rulebook’s card layout.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
date: '2026-06-15',
|
||||||
|
changes: [
|
||||||
|
'First tracked version — the app now reports its version here in the ☰ menu → About, with a log of player-facing changes.',
|
||||||
|
'Home screen lists crews you can rejoin above “Muster a New Crew”.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1242,13 +1242,3 @@ function loadTechniquePool() {
|
|||||||
export async function getTechniqueSuggestion(exclude = []) {
|
export async function getTechniqueSuggestion(exclude = []) {
|
||||||
return pickFrom(await loadTechniquePool(), exclude);
|
return pickFrom(await loadTechniquePool(), exclude);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draws `count` distinct technique suggestions.
|
|
||||||
export async function getTechniqueSuggestions(count, exclude = []) {
|
|
||||||
const pool = await loadTechniquePool();
|
|
||||||
const picked = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
picked.push(pickFrom(pool, [...exclude, ...picked]));
|
|
||||||
}
|
|
||||||
return picked;
|
|
||||||
}
|
|
||||||
|
|||||||
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,17 +2,29 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { setPageTitle } from '../lib/title';
|
import { setPageTitle } from '../lib/title';
|
||||||
|
import { getSessions } from '../lib/sessions';
|
||||||
|
|
||||||
export let params = {};
|
export let params = {};
|
||||||
let gameId = params.id;
|
let gameId = params.id;
|
||||||
let adminKey = '';
|
let adminKey = '';
|
||||||
|
|
||||||
|
// Return to this browser's own Pi-Rat dashboard for the game (the most
|
||||||
|
// recently used session), not the public join screen. Falls back to join
|
||||||
|
// if this browser has no saved session for the game.
|
||||||
|
$: mySession = getSessions().find(s => s.gameId === gameId);
|
||||||
|
$: backLink = mySession
|
||||||
|
? `/#/game/${gameId}/player/${mySession.playerId}`
|
||||||
|
: `/#/game/${gameId}/join`;
|
||||||
|
$: backLabel = mySession ? '← Back to Game' : 'Back to Game Join Page';
|
||||||
|
|
||||||
let data = null;
|
let data = null;
|
||||||
let error = '';
|
let error = '';
|
||||||
let copiedPlayerId = null;
|
let copiedPlayerId = null;
|
||||||
let copiedTimer = null;
|
let copiedTimer = null;
|
||||||
let copiedInvite = false;
|
let copiedInvite = false;
|
||||||
let copiedInviteTimer = null;
|
let copiedInviteTimer = null;
|
||||||
|
let copiedJoinCode = false;
|
||||||
|
let copiedJoinCodeTimer = null;
|
||||||
|
|
||||||
$: setPageTitle('Admin', data?.game?.crew_name);
|
$: setPageTitle('Admin', data?.game?.crew_name);
|
||||||
|
|
||||||
@@ -38,6 +50,13 @@
|
|||||||
copiedInviteTimer = setTimeout(() => { copiedInvite = false; }, 2000);
|
copiedInviteTimer = setTimeout(() => { copiedInvite = false; }, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyJoinCode() {
|
||||||
|
navigator.clipboard.writeText(data.game.join_code);
|
||||||
|
copiedJoinCode = true;
|
||||||
|
clearTimeout(copiedJoinCodeTimer);
|
||||||
|
copiedJoinCodeTimer = setTimeout(() => { copiedJoinCode = false; }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Try to load admin_key from local storage
|
// Try to load admin_key from local storage
|
||||||
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
|
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
|
||||||
@@ -107,9 +126,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
|
<div class="admin-summary">
|
||||||
<p><strong>Phase:</strong> {data.game.phase}</p>
|
<div class="admin-summary-details">
|
||||||
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
|
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
|
||||||
|
<p><strong>Phase:</strong> {data.game.phase}</p>
|
||||||
|
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-join-code"
|
||||||
|
class:copied={copiedJoinCode}
|
||||||
|
aria-label="Copy join code {data.game.join_code}"
|
||||||
|
on:click={copyJoinCode}
|
||||||
|
>
|
||||||
|
<span class="admin-join-code-label">Join Code</span>
|
||||||
|
<strong>{data.game.join_code}</strong>
|
||||||
|
<span class="admin-join-code-hint">{copiedJoinCode ? '✓ Copied!' : 'Click to copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert alert-danger mt-4">{error}</div>
|
<div class="alert alert-danger mt-4">{error}</div>
|
||||||
@@ -142,7 +176,13 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each data.players as p}
|
{#each data.players as p}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{p.name}</td>
|
<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>{p.player_name || p.name}</td>
|
||||||
<td>
|
<td>
|
||||||
{#if p.role === 'deep'} 🌊 Deep
|
{#if p.role === 'deep'} 🌊 Deep
|
||||||
@@ -187,7 +227,7 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<a href="/#/game/{gameId}/join" class="btn btn-secondary">Back to Game Join Page</a>
|
<a href={backLink} class="btn btn-secondary">{backLabel}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@
|
|||||||
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
||||||
import RecruitPhase from '../components/RecruitPhase.svelte';
|
import RecruitPhase from '../components/RecruitPhase.svelte';
|
||||||
import GameOverPhase from '../components/GameOverPhase.svelte';
|
import GameOverPhase from '../components/GameOverPhase.svelte';
|
||||||
|
import CrewSidebar from '../components/CrewSidebar.svelte';
|
||||||
import EventLog from '../components/EventLog.svelte';
|
import EventLog from '../components/EventLog.svelte';
|
||||||
|
import NameModal from '../components/NameModal.svelte';
|
||||||
|
import GatModal from '../components/GatModal.svelte';
|
||||||
|
import RankBonusModal from '../components/RankBonusModal.svelte';
|
||||||
|
|
||||||
export let params = {};
|
export let params = {};
|
||||||
let gameId = params.id;
|
let gameId = params.id;
|
||||||
@@ -28,14 +32,38 @@
|
|||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let reconnectDelay = 1000;
|
let reconnectDelay = 1000;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
let staleSession = false;
|
||||||
|
// Non-scene phases use the same pinned Event Log column and fixed corner
|
||||||
|
// toggle that ScenePhase owns for the main play screen.
|
||||||
|
let phaseLogOpen = true;
|
||||||
|
|
||||||
|
function discardStaleSession(message) {
|
||||||
|
staleSession = true;
|
||||||
|
removeSession(gameId, playerId);
|
||||||
|
state = null;
|
||||||
|
error = message;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
if (ws) ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchState() {
|
async function fetchState() {
|
||||||
|
if (staleSession) return;
|
||||||
try {
|
try {
|
||||||
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
|
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
|
||||||
|
// Preserve the Game Over screen for players who were already present
|
||||||
|
// when play ended, but discard stale home-screen rejoin attempts.
|
||||||
|
if (!state && data.game.phase === 'ended') {
|
||||||
|
discardStaleSession('This game has ended and can no longer be rejoined.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
state = data;
|
state = data;
|
||||||
error = '';
|
error = '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +78,7 @@
|
|||||||
};
|
};
|
||||||
ws.onmessage = () => fetchState();
|
ws.onmessage = () => fetchState();
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (destroyed) return;
|
if (destroyed || staleSession) return;
|
||||||
reconnectTimer = setTimeout(connectSocket, reconnectDelay);
|
reconnectTimer = setTimeout(connectSocket, reconnectDelay);
|
||||||
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
||||||
};
|
};
|
||||||
@@ -185,31 +213,59 @@
|
|||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert alert-danger" style="margin: 20px;">
|
<div class="alert alert-danger" style="margin: 20px;">
|
||||||
Error connecting to game: {error}
|
Error connecting to game: {error}
|
||||||
|
{#if staleSession}
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="#/" class="btn btn-secondary btn-small">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if state}
|
{#if state}
|
||||||
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
||||||
{#if state.game.phase === 'lobby'}
|
{#if state.game.phase === 'scene'}
|
||||||
<LobbyPhase {state} />
|
|
||||||
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
|
|
||||||
<CharacterCreationPhase {state} />
|
|
||||||
{:else if state.game.phase === 'scene_setup'}
|
|
||||||
<SceneSetupPhase {state} />
|
|
||||||
{:else if state.game.phase === 'scene'}
|
|
||||||
<ScenePhase {state} />
|
<ScenePhase {state} />
|
||||||
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
|
||||||
<UpkeepPhase {state} />
|
|
||||||
{:else if state.game.phase === 'recruit_creation'}
|
|
||||||
<RecruitPhase {state} />
|
|
||||||
{:else if state.game.phase === 'ended'}
|
|
||||||
<GameOverPhase {state} />
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
|
||||||
|
<CrewSidebar {state} />
|
||||||
|
<main class="phase-main-column">
|
||||||
|
{#if state.game.phase === 'lobby'}
|
||||||
|
<LobbyPhase {state} />
|
||||||
|
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
|
||||||
|
<CharacterCreationPhase {state} />
|
||||||
|
{:else if state.game.phase === 'scene_setup'}
|
||||||
|
<SceneSetupPhase {state} />
|
||||||
|
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
||||||
|
<UpkeepPhase {state} />
|
||||||
|
{:else if state.game.phase === 'recruit_creation'}
|
||||||
|
<RecruitPhase {state} />
|
||||||
|
{:else if state.game.phase === 'ended'}
|
||||||
|
<GameOverPhase {state} />
|
||||||
|
{:else}
|
||||||
|
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
{#if phaseLogOpen}
|
||||||
|
<div class="log-column">
|
||||||
|
<EventLog {state} inline on:collapse={() => (phaseLogOpen = false)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EventLog {state} />
|
{#if state.game.phase !== 'scene'}
|
||||||
|
<button
|
||||||
|
class="log-reopen-btn"
|
||||||
|
title={phaseLogOpen ? 'Hide the event log' : 'Show the event log'}
|
||||||
|
on:click={() => (phaseLogOpen = !phaseLogOpen)}
|
||||||
|
>
|
||||||
|
{phaseLogOpen ? '✕ Close Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<NameModal {state} />
|
||||||
|
<GatModal {state} />
|
||||||
|
<RankBonusModal {state} />
|
||||||
{:else if !error}
|
{:else if !error}
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<p>Loading game state...</p>
|
<p>Loading game state...</p>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
let crewName = '';
|
let crewName = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
let creating = false;
|
let creating = false;
|
||||||
|
let joinCode = '';
|
||||||
|
let joinCodeError = '';
|
||||||
|
let joiningByCode = false;
|
||||||
|
|
||||||
function sessionRat(s) {
|
function sessionRat(s) {
|
||||||
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
|
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
|
||||||
@@ -53,6 +56,26 @@
|
|||||||
creating = false;
|
creating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateJoinCode(event) {
|
||||||
|
joinCode = event.currentTarget.value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, '')
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinByCode() {
|
||||||
|
if (joinCode.length !== 5) return;
|
||||||
|
joiningByCode = true;
|
||||||
|
joinCodeError = '';
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/game/code/${joinCode}`);
|
||||||
|
push(`/game/${data.id}/join`);
|
||||||
|
} catch (err) {
|
||||||
|
joinCodeError = err.message;
|
||||||
|
joiningByCode = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="welcome-container">
|
<div class="welcome-container">
|
||||||
@@ -63,33 +86,6 @@
|
|||||||
<div class="hero-flourish" aria-hidden="true">🐀 ⚓ 🐀</div>
|
<div class="hero-flourish" aria-hidden="true">🐀 ⚓ 🐀</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="glass-panel creation-card">
|
|
||||||
<h2>Muster a New Crew</h2>
|
|
||||||
<form on:submit|preventDefault={createGame}>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="crewName">Crew Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="crewName"
|
|
||||||
bind:value={crewName}
|
|
||||||
required
|
|
||||||
placeholder="e.g. The Salty Dogs"
|
|
||||||
class="input-field input-large"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="alert alert-danger">{error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
|
|
||||||
{creating ? 'Creating...' : 'Create Game'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if sessions.length > 0}
|
{#if sessions.length > 0}
|
||||||
<div class="glass-panel creation-card saved-sessions">
|
<div class="glass-panel creation-card saved-sessions">
|
||||||
<h2>Rejoin a Crew</h2>
|
<h2>Rejoin a Crew</h2>
|
||||||
@@ -118,12 +114,92 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="glass-panel creation-card join-code-card">
|
||||||
|
<h2>Join a Crew</h2>
|
||||||
|
<p class="info-text">Enter the five-character code shared by your game’s Admin.</p>
|
||||||
|
<form class="join-code-form" on:submit|preventDefault={joinByCode}>
|
||||||
|
<label for="joinCode">Join Code</label>
|
||||||
|
<div class="join-code-action">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="joinCode"
|
||||||
|
value={joinCode}
|
||||||
|
on:input={updateJoinCode}
|
||||||
|
required
|
||||||
|
minlength="5"
|
||||||
|
maxlength="5"
|
||||||
|
placeholder="ABCDE"
|
||||||
|
class="input-field input-large join-code-input"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="characters"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary btn-large" disabled={joiningByCode || joinCode.length !== 5}>
|
||||||
|
{joiningByCode ? 'Joining...' : 'Join'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if joinCodeError}
|
||||||
|
<div class="alert alert-danger mt-4">{joinCodeError}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-panel creation-card">
|
||||||
|
<h2>Muster a New Crew</h2>
|
||||||
|
<form on:submit|preventDefault={createGame}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="crewName">Crew Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="crewName"
|
||||||
|
bind:value={crewName}
|
||||||
|
required
|
||||||
|
placeholder="e.g. The Salty Dogs"
|
||||||
|
class="input-field input-large"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
|
||||||
|
{creating ? 'Creating...' : 'Create Game'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="welcome-links">
|
<div class="welcome-links">
|
||||||
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.session-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -6,12 +6,18 @@
|
|||||||
import { saveSession } from '../lib/sessions';
|
import { saveSession } from '../lib/sessions';
|
||||||
export let params = {};
|
export let params = {};
|
||||||
|
|
||||||
onMount(() => setPageTitle('Join the Crew'));
|
const PLAYER_NAME_KEY = 'pirats-player-name';
|
||||||
|
|
||||||
let gameId = params.id;
|
let gameId = params.id;
|
||||||
let playerName = '';
|
let playerName = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
let joining = false;
|
let joining = false;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setPageTitle('Join the Crew');
|
||||||
|
playerName = localStorage.getItem(PLAYER_NAME_KEY) || '';
|
||||||
|
});
|
||||||
|
|
||||||
// We can extract ?creator=true from $querystring if needed,
|
// We can extract ?creator=true from $querystring if needed,
|
||||||
// though the backend determines creator based on if they are the first player.
|
// though the backend determines creator based on if they are the first player.
|
||||||
// For UI purposes, we could show "You are the creator" if we want.
|
// For UI purposes, we could show "You are the creator" if we want.
|
||||||
@@ -22,6 +28,7 @@
|
|||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
||||||
|
localStorage.setItem(PLAYER_NAME_KEY, playerName.trim());
|
||||||
// Remember this session so the home page can offer a rejoin link;
|
// Remember this session so the home page can offer a rejoin link;
|
||||||
// the dashboard enriches it with crew/rat names once state loads.
|
// the dashboard enriches it with crew/rat names once state loads.
|
||||||
saveSession({ gameId, playerId: data.id, playerName });
|
saveSession({ gameId, playerId: data.id, playerName });
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from .models import Game, Player, GameEvent
|
from .models import Game, Player, GameEvent, new_join_code
|
||||||
from . import cards
|
from . import cards
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
FACE_VALUES = ("J", "Q", "K")
|
FACE_VALUES = ("J", "Q", "K")
|
||||||
|
|
||||||
# --- Card and Hand Utilities ---
|
# --- Card and Hand Utilities ---
|
||||||
@@ -111,10 +114,6 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player], capt
|
|||||||
return base_size + 1
|
return base_size + 1
|
||||||
return base_size
|
return base_size
|
||||||
|
|
||||||
def is_player_captain(player: Player, game: Game) -> bool:
|
|
||||||
"""The Captain is a persistent position tracked on the Game (set once the crew steals a ship)."""
|
|
||||||
return game.captain_player_id is not None and game.captain_player_id == player.id
|
|
||||||
|
|
||||||
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict:
|
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict:
|
||||||
"""Snapshot every player's max hand size, for detecting increases after a Rank/Captain change."""
|
"""Snapshot every player's max hand size, for detecting increases after a Rank/Captain change."""
|
||||||
return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
|
return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
|
||||||
@@ -139,6 +138,18 @@ def change_player_rank(db: Session, game: Game, player: Player, delta: int):
|
|||||||
db.commit()
|
db.commit()
|
||||||
apply_hand_increases(db, game, old_maxes)
|
apply_hand_increases(db, game, old_maxes)
|
||||||
|
|
||||||
|
def is_eligible_nominee(voter, nominee) -> bool:
|
||||||
|
"""A rank-up nominee must be another living, in-play Pi-Rat. Excludes the
|
||||||
|
voter, previous-scene Deep players, and dead / awaiting-recruit players
|
||||||
|
(ranking them up is wasted — recruits reset to Rank 1). Shared by the
|
||||||
|
between-scenes rank vote and the 3rd-objective story bonus."""
|
||||||
|
return (
|
||||||
|
nominee.id != voter.id
|
||||||
|
and nominee.role != "deep"
|
||||||
|
and not nominee.is_dead
|
||||||
|
and not nominee.needs_reroll
|
||||||
|
)
|
||||||
|
|
||||||
def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""):
|
def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""):
|
||||||
"""Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
|
"""Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
|
||||||
if game.captain_player_id == player_id:
|
if game.captain_player_id == player_id:
|
||||||
@@ -242,7 +253,16 @@ def has_min_players(game: Game) -> bool:
|
|||||||
|
|
||||||
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
||||||
deck = cards.get_fresh_deck()
|
deck = cards.get_fresh_deck()
|
||||||
|
join_code = ""
|
||||||
|
for _ in range(100):
|
||||||
|
candidate = new_join_code()
|
||||||
|
if not db.exec(select(Game).where(Game.join_code == candidate)).first():
|
||||||
|
join_code = candidate
|
||||||
|
break
|
||||||
|
if not join_code:
|
||||||
|
raise RuntimeError("Could not allocate a unique join code")
|
||||||
game = Game(
|
game = Game(
|
||||||
|
join_code=join_code,
|
||||||
deck_cards=json.dumps(deck),
|
deck_cards=json.dumps(deck),
|
||||||
phase="lobby",
|
phase="lobby",
|
||||||
current_scene_number=1,
|
current_scene_number=1,
|
||||||
@@ -253,11 +273,15 @@ def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
|||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(game)
|
db.refresh(game)
|
||||||
|
logger.info("Game %s created (crew_name=%r, dev_mode=%s)", game.id, crew_name, game.dev_mode)
|
||||||
return game
|
return game
|
||||||
|
|
||||||
def get_game(db: Session, game_id: str) -> Optional[Game]:
|
def get_game(db: Session, game_id: str) -> Optional[Game]:
|
||||||
return db.get(Game, game_id)
|
return db.get(Game, game_id)
|
||||||
|
|
||||||
|
def get_game_by_join_code(db: Session, join_code: str) -> Optional[Game]:
|
||||||
|
return db.exec(select(Game).where(Game.join_code == join_code)).first()
|
||||||
|
|
||||||
def get_player(db: Session, player_id: str) -> Optional[Player]:
|
def get_player(db: Session, player_id: str) -> Optional[Player]:
|
||||||
return db.get(Player, player_id)
|
return db.get(Player, player_id)
|
||||||
|
|
||||||
@@ -300,6 +324,10 @@ def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -
|
|||||||
msg += " They'll create their Pi-Rat with the crew between scenes."
|
msg += " They'll create their Pi-Rat with the crew between scenes."
|
||||||
add_game_event(db, game_id, msg, kind="join")
|
add_game_event(db, game_id, msg, kind="join")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Player %s (%r) joined game %s (phase=%s, creator=%s)",
|
||||||
|
player.id, name, game_id, game.phase if game else "?", is_creator,
|
||||||
|
)
|
||||||
return player
|
return player
|
||||||
|
|
||||||
def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
|
def add_game_event(db: Session, game_id: str, message: str, kind: str = "info"):
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ def create_challenge(
|
|||||||
deep_player_id: str,
|
deep_player_id: str,
|
||||||
target_player_id: str,
|
target_player_id: str,
|
||||||
obstacle_ids: List[str],
|
obstacle_ids: List[str],
|
||||||
stakes: str = ""
|
stakes_success: str = "",
|
||||||
|
stakes_failure: str = ""
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat."""
|
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat."""
|
||||||
game = get_game(db, game_id)
|
game = get_game(db, game_id)
|
||||||
@@ -74,15 +75,20 @@ def create_challenge(
|
|||||||
acting_player_id=target.id,
|
acting_player_id=target.id,
|
||||||
challenger_player_id=deep_player.id,
|
challenger_player_id=deep_player.id,
|
||||||
obstacle_ids=json.dumps(obstacle_ids),
|
obstacle_ids=json.dumps(obstacle_ids),
|
||||||
stakes=stakes.strip(),
|
stakes_success=stakes_success.strip(),
|
||||||
|
stakes_failure=stakes_failure.strip(),
|
||||||
)
|
)
|
||||||
db.add(challenge)
|
db.add(challenge)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids)
|
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids)
|
||||||
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
|
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
|
||||||
if stakes.strip():
|
success_text = stakes_success.strip()
|
||||||
msg += f" Stakes: {stakes.strip()}"
|
failure_text = stakes_failure.strip()
|
||||||
|
if success_text:
|
||||||
|
msg += f" On success: {success_text}"
|
||||||
|
if failure_text:
|
||||||
|
msg += f" On failure: {failure_text}"
|
||||||
add_game_event(db, game.id, msg, kind="challenge")
|
add_game_event(db, game.id, msg, kind="challenge")
|
||||||
return True, "Challenge called!"
|
return True, "Challenge called!"
|
||||||
|
|
||||||
@@ -207,6 +213,9 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
|
|||||||
if challenge.tax_type == "gat":
|
if challenge.tax_type == "gat":
|
||||||
acting.completed_personal_1 = False
|
acting.completed_personal_1 = False
|
||||||
refuser.completed_personal_1 = True
|
refuser.completed_personal_1 = True
|
||||||
|
# The Gat (and its description) returns to its owner.
|
||||||
|
refuser.gat_description = acting.gat_description
|
||||||
|
acting.gat_description = ""
|
||||||
else:
|
else:
|
||||||
# Return the stolen name string; the failed thief reverts to
|
# Return the stolen name string; the failed thief reverts to
|
||||||
# their smell-based recruit identity.
|
# their smell-based recruit identity.
|
||||||
@@ -338,6 +347,9 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
|
|||||||
if challenge.tax_type == "gat":
|
if challenge.tax_type == "gat":
|
||||||
requester.completed_personal_1 = True
|
requester.completed_personal_1 = True
|
||||||
responder.completed_personal_1 = False
|
responder.completed_personal_1 = False
|
||||||
|
# The Gat changes hands along with its description (like a stolen Name).
|
||||||
|
requester.gat_description = responder.gat_description
|
||||||
|
responder.gat_description = ""
|
||||||
event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!"
|
event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!"
|
||||||
else:
|
else:
|
||||||
# The Name itself is stolen: the requester literally takes the responder's
|
# The Name itself is stolen: the requester literally takes the responder's
|
||||||
|
|||||||
@@ -435,6 +435,14 @@ def maybe_finish_assign_techniques(db: Session, game: Game):
|
|||||||
# 1. Randomly assign starting ranks
|
# 1. Randomly assign starting ranks
|
||||||
random.shuffle(players_list)
|
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 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."
|
# "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."
|
# "All remaining Players start at Rank 2. These Players may choose to play either during the first scene."
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
import random
|
import logging
|
||||||
from typing import Tuple, Dict, Any
|
from typing import Tuple, Dict, Any
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .models import Game, Player, Obstacle
|
from .models import Game, Player, Obstacle
|
||||||
@@ -7,12 +7,21 @@ from . import cards
|
|||||||
from .crud_base import (
|
from .crud_base import (
|
||||||
get_player, get_game, get_player_hand, set_player_hand,
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
||||||
change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS
|
change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS,
|
||||||
|
is_eligible_nominee
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# --- Scene Setup Operations ---
|
# --- Scene Setup Operations ---
|
||||||
|
|
||||||
|
VALID_ROLES = ("pirat", "deep")
|
||||||
|
|
||||||
def update_scene_role(db: Session, player_id: str, role: str):
|
def update_scene_role(db: Session, player_id: str, role: str):
|
||||||
|
# Constrain to the two real roles so the endpoint can't stash an arbitrary
|
||||||
|
# string in the column (defensive: it only ever drives gameplay branches).
|
||||||
|
if role not in VALID_ROLES:
|
||||||
|
return
|
||||||
player = get_player(db, player_id)
|
player = get_player(db, player_id)
|
||||||
if not player:
|
if not player:
|
||||||
return
|
return
|
||||||
@@ -305,33 +314,6 @@ def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) ->
|
|||||||
|
|
||||||
def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool):
|
def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool):
|
||||||
"""Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly."""
|
"""Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly."""
|
||||||
if obj_type.startswith("crew_"):
|
|
||||||
game = get_game(db, game_id)
|
|
||||||
if not game: return
|
|
||||||
if obj_type == "crew_1":
|
|
||||||
game.completed_crew_1 = status
|
|
||||||
db.add(game)
|
|
||||||
db.commit()
|
|
||||||
if status and not game.captain_player_id:
|
|
||||||
# "Once the crew controls a ship, the highest-Ranked Pi-Rat becomes the de facto Captain."
|
|
||||||
pi_rats = [p for p in game.players if p.role != "deep" and not p.is_dead and not p.is_ghost]
|
|
||||||
if pi_rats:
|
|
||||||
max_rank = max(p.rank for p in pi_rats)
|
|
||||||
candidates = [p for p in pi_rats if p.rank == max_rank]
|
|
||||||
set_captain(db, game, random.choice(candidates).id, "Highest-ranked Pi-Rat aboard the stolen ship")
|
|
||||||
elif not status and game.captain_player_id:
|
|
||||||
# The ship is lost: the Captain's position goes with it.
|
|
||||||
set_captain(db, game, None, "The ship was lost")
|
|
||||||
elif obj_type == "crew_2":
|
|
||||||
game.completed_crew_2 = status
|
|
||||||
db.add(game)
|
|
||||||
db.commit()
|
|
||||||
elif obj_type == "crew_3":
|
|
||||||
game.completed_crew_3 = status
|
|
||||||
db.add(game)
|
|
||||||
db.commit()
|
|
||||||
return
|
|
||||||
|
|
||||||
player = get_player(db, target_id)
|
player = get_player(db, target_id)
|
||||||
if not player:
|
if not player:
|
||||||
return
|
return
|
||||||
@@ -339,12 +321,34 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
|||||||
if not game:
|
if not game:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if obj_type.startswith("crew_"):
|
||||||
|
if obj_type == "crew_1":
|
||||||
|
# Stealing a ship no longer auto-anoints a Captain — the crew has to
|
||||||
|
# earn and choose one themselves (Crew Objective 2 + the Captaincy UI).
|
||||||
|
game.completed_crew_1 = status
|
||||||
|
elif obj_type == "crew_2":
|
||||||
|
game.completed_crew_2 = status
|
||||||
|
elif obj_type == "crew_3":
|
||||||
|
game.completed_crew_3 = status
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
obj_name = obj_type.replace('_', ' ').title()
|
||||||
|
status_text = "completed" if status else "unmarked"
|
||||||
|
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
rank_diff = 0
|
rank_diff = 0
|
||||||
if obj_type == "personal_1":
|
if obj_type == "personal_1":
|
||||||
if status and not player.completed_personal_1:
|
if status and not player.completed_personal_1:
|
||||||
rank_diff = 1
|
rank_diff = 1
|
||||||
|
player.needs_gat_description = True
|
||||||
elif not status and player.completed_personal_1:
|
elif not status and player.completed_personal_1:
|
||||||
rank_diff = -1
|
rank_diff = -1
|
||||||
|
player.needs_gat_description = False
|
||||||
|
player.gat_description = ""
|
||||||
player.completed_personal_1 = status
|
player.completed_personal_1 = status
|
||||||
|
|
||||||
elif obj_type == "personal_2":
|
elif obj_type == "personal_2":
|
||||||
@@ -379,6 +383,25 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
|||||||
status_text = "completed" if status else "unmarked"
|
status_text = "completed" if status else "unmarked"
|
||||||
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
|
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.", kind="objective")
|
||||||
|
|
||||||
|
def grant_story_bonus_rank(db: Session, game: Game, granter: Player, target):
|
||||||
|
"""A Pi-Rat who finished their story (3rd Personal Objective) grants another
|
||||||
|
eligible Pi-Rat +1 Rank. `target=None` forfeits the bonus (e.g. no eligible
|
||||||
|
crewmate); either way the needs_rank_3_bonus prompt clears. Naming an
|
||||||
|
ineligible target is refused so the prompt stays up rather than waste the Rank."""
|
||||||
|
if not granter.needs_rank_3_bonus:
|
||||||
|
return False, "There is no story Rank bonus to grant."
|
||||||
|
if target is not None and not is_eligible_nominee(granter, target):
|
||||||
|
return False, "Pick a living crewmate who isn't this scene's Deep."
|
||||||
|
if target is not None:
|
||||||
|
change_player_rank(db, game, target, 1)
|
||||||
|
add_game_event(db, game.id, f"{granter.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.", kind="rank")
|
||||||
|
else:
|
||||||
|
add_game_event(db, game.id, f"{granter.name} completed their story, but had no crewmate to pass a Rank to.", kind="rank")
|
||||||
|
granter.needs_rank_3_bonus = False
|
||||||
|
db.add(granter)
|
||||||
|
db.commit()
|
||||||
|
return True, "Rank granted."
|
||||||
|
|
||||||
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
||||||
obstacle = db.get(Obstacle, obstacle_id)
|
obstacle = db.get(Obstacle, obstacle_id)
|
||||||
if obstacle:
|
if obstacle:
|
||||||
@@ -395,3 +418,4 @@ def finish_game(db: Session, game_id: str):
|
|||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉", kind="victory")
|
add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉", kind="victory")
|
||||||
|
logger.info("Game %s ended after %s scene(s)", game_id, game.current_scene_number)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import random
|
import random
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -5,21 +6,12 @@ from .models import Game, Player, Vote
|
|||||||
from .crud_base import (
|
from .crud_base import (
|
||||||
get_player, get_game, get_player_hand, set_player_hand,
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
|
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
|
||||||
calculate_max_hand_size, change_player_rank, set_captain
|
calculate_max_hand_size, change_player_rank, set_captain, is_eligible_nominee
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Between Scenes Operations ---
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def is_eligible_nominee(voter, nominee) -> bool:
|
# --- Between Scenes Operations ---
|
||||||
"""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)."""
|
|
||||||
return (
|
|
||||||
nominee.id != voter.id
|
|
||||||
and nominee.role != "deep"
|
|
||||||
and not nominee.is_dead
|
|
||||||
and not nominee.needs_reroll
|
|
||||||
)
|
|
||||||
|
|
||||||
def eligible_vote_nominees(game: Game, voter) -> list:
|
def eligible_vote_nominees(game: Game, voter) -> list:
|
||||||
"""The crewmates `voter` may nominate to rank up. An empty list means the
|
"""The crewmates `voter` may nominate to rank up. An empty list means the
|
||||||
@@ -37,12 +29,8 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
|
|||||||
nominee = get_player(db, nominated_id)
|
nominee = get_player(db, nominated_id)
|
||||||
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
|
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
|
||||||
return False, "Player not found."
|
return False, "Player not found."
|
||||||
if voter_id == nominated_id:
|
if not is_eligible_nominee(voter, nominee):
|
||||||
return False, "You cannot nominate yourself. Nice try."
|
return False, "You can only nominate a living crewmate who isn't this scene's Deep — and not yourself."
|
||||||
if nominee.role == "deep":
|
|
||||||
return False, "Deep Players from the previous scene cannot be nominated."
|
|
||||||
if nominee.is_dead or nominee.needs_reroll:
|
|
||||||
return False, "You can't nominate a Pi-Rat who's out of play. Pick a living crewmate."
|
|
||||||
|
|
||||||
# Remove any existing vote by this voter for this game
|
# Remove any existing vote by this voter for this game
|
||||||
existing = db.exec(
|
existing = db.exec(
|
||||||
@@ -61,21 +49,43 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
|
|||||||
db.commit()
|
db.commit()
|
||||||
return True, "Vote submitted."
|
return True, "Vote submitted."
|
||||||
|
|
||||||
|
def unchallenged_pirats(game: Game):
|
||||||
|
"""Living Pi-Rats who have not yet been the target of a Deep Challenge this scene."""
|
||||||
|
challenged_ids = {c.target_player_id for c in game.challenges if c.challenge_type == "deep"}
|
||||||
|
return [p for p in game.players
|
||||||
|
if p.role == "pirat" and not p.is_dead and p.id not in challenged_ids]
|
||||||
|
|
||||||
def end_scene_and_transition(db: Session, game_id: str):
|
def end_scene_and_transition(db: Session, game_id: str):
|
||||||
"""Moves game phase to between_scenes."""
|
"""Moves game phase to between_scenes.
|
||||||
|
|
||||||
|
Unless Dev Mode is on, the scene can't end until every living Pi-Rat has
|
||||||
|
faced at least one Deep Challenge, or the Obstacle List has been cleared.
|
||||||
|
(Whether each Pi-Rat had a chance at their Objective is left to table talk.)
|
||||||
|
"""
|
||||||
game = get_game(db, game_id)
|
game = get_game(db, game_id)
|
||||||
if not game:
|
if not game:
|
||||||
return
|
return False, "Game not found."
|
||||||
|
|
||||||
|
if not game.dev_mode and game.obstacles:
|
||||||
|
waiting = unchallenged_pirats(game)
|
||||||
|
if waiting:
|
||||||
|
names = ", ".join(p.name for p in waiting)
|
||||||
|
return False, (
|
||||||
|
f"Every Pi-Rat must face a Challenge before the scene can end "
|
||||||
|
f"(still waiting on: {names}). Clear the Obstacle List to end early."
|
||||||
|
)
|
||||||
|
|
||||||
game.phase = "between_scenes"
|
game.phase = "between_scenes"
|
||||||
|
|
||||||
# Clear ready flags for players
|
# Clear ready flags for players
|
||||||
for p in game.players:
|
for p in game.players:
|
||||||
p.is_ready = False
|
p.is_ready = False
|
||||||
db.add(p)
|
db.add(p)
|
||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
|
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
|
||||||
|
return True, "Scene ended."
|
||||||
|
|
||||||
def process_between_scenes_votes(db: Session, game: Game):
|
def process_between_scenes_votes(db: Session, game: Game):
|
||||||
"""
|
"""
|
||||||
@@ -83,24 +93,30 @@ def process_between_scenes_votes(db: Session, game: Game):
|
|||||||
Then sets players who were Deep to discard and redraw.
|
Then sets players who were Deep to discard and redraw.
|
||||||
"""
|
"""
|
||||||
votes = game.votes
|
votes = game.votes
|
||||||
|
# Reset the previous scene's winner before tallying this one.
|
||||||
|
game.last_rank_up_player_id = None
|
||||||
|
db.add(game)
|
||||||
if not votes:
|
if not votes:
|
||||||
|
db.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Count votes
|
# Count votes
|
||||||
counts = {}
|
counts = {}
|
||||||
for v in votes:
|
for v in votes:
|
||||||
counts[v.nominated_player_id] = counts.get(v.nominated_player_id, 0) + 1
|
counts[v.nominated_player_id] = counts.get(v.nominated_player_id, 0) + 1
|
||||||
|
|
||||||
if counts:
|
if counts:
|
||||||
max_votes = max(counts.values())
|
max_votes = max(counts.values())
|
||||||
winners = [pid for pid, val in counts.items() if val == max_votes]
|
winners = [pid for pid, val in counts.items() if val == max_votes]
|
||||||
|
|
||||||
# If there is a clear winner (or random in case of tie)
|
# If there is a clear winner (or random in case of tie)
|
||||||
if winners:
|
if winners:
|
||||||
winner_id = random.choice(winners)
|
winner_id = random.choice(winners)
|
||||||
winner = get_player(db, winner_id)
|
winner = get_player(db, winner_id)
|
||||||
if winner:
|
if winner:
|
||||||
change_player_rank(db, game, winner, 1)
|
change_player_rank(db, game, winner, 1)
|
||||||
|
game.last_rank_up_player_id = winner.id
|
||||||
|
db.add(game)
|
||||||
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank")
|
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.", kind="rank")
|
||||||
|
|
||||||
# Clear all votes
|
# Clear all votes
|
||||||
@@ -112,6 +128,7 @@ def begin_next_scene_setup(db: Session, game: Game):
|
|||||||
"""Advances to the next scene's setup: bump the scene counter and clear roles."""
|
"""Advances to the next scene's setup: bump the scene counter and clear roles."""
|
||||||
game.current_scene_number += 1
|
game.current_scene_number += 1
|
||||||
game.phase = "scene_setup"
|
game.phase = "scene_setup"
|
||||||
|
game.last_rank_up_player_id = None # Stale once we leave the post-voting screen
|
||||||
for p in game.players:
|
for p in game.players:
|
||||||
p.role = None
|
p.role = None
|
||||||
p.is_ready = False
|
p.is_ready = False
|
||||||
@@ -238,6 +255,7 @@ def _remove_player(db: Session, game: Game, target: Player, event_message: str,
|
|||||||
phase's completion gate so a departed blocker can't stall the table."""
|
phase's completion gate so a departed blocker can't stall the table."""
|
||||||
from . import crud_character as cc
|
from . import crud_character as cc
|
||||||
target_id = target.id
|
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:
|
if game.captain_player_id == target_id:
|
||||||
set_captain(db, game, None, reason=captain_reason)
|
set_captain(db, game, None, reason=captain_reason)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
|
||||||
@@ -6,13 +8,17 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
import logging
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import crud
|
from . import crud
|
||||||
|
from . import maintenance
|
||||||
from .database import run_migrations, get_session, engine
|
from .database import run_migrations, get_session, engine
|
||||||
|
from .validation import sanitize_name
|
||||||
from .ws import manager
|
from .ws import manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -21,11 +27,156 @@ EVENT_PAGE_SIZE = 50
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
_logging_configured = False
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
"""Set up backend logging: always to stderr, plus a rotating file when
|
||||||
|
PIRATS_LOG_FILE is set (the NixOS service points it at /var/log/pirats/).
|
||||||
|
Level comes from PIRATS_LOG_LEVEL (default INFO). Idempotent so it's safe to
|
||||||
|
call from both the CLI entrypoint and the app's startup lifespan."""
|
||||||
|
global _logging_configured
|
||||||
|
if _logging_configured:
|
||||||
|
return
|
||||||
|
_logging_configured = True
|
||||||
|
|
||||||
|
level_name = os.environ.get("PIRATS_LOG_LEVEL", "INFO").upper()
|
||||||
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level)
|
||||||
|
fmt = logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
stderr_handler = logging.StreamHandler(sys.stderr)
|
||||||
|
stderr_handler.setFormatter(fmt)
|
||||||
|
root.addHandler(stderr_handler)
|
||||||
|
|
||||||
|
log_file = os.environ.get("PIRATS_LOG_FILE")
|
||||||
|
if log_file:
|
||||||
|
try:
|
||||||
|
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_handler = RotatingFileHandler(
|
||||||
|
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(fmt)
|
||||||
|
root.addHandler(file_handler)
|
||||||
|
logger.info("Logging to %s", log_file)
|
||||||
|
except OSError:
|
||||||
|
# A bad/unwritable path mustn't take the server down — keep stderr.
|
||||||
|
logger.exception("Could not open log file %s; logging to stderr only", log_file)
|
||||||
|
|
||||||
|
DEFAULT_MAX_BODY_BYTES = 1024 * 1024 # 1 MiB — far above any legitimate form post
|
||||||
|
|
||||||
|
def max_body_bytes() -> int:
|
||||||
|
"""Largest request body to accept, from PIRATS_MAX_BODY_BYTES (default 1 MiB)."""
|
||||||
|
val = os.environ.get("PIRATS_MAX_BODY_BYTES")
|
||||||
|
if val is None:
|
||||||
|
return DEFAULT_MAX_BODY_BYTES
|
||||||
|
try:
|
||||||
|
return max(int(val), 0)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid PIRATS_MAX_BODY_BYTES=%r; using default", val)
|
||||||
|
return DEFAULT_MAX_BODY_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
class _BodyTooLarge(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LimitRequestBodyMiddleware:
|
||||||
|
"""Reject request bodies larger than `max_bytes` with HTTP 413 before they are
|
||||||
|
buffered into memory. The app is on the open internet, so this caps the damage
|
||||||
|
a single oversized POST can do. The declared Content-Length is checked up front
|
||||||
|
(the common case); the bytes actually streamed are then counted too, so a
|
||||||
|
chunked or length-omitting client can't slip past the header check."""
|
||||||
|
|
||||||
|
def __init__(self, app, max_bytes: int):
|
||||||
|
self.app = app
|
||||||
|
self.max_bytes = max_bytes
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
for name, value in scope.get("headers", []):
|
||||||
|
if name == b"content-length":
|
||||||
|
try:
|
||||||
|
declared = int(value)
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
if declared > self.max_bytes:
|
||||||
|
await self._send_413(send)
|
||||||
|
return
|
||||||
|
break
|
||||||
|
|
||||||
|
received = 0
|
||||||
|
response_started = False
|
||||||
|
|
||||||
|
async def capped_receive():
|
||||||
|
nonlocal received
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.request":
|
||||||
|
received += len(message.get("body", b""))
|
||||||
|
if received > self.max_bytes:
|
||||||
|
raise _BodyTooLarge()
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def watched_send(message):
|
||||||
|
nonlocal response_started
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
response_started = True
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.app(scope, capped_receive, watched_send)
|
||||||
|
except _BodyTooLarge:
|
||||||
|
# The handler reads the whole body before responding, so nothing has
|
||||||
|
# been sent yet; if a response somehow already started, we can only stop.
|
||||||
|
if not response_started:
|
||||||
|
await self._send_413(send)
|
||||||
|
|
||||||
|
async def _send_413(self, send):
|
||||||
|
body = b'{"error":"Request body too large."}'
|
||||||
|
await send({
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 413,
|
||||||
|
"headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(body)).encode()),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await send({"type": "http.response.body", "body": body})
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
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()
|
run_migrations()
|
||||||
|
logger.info("Database migrations applied")
|
||||||
|
|
||||||
|
purge_cfg = maintenance.purge_config()
|
||||||
|
purge_task = None
|
||||||
|
if purge_cfg.enabled:
|
||||||
|
purge_task = asyncio.create_task(maintenance.purge_loop(purge_cfg))
|
||||||
|
else:
|
||||||
|
logger.info("Game purge task disabled (PIRATS_PURGE_ENABLED)")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
if purge_task is not None:
|
||||||
|
purge_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await purge_task
|
||||||
|
logger.info("Shutting down")
|
||||||
|
|
||||||
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)
|
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)
|
||||||
api = APIRouter()
|
api = APIRouter()
|
||||||
|
|
||||||
@@ -64,6 +215,10 @@ async def broadcast_state_changes(request, call_next):
|
|||||||
await manager.broadcast(game_id, {"type": "state_changed"})
|
await manager.broadcast(game_id, {"type": "state_changed"})
|
||||||
return response
|
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")
|
@app.websocket("/api/game/{game_id}/ws")
|
||||||
async def game_websocket(websocket: WebSocket, game_id: str):
|
async def game_websocket(websocket: WebSocket, game_id: str):
|
||||||
await manager.connect(game_id, websocket)
|
await manager.connect(game_id, websocket)
|
||||||
@@ -83,10 +238,18 @@ def create_game_route(
|
|||||||
crew_name: str = Form(...),
|
crew_name: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
game = crud.create_game(db, crew_name=crew_name.strip())
|
game = crud.create_game(db, crew_name=sanitize_name(crew_name))
|
||||||
# admin_key is only revealed here, to the creator; the state endpoint hides it
|
# admin_key is only revealed here, to the creator; the state endpoint hides it
|
||||||
return {"id": game.id, "admin_key": game.admin_key}
|
return {"id": game.id, "admin_key": game.admin_key}
|
||||||
|
|
||||||
|
@api.get("/game/code/{join_code}")
|
||||||
|
def find_game_by_join_code(join_code: str, db: Session = Depends(get_session)):
|
||||||
|
normalized = join_code.strip().upper()
|
||||||
|
game = crud.get_game_by_join_code(db, normalized)
|
||||||
|
if not game or game.phase == "ended":
|
||||||
|
raise HTTPException(status_code=404, detail="No active game found for that join code")
|
||||||
|
return {"id": game.id}
|
||||||
|
|
||||||
@api.post("/game/{game_id}/join")
|
@api.post("/game/{game_id}/join")
|
||||||
def join_game_submit(
|
def join_game_submit(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
@@ -99,7 +262,7 @@ def join_game_submit(
|
|||||||
|
|
||||||
# First player is automatically the creator
|
# First player is automatically the creator
|
||||||
is_creator = len(game.players) == 0
|
is_creator = len(game.players) == 0
|
||||||
player = crud.add_player(db, game_id, name.strip(), is_creator=is_creator)
|
player = crud.add_player(db, game_id, sanitize_name(name), is_creator=is_creator)
|
||||||
return {"id": player.id}
|
return {"id": player.id}
|
||||||
|
|
||||||
@api.post("/game/{game_id}/player/{player_id}/leave")
|
@api.post("/game/{game_id}/player/{player_id}/leave")
|
||||||
@@ -189,6 +352,7 @@ if os.path.exists(static_dir):
|
|||||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
configure_logging()
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description="Run the Rats with Gats web app")
|
parser = argparse.ArgumentParser(description="Run the Rats with Gats web app")
|
||||||
parser.add_argument("--host", default="0.0.0.0", help="Host address to bind to")
|
parser.add_argument("--host", default="0.0.0.0", help="Host address to bind to")
|
||||||
|
|||||||
204
src/pirats/maintenance.py
Normal file
204
src/pirats/maintenance.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Periodic database maintenance: purge old finished/inactive games.
|
||||||
|
|
||||||
|
A game is purged when it either finished more than `finished_days` ago or has
|
||||||
|
had no activity for `inactive_days`. "Activity" is the timestamp of a game's
|
||||||
|
most recent GameEvent (every meaningful move records one); for an ended game
|
||||||
|
that last event is its finish, so the same signal serves both windows. Games
|
||||||
|
with no events at all are undatable and left alone.
|
||||||
|
|
||||||
|
The purge runs in-process on a background asyncio loop (started from the app
|
||||||
|
lifespan); all knobs are configurable via environment variables / the NixOS
|
||||||
|
service. Every purge is logged with the crew name, uuid and an estimate of the
|
||||||
|
bytes it held, and each run that removes anything VACUUMs the SQLite file and
|
||||||
|
logs the disk space actually reclaimed.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from .database import engine
|
||||||
|
from .models import Game, GameEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DAY_SECONDS = 86400.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PurgeConfig:
|
||||||
|
enabled: bool
|
||||||
|
interval_hours: float
|
||||||
|
finished_days: float
|
||||||
|
inactive_days: float
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
|
val = os.environ.get(name)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
return val.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _env_float(name: str, default: float) -> float:
|
||||||
|
val = os.environ.get(name)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid %s=%r; using default %s", name, val, default)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def purge_config() -> PurgeConfig:
|
||||||
|
"""Read purge settings from the environment (see README for the knobs)."""
|
||||||
|
return PurgeConfig(
|
||||||
|
enabled=_env_bool("PIRATS_PURGE_ENABLED", True),
|
||||||
|
interval_hours=_env_float("PIRATS_PURGE_INTERVAL_HOURS", 24.0),
|
||||||
|
finished_days=_env_float("PIRATS_PURGE_FINISHED_DAYS", 14.0),
|
||||||
|
inactive_days=_env_float("PIRATS_PURGE_INACTIVE_DAYS", 30.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_file_path() -> Optional[Path]:
|
||||||
|
"""The on-disk SQLite file, or None for in-memory / non-file databases."""
|
||||||
|
name = engine.url.database
|
||||||
|
if not name or name == ":memory:":
|
||||||
|
return None
|
||||||
|
return Path(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_game_bytes(game: Game) -> int:
|
||||||
|
"""A rough (uncompressed) byte footprint of a game and all its rows, summed
|
||||||
|
from each row's serialized columns. Reported per purge; the dominant term is
|
||||||
|
any leftover rollback checkpoints' state_json."""
|
||||||
|
total = len(json.dumps(game.model_dump(), default=str))
|
||||||
|
for rows in (game.players, game.obstacles, game.votes, game.events,
|
||||||
|
game.challenges, game.checkpoints):
|
||||||
|
for row in rows:
|
||||||
|
total += len(json.dumps(row.model_dump(), default=str))
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def purge_old_games(db: Session, *, finished_days: float, inactive_days: float,
|
||||||
|
now: Optional[float] = None) -> List[dict]:
|
||||||
|
"""Delete games that finished more than `finished_days` ago or have been
|
||||||
|
inactive for `inactive_days`. Returns a summary dict per purged game and
|
||||||
|
logs each one. Does not VACUUM (the caller does, once per run)."""
|
||||||
|
import time
|
||||||
|
if now is None:
|
||||||
|
now = time.time()
|
||||||
|
finished_cutoff = now - finished_days * DAY_SECONDS
|
||||||
|
inactive_cutoff = now - inactive_days * DAY_SECONDS
|
||||||
|
|
||||||
|
# Most-recent event timestamp per game, in one grouped query.
|
||||||
|
last_activity = {
|
||||||
|
gid: ts
|
||||||
|
for gid, ts in db.exec(
|
||||||
|
select(GameEvent.game_id, func.max(GameEvent.timestamp)).group_by(GameEvent.game_id)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
purged: List[dict] = []
|
||||||
|
for game in db.exec(select(Game)).all():
|
||||||
|
last = last_activity.get(game.id)
|
||||||
|
if last is None:
|
||||||
|
# No events ever recorded: we can't date it, so leave it be.
|
||||||
|
continue
|
||||||
|
finished = game.phase == "ended" and last < finished_cutoff
|
||||||
|
inactive = last < inactive_cutoff
|
||||||
|
if not (finished or inactive):
|
||||||
|
continue
|
||||||
|
|
||||||
|
est_bytes = _estimate_game_bytes(game)
|
||||||
|
age_days = (now - last) / DAY_SECONDS
|
||||||
|
reason = "finished" if finished else "inactive"
|
||||||
|
logger.info(
|
||||||
|
"Purging game %s (crew=%r, phase=%s, reason=%s, idle %.1f days, ~%d bytes)",
|
||||||
|
game.id, game.crew_name, game.phase, reason, age_days, est_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# _estimate_game_bytes loaded every relationship, so the ORM's
|
||||||
|
# cascade_delete (set on each Game relationship) deletes all the
|
||||||
|
# children along with the game — no DB-level FK pragma needed.
|
||||||
|
db.delete(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
purged.append({
|
||||||
|
"id": game.id,
|
||||||
|
"crew_name": game.crew_name,
|
||||||
|
"phase": game.phase,
|
||||||
|
"reason": reason,
|
||||||
|
"idle_days": age_days,
|
||||||
|
"estimated_bytes": est_bytes,
|
||||||
|
})
|
||||||
|
|
||||||
|
return purged
|
||||||
|
|
||||||
|
|
||||||
|
def _vacuum() -> None:
|
||||||
|
"""Reclaim the pages freed by the deletes. VACUUM cannot run inside a
|
||||||
|
transaction, hence the autocommit connection."""
|
||||||
|
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
|
||||||
|
conn.exec_driver_sql("VACUUM")
|
||||||
|
|
||||||
|
|
||||||
|
def run_purge(*, finished_days: float, inactive_days: float,
|
||||||
|
now: Optional[float] = None) -> List[dict]:
|
||||||
|
"""One full purge cycle: delete stale games, then VACUUM and report the
|
||||||
|
disk space actually reclaimed from the SQLite file."""
|
||||||
|
db_path = _db_file_path()
|
||||||
|
size_before = db_path.stat().st_size if db_path and db_path.exists() else None
|
||||||
|
|
||||||
|
with Session(engine) as db:
|
||||||
|
purged = purge_old_games(
|
||||||
|
db, finished_days=finished_days, inactive_days=inactive_days, now=now
|
||||||
|
)
|
||||||
|
|
||||||
|
if not purged:
|
||||||
|
logger.debug("Game purge: nothing to remove")
|
||||||
|
return purged
|
||||||
|
|
||||||
|
if db_path and db_path.exists():
|
||||||
|
_vacuum()
|
||||||
|
size_after = db_path.stat().st_size
|
||||||
|
reclaimed = (size_before - size_after) if size_before is not None else None
|
||||||
|
logger.info(
|
||||||
|
"Game purge removed %d game(s); reclaimed %s bytes from %s",
|
||||||
|
len(purged),
|
||||||
|
reclaimed if reclaimed is not None else "?",
|
||||||
|
db_path,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Game purge removed %d game(s)", len(purged))
|
||||||
|
return purged
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_loop(config: Optional[PurgeConfig] = None) -> None:
|
||||||
|
"""Background task: run a purge at startup, then every `interval_hours`."""
|
||||||
|
cfg = config or purge_config()
|
||||||
|
logger.info(
|
||||||
|
"Game purge task started: every %.1f h, finished > %.0f days, inactive > %.0f days",
|
||||||
|
cfg.interval_hours, cfg.finished_days, cfg.inactive_days,
|
||||||
|
)
|
||||||
|
interval_seconds = max(cfg.interval_hours, 0.0) * 3600.0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
await run_in_threadpool(
|
||||||
|
run_purge,
|
||||||
|
finished_days=cfg.finished_days,
|
||||||
|
inactive_days=cfg.inactive_days,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Game purge run failed")
|
||||||
|
await asyncio.sleep(interval_seconds)
|
||||||
@@ -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,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,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 ###
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import secrets
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from sqlmodel import Field, SQLModel, Relationship
|
from sqlmodel import Field, SQLModel, Relationship
|
||||||
|
|
||||||
|
JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def new_join_code() -> str:
|
||||||
|
return "".join(secrets.choice(JOIN_CODE_ALPHABET) for _ in range(5))
|
||||||
|
|
||||||
class Game(SQLModel, table=True):
|
class Game(SQLModel, table=True):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||||
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
join_code: str = Field(default_factory=new_join_code, index=True, unique=True)
|
||||||
crew_name: Optional[str] = Field(default=None)
|
crew_name: Optional[str] = Field(default=None)
|
||||||
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "assign_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "recruit_creation", "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
|
dev_mode: bool = Field(default=False) # Creator-toggled; reveals testing shortcuts (Suggest buttons, skip character creation) to the whole table
|
||||||
@@ -18,6 +26,8 @@ class Game(SQLModel, table=True):
|
|||||||
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
||||||
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
||||||
captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship
|
captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship
|
||||||
|
teaching_deep_player_id: Optional[str] = Field(default=None) # Teaching mode: an admin who seeds themselves as the forced Rank-3 Deep for the first scene (set in the lobby, honored when starting ranks are rolled)
|
||||||
|
last_rank_up_player_id: Optional[str] = Field(default=None) # Winner of the most recent rank-up vote, shown on the post-voting upkeep screen; cleared when the next scene begins
|
||||||
|
|
||||||
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
|
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True)
|
obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
@@ -66,8 +76,10 @@ class Player(SQLModel, table=True):
|
|||||||
completed_personal_2: bool = Field(default=False) # Earn a Name
|
completed_personal_2: bool = Field(default=False) # Earn a Name
|
||||||
completed_personal_3: bool = Field(default=False) # Die like a Pirate
|
completed_personal_3: bool = Field(default=False) # Die like a Pirate
|
||||||
|
|
||||||
|
gat_description: str = Field(default="") # Free-text description of the Pi-Rat's Gat, entered via a modal when they earn one
|
||||||
# States for Milestones & Death
|
# States for Milestones & Death
|
||||||
needs_name: bool = Field(default=False)
|
needs_name: bool = Field(default=False)
|
||||||
|
needs_gat_description: bool = Field(default=False) # Prompts the gat-description modal after earning a Gat objective
|
||||||
needs_rank_3_bonus: bool = Field(default=False)
|
needs_rank_3_bonus: bool = Field(default=False)
|
||||||
is_dead: bool = Field(default=False)
|
is_dead: bool = Field(default=False)
|
||||||
is_ghost: bool = Field(default=False)
|
is_ghost: bool = Field(default=False)
|
||||||
@@ -110,7 +122,9 @@ class Challenge(SQLModel, table=True):
|
|||||||
challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
|
challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
|
||||||
obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges)
|
obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges)
|
||||||
temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
|
temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
|
||||||
stakes: str = Field(default="")
|
stakes: str = Field(default="") # Deprecated single-field stakes; kept for old rollback-snapshot compatibility. New challenges use stakes_success/stakes_failure.
|
||||||
|
stakes_success: str = Field(default="") # What the Deep promises happens if the Pi-Rat beats the Challenge
|
||||||
|
stakes_failure: str = Field(default="") # What the Deep threatens happens if the Pi-Rat fails
|
||||||
plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
|
plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
|
||||||
status: str = Field(default="open") # "open", "succeeded", "failed"
|
status: str = Field(default="open") # "open", "succeeded", "failed"
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,36 @@ def set_dev_mode(
|
|||||||
crud.add_game_event(db, game.id, f"🛠 Dev Mode turned {'ON' if enabled else 'OFF'} by {player.name}.", kind="info")
|
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}
|
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
|
# Dev Mode shortcut: auto-complete character creation for everyone and move on
|
||||||
@router.post("/game/{game_id}/player/{player_id}/skip-character-creation")
|
@router.post("/game/{game_id}/player/{player_id}/skip-character-creation")
|
||||||
def skip_character_creation_route(
|
def skip_character_creation_route(
|
||||||
@@ -103,6 +133,7 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
|
|||||||
"game": {
|
"game": {
|
||||||
"id": game.id,
|
"id": game.id,
|
||||||
"crew_name": game.crew_name,
|
"crew_name": game.crew_name,
|
||||||
|
"join_code": game.join_code,
|
||||||
"phase": game.phase,
|
"phase": game.phase,
|
||||||
"admin_key": game.admin_key
|
"admin_key": game.admin_key
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
|
from .validation import sanitize_text
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ def create_challenge_route(
|
|||||||
player_id: str,
|
player_id: str,
|
||||||
target_player_id: str = Form(...),
|
target_player_id: str = Form(...),
|
||||||
obstacle_ids: str = Form(...), # JSON list of obstacle ids
|
obstacle_ids: str = Form(...), # JSON list of obstacle ids
|
||||||
stakes: str = Form(""),
|
stakes_success: str = Form(""),
|
||||||
|
stakes_failure: str = Form(""),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -22,7 +24,8 @@ def create_challenge_route(
|
|||||||
assert isinstance(ids, list)
|
assert isinstance(ids, list)
|
||||||
except Exception:
|
except Exception:
|
||||||
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
|
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
|
||||||
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes)
|
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids,
|
||||||
|
sanitize_text(stakes_success), sanitize_text(stakes_failure))
|
||||||
if not ok:
|
if not ok:
|
||||||
return JSONResponse({"error": msg}, status_code=400)
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlmodel import Session
|
|||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
from .cards import TECHNIQUE_SUGGESTIONS
|
from .cards import TECHNIQUE_SUGGESTIONS
|
||||||
|
from .validation import sanitize_text, sanitize_name
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -27,10 +28,10 @@ def player_save_basic_details(
|
|||||||
if not player:
|
if not player:
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
|
||||||
player.avatar_look = avatar_look.strip()
|
player.avatar_look = sanitize_text(avatar_look)
|
||||||
player.avatar_smell = avatar_smell.strip()
|
player.avatar_smell = sanitize_name(avatar_smell) # seeds the "Recruit {smell}" name
|
||||||
player.first_words = first_words.strip()
|
player.first_words = sanitize_text(first_words)
|
||||||
player.good_at_math = good_at_math.strip()
|
player.good_at_math = sanitize_text(good_at_math)
|
||||||
# Until a Pi-Rat earns a Name they are identified by their smell
|
# Until a Pi-Rat earns a Name they are identified by their smell
|
||||||
if not player.completed_personal_2:
|
if not player.completed_personal_2:
|
||||||
player.name = crud.recruit_name(player)
|
player.name = crud.recruit_name(player)
|
||||||
@@ -88,28 +89,6 @@ def player_delegate_question(
|
|||||||
crud.delegate_question(db, player_id, question_type, target_player_id)
|
crud.delegate_question(db, player_id, question_type, target_player_id)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Revoke a delegated question task
|
|
||||||
@router.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}")
|
|
||||||
def player_revoke_delegation(
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
question_type: str, # "like" or "hate"
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
|
||||||
if question_type == "like":
|
|
||||||
player.other_like_from_player_id = None
|
|
||||||
player.other_like = ""
|
|
||||||
elif question_type == "hate":
|
|
||||||
player.other_hate_from_player_id = None
|
|
||||||
player.other_hate = ""
|
|
||||||
db.add(player)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
# Answer a delegated question for another player
|
# Answer a delegated question for another player
|
||||||
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
||||||
def player_submit_delegated_answer(
|
def player_submit_delegated_answer(
|
||||||
@@ -120,7 +99,7 @@ def player_submit_delegated_answer(
|
|||||||
answer: str = Form(...),
|
answer: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id)
|
crud.submit_delegated_answer(db, target_player_id, question_type, sanitize_text(answer), player_id)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Submit 3 Secret Pirate Techniques
|
# Submit 3 Secret Pirate Techniques
|
||||||
@@ -133,7 +112,7 @@ def player_submit_techniques(
|
|||||||
tech3: str = Form(...),
|
tech3: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
error = crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip())
|
error = crud.submit_techniques(db, player_id, sanitize_text(tech1), sanitize_text(tech2), sanitize_text(tech3))
|
||||||
if error:
|
if error:
|
||||||
return JSONResponse({"error": error}, status_code=400)
|
return JSONResponse({"error": error}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@@ -256,7 +235,7 @@ def contribute_technique_route(
|
|||||||
text: str = Form(...),
|
text: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
ok, msg = crud.contribute_recruit_technique(db, player_id, recruit_id, slot, text)
|
ok, msg = crud.contribute_recruit_technique(db, player_id, recruit_id, slot, sanitize_text(text))
|
||||||
if not ok:
|
if not ok:
|
||||||
return JSONResponse({"error": msg}, status_code=400)
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlmodel import Session
|
|||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
from .cards import OBSTACLES_DATA
|
from .cards import OBSTACLES_DATA
|
||||||
|
from .validation import sanitize_text, sanitize_name
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -89,19 +90,27 @@ def toggle_objective_route(
|
|||||||
):
|
):
|
||||||
if type.startswith("crew_"):
|
if type.startswith("crew_"):
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
if not game: raise HTTPException(status_code=404)
|
if not game:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
current_status = False
|
current_status = False
|
||||||
if type == "crew_1": current_status = game.completed_crew_1
|
if type == "crew_1":
|
||||||
elif type == "crew_2": current_status = game.completed_crew_2
|
current_status = game.completed_crew_1
|
||||||
elif type == "crew_3": current_status = game.completed_crew_3
|
elif type == "crew_2":
|
||||||
|
current_status = game.completed_crew_2
|
||||||
|
elif type == "crew_3":
|
||||||
|
current_status = game.completed_crew_3
|
||||||
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
||||||
else:
|
else:
|
||||||
player = crud.get_player(db, player_id)
|
player = crud.get_player(db, player_id)
|
||||||
if not player: raise HTTPException(status_code=404, detail="Player not found")
|
if not player:
|
||||||
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
current_status = False
|
current_status = False
|
||||||
if type == "personal_1": current_status = player.completed_personal_1
|
if type == "personal_1":
|
||||||
elif type == "personal_2": current_status = player.completed_personal_2
|
current_status = player.completed_personal_1
|
||||||
elif type == "personal_3": current_status = player.completed_personal_3
|
elif type == "personal_2":
|
||||||
|
current_status = player.completed_personal_2
|
||||||
|
elif type == "personal_3":
|
||||||
|
current_status = player.completed_personal_3
|
||||||
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@@ -116,29 +125,45 @@ def set_name_route(
|
|||||||
):
|
):
|
||||||
player = crud.get_player(db, player_id)
|
player = crud.get_player(db, player_id)
|
||||||
if player and player.needs_name:
|
if player and player.needs_name:
|
||||||
player.name = new_name
|
player.name = sanitize_name(new_name)
|
||||||
player.needs_name = False
|
player.needs_name = False
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Pi-Rat bonus rank up for another player
|
# Pi-Rat describes the Gat they just earned
|
||||||
|
@router.post("/game/{game_id}/player/{player_id}/set-gat-description")
|
||||||
|
def set_gat_description_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
description: str = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
player = crud.get_player(db, player_id)
|
||||||
|
if player and player.needs_gat_description:
|
||||||
|
player.gat_description = sanitize_text(description)
|
||||||
|
player.needs_gat_description = False
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# A Pi-Rat who finished their story (3rd Personal Objective) grants another Pi-Rat a
|
||||||
|
# bonus Rank. An empty target_player_id forfeits the bonus (e.g. no eligible crewmate).
|
||||||
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
|
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
|
||||||
def bonus_rank_up_route(
|
def bonus_rank_up_route(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
target_player_id: str = Form(...),
|
target_player_id: str = Form(""),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
target = crud.get_player(db, target_player_id)
|
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
if player and target and game and player.needs_rank_3_bonus:
|
player = crud.get_player(db, player_id)
|
||||||
player.needs_rank_3_bonus = False
|
if not game or not player:
|
||||||
db.add(player)
|
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||||
db.commit()
|
target = crud.get_player(db, target_player_id) if target_player_id else None
|
||||||
crud.change_player_rank(db, game, target, 1)
|
ok, msg = crud.grant_story_bonus_rank(db, game, player, target)
|
||||||
crud.add_game_event(db, game_id, f"{player.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.")
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Pi-Rat becomes a ghost
|
# Pi-Rat becomes a ghost
|
||||||
@@ -160,7 +185,9 @@ def become_ghost_route(
|
|||||||
# Deep ends the scene
|
# Deep ends the scene
|
||||||
@router.post("/game/{game_id}/scene/end")
|
@router.post("/game/{game_id}/scene/end")
|
||||||
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
|
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
|
||||||
crud.end_scene_and_transition(db, game_id)
|
ok, msg = crud.end_scene_and_transition(db, game_id)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Deep clears a completed obstacle
|
# Deep clears a completed obstacle
|
||||||
|
|||||||
@@ -232,6 +232,103 @@ a { color: var(--neon-cyan); }
|
|||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Example-of-Play card diagrams. Mirror the SPA's Obstacle column:
|
||||||
|
the original card sits at the back (gold ring), each play stacks in
|
||||||
|
front with a heavy negative margin so only its rank + suit peeks out,
|
||||||
|
and the newest card — which sets the difficulty — is fully visible at
|
||||||
|
the bottom. Green ring = a play that beat the difficulty; red = one
|
||||||
|
that didn't. Card faces stay light parchment in both themes, like the app.
|
||||||
|
============================================================ */
|
||||||
|
:root {
|
||||||
|
--ex-card-face: #faf4e0;
|
||||||
|
--ex-card-shade: #ead9b8;
|
||||||
|
--ex-card-red: #c2303a;
|
||||||
|
--ex-card-black: #2c3245;
|
||||||
|
--ex-success: #3f9d5b;
|
||||||
|
--ex-fail: #c0392b;
|
||||||
|
--ex-card-h: 74px;
|
||||||
|
--ex-peek: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-board {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem 2.25rem;
|
||||||
|
margin: 0.75rem 0 1.25rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: var(--well-bg);
|
||||||
|
border: 1px solid var(--row-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-board-label {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-obstacle { display: flex; align-items: flex-start; gap: 0.85rem; }
|
||||||
|
|
||||||
|
.ex-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-stack-card { position: relative; border-radius: 6px; line-height: 0; }
|
||||||
|
.ex-stack-card + .ex-stack-card { margin-top: calc(var(--ex-peek) - var(--ex-card-h)); }
|
||||||
|
.ex-stack-card.is-original { box-shadow: 0 0 0 2px var(--gold); }
|
||||||
|
.ex-stack-card.is-success { box-shadow: 0 0 0 2px var(--ex-success); }
|
||||||
|
.ex-stack-card.is-fail { box-shadow: 0 0 0 2px var(--ex-fail); }
|
||||||
|
|
||||||
|
.ex-card {
|
||||||
|
width: 52px;
|
||||||
|
height: var(--ex-card-h);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(150deg, var(--ex-card-face), var(--ex-card-shade));
|
||||||
|
border: 1px solid rgba(44, 50, 69, 0.35);
|
||||||
|
color: var(--ex-card-black);
|
||||||
|
padding: 4px 5px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 2px 5px rgba(36, 62, 87, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-card.red { color: var(--ex-card-red); }
|
||||||
|
|
||||||
|
.ex-card .ex-corner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: max-content;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-card .ex-corner .r { font-size: 0.82rem; }
|
||||||
|
.ex-card .ex-corner .s { font-size: 0.72rem; }
|
||||||
|
|
||||||
|
.ex-card .ex-pip {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-meta { font-size: 0.85rem; line-height: 1.35; }
|
||||||
|
.ex-meta .ex-title { display: block; font-family: var(--font-heading); font-weight: 700; color: var(--text-primary); }
|
||||||
|
.ex-meta .ex-val { color: var(--gold); font-weight: 800; }
|
||||||
|
|
||||||
|
.ex-legend .ex-ok { color: var(--ex-success); font-weight: 700; }
|
||||||
|
.ex-legend .ex-no { color: var(--ex-fail); font-weight: 700; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1021,6 +1118,28 @@ a { color: var(--neon-cyan); }
|
|||||||
are a total of two Obstacles on the list. Sally draws from the top of the deck: 10 of ♥
|
are a total of two Obstacles on the list. Sally draws from the top of the deck: 10 of ♥
|
||||||
Hearts: Cheese Thief. The Obstacle List now has: 7 of ♥ Hearts: Bad Breakups (value: 7) and
|
Hearts: Cheese Thief. The Obstacle List now has: 7 of ♥ Hearts: Bad Breakups (value: 7) and
|
||||||
10 of ♥ Hearts: Cheese Thief (value: 10).</p>
|
10 of ♥ Hearts: Cheese Thief (value: 10).</p>
|
||||||
|
|
||||||
|
<p class="aside ex-legend">In the diagrams below, each Obstacle is a column of cards. The
|
||||||
|
<strong>original</strong> card (gold outline) stays at the back and fixes the color; each play
|
||||||
|
stacks in front of it, and the newest card — which sets the current difficulty — sits in full
|
||||||
|
view. <span class="ex-ok">Green</span> marks a play that beat the difficulty, <span class="ex-no">red</span>
|
||||||
|
one that didn't.</p>
|
||||||
|
<div class="ex-board">
|
||||||
|
<div class="ex-board-label">Obstacle List after the draw</div>
|
||||||
|
<div class="ex-obstacle">
|
||||||
|
<div class="ex-stack">
|
||||||
|
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">7</span><br>0 successes</div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-obstacle">
|
||||||
|
<div class="ex-stack">
|
||||||
|
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">10</span><br>0 successes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p><strong>Step 3: Frame the Scene!</strong></p>
|
<p><strong>Step 3: Frame the Scene!</strong></p>
|
||||||
<p>Sally: "Alright, so you rowdy rats are in the galley below deck, drinking grog, and
|
<p>Sally: "Alright, so you rowdy rats are in the galley below deck, drinking grog, and
|
||||||
shootin' the breeze. The crew's been tense ever since that breakup between Salty Socks Pete
|
shootin' the breeze. The crew's been tense ever since that breakup between Salty Socks Pete
|
||||||
@@ -1065,6 +1184,24 @@ a { color: var(--neon-cyan); }
|
|||||||
<p><strong>Draw Cards:</strong> Jake played a 3 of ♥ Hearts against a 10 of ♥ Hearts. Since
|
<p><strong>Draw Cards:</strong> Jake played a 3 of ♥ Hearts against a 10 of ♥ Hearts. Since
|
||||||
both were red, Jake draws 1 card and adds it to their hand.</p>
|
both were red, Jake draws 1 card and adds it to their hand.</p>
|
||||||
|
|
||||||
|
<div class="ex-board">
|
||||||
|
<div class="ex-board-label">After the First Challenge</div>
|
||||||
|
<div class="ex-obstacle">
|
||||||
|
<div class="ex-stack">
|
||||||
|
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">8</span><span class="s">♠</span></span><span class="ex-pip">♠</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">8</span><br>1 success</div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-obstacle">
|
||||||
|
<div class="ex-stack">
|
||||||
|
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
<div class="ex-stack-card is-fail"><div class="ex-card red"><span class="ex-corner"><span class="r">3</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">3</span><br>0 successes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4>Second Challenge</h4>
|
<h4>Second Challenge</h4>
|
||||||
<p>Gabe: "While everyone's yelling at Carlos, I sneak toward the cheese wheel on the table,
|
<p>Gabe: "While everyone's yelling at Carlos, I sneak toward the cheese wheel on the table,
|
||||||
looking for clues, and maybe to steal it." Sally: "Oh no you don't! Challenge time! Can you
|
looking for clues, and maybe to steal it." Sally: "Oh no you don't! Challenge time! Can you
|
||||||
@@ -1089,6 +1226,25 @@ a { color: var(--neon-cyan); }
|
|||||||
<p><strong>Draw Cards:</strong> Gabe played a King of ♠ Spades (black) against a red
|
<p><strong>Draw Cards:</strong> Gabe played a King of ♠ Spades (black) against a red
|
||||||
Obstacle, so he doesn't draw any cards.</p>
|
Obstacle, so he doesn't draw any cards.</p>
|
||||||
|
|
||||||
|
<div class="ex-board">
|
||||||
|
<div class="ex-board-label">After the Second Challenge</div>
|
||||||
|
<div class="ex-obstacle">
|
||||||
|
<div class="ex-stack">
|
||||||
|
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">8</span><span class="s">♠</span></span><span class="ex-pip">♠</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">8</span><br>1 success</div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-obstacle">
|
||||||
|
<div class="ex-stack">
|
||||||
|
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
<div class="ex-stack-card is-fail"><div class="ex-card red"><span class="ex-corner"><span class="r">3</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">K</span><span class="s">♠</span></span><span class="ex-pip">♠</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">Rank</span><br>1 success<br><span class="aside">(King = Secret Pirate Technique)</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4>Third Challenge</h4>
|
<h4>Third Challenge</h4>
|
||||||
<p>Gabe: "Whiskey Shrinkage is up on deck now, eating cheese openly. He's taunting the crew
|
<p>Gabe: "Whiskey Shrinkage is up on deck now, eating cheese openly. He's taunting the crew
|
||||||
with it, hoping to draw out the Cheese Thief. 'Will the owner of this delicious havarti come
|
with it, hoping to draw out the Cheese Thief. 'Will the owner of this delicious havarti come
|
||||||
@@ -1123,6 +1279,27 @@ a { color: var(--neon-cyan); }
|
|||||||
draws 1 card. Jake played Queen of ♥ Hearts (red) against a red Obstacle, so they WOULD draw
|
draws 1 card. Jake played Queen of ♥ Hearts (red) against a red Obstacle, so they WOULD draw
|
||||||
1 card, but they were assisting, so they didn't get to draw.</p>
|
1 card, but they were assisting, so they didn't get to draw.</p>
|
||||||
|
|
||||||
|
<div class="ex-board">
|
||||||
|
<div class="ex-board-label">After the Third Challenge</div>
|
||||||
|
<div class="ex-obstacle">
|
||||||
|
<div class="ex-stack">
|
||||||
|
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">8</span><span class="s">♠</span></span><span class="ex-pip">♠</span></div></div>
|
||||||
|
<div class="ex-stack-card is-success"><div class="ex-card red"><span class="ex-corner"><span class="r">Q</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">Rank</span><br>2 successes<br><span class="aside">(Queen = Secret Pirate Technique)</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-obstacle">
|
||||||
|
<div class="ex-stack">
|
||||||
|
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
<div class="ex-stack-card is-fail"><div class="ex-card red"><span class="ex-corner"><span class="r">3</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">K</span><span class="s">♠</span></span><span class="ex-pip">♠</span></div></div>
|
||||||
|
<div class="ex-stack-card is-success"><div class="ex-card red"><span class="ex-corner"><span class="r">9</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">9</span><br>2 successes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4>Ending the Scene</h4>
|
<h4>Ending the Scene</h4>
|
||||||
<p>Sally: "Alright, let's check the requirements for ending the Scene. Both Pi-Rat Players
|
<p>Sally: "Alright, let's check the requirements for ending the Scene. Both Pi-Rat Players
|
||||||
have been challenged at least once. Has anyone made progress on their Objectives?"</p>
|
have been challenged at least once. Has anyone made progress on their Objectives?"</p>
|
||||||
|
|||||||
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())
|
||||||
@@ -3,7 +3,7 @@ import json
|
|||||||
from sqlmodel import SQLModel, create_engine, Session, select
|
from sqlmodel import SQLModel, create_engine, Session, select
|
||||||
from pirats import cards
|
from pirats import cards
|
||||||
from pirats import crud
|
from pirats import crud
|
||||||
from pirats.models import Obstacle, Player, Challenge, GameEvent, Checkpoint
|
from pirats.models import Game, Obstacle, Player, Challenge, GameEvent, Checkpoint
|
||||||
|
|
||||||
# In-memory database for testing
|
# In-memory database for testing
|
||||||
@pytest.fixture(name="session")
|
@pytest.fixture(name="session")
|
||||||
@@ -194,12 +194,14 @@ def test_scene_start_and_challenges(session):
|
|||||||
assert "Challenge" in msg
|
assert "Challenge" in msg
|
||||||
|
|
||||||
# The Deep calls a Challenge applying the obstacle
|
# The Deep calls a Challenge applying the obstacle
|
||||||
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="The cheese is on the line")
|
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_success="The cheese is yours", stakes_failure="The cheese is on the line")
|
||||||
assert ok, msg
|
assert ok, msg
|
||||||
session.refresh(game)
|
session.refresh(game)
|
||||||
challenge = game.challenges[0]
|
challenge = game.challenges[0]
|
||||||
assert challenge.status == "open"
|
assert challenge.status == "open"
|
||||||
assert challenge.acting_player_id == p2.id
|
assert challenge.acting_player_id == p2.id
|
||||||
|
assert challenge.stakes_success == "The cheese is yours"
|
||||||
|
assert challenge.stakes_failure == "The cheese is on the line"
|
||||||
|
|
||||||
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
|
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
|
||||||
|
|
||||||
@@ -343,7 +345,6 @@ def test_captaincy_and_hand_sizes(session):
|
|||||||
|
|
||||||
# No captain until the crew controls a ship
|
# No captain until the crew controls a ship
|
||||||
assert game.captain_player_id is None
|
assert game.captain_player_id is None
|
||||||
assert not crud.is_player_captain(p2, game)
|
|
||||||
|
|
||||||
# Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards
|
# Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards
|
||||||
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 4
|
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 4
|
||||||
@@ -355,20 +356,19 @@ def test_captaincy_and_hand_sizes(session):
|
|||||||
session.commit()
|
session.commit()
|
||||||
assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 4
|
assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 4
|
||||||
|
|
||||||
# Completing Crew Objective 1 (Steal a Ship) makes the highest-ranked Pi-Rat the Captain
|
# Stealing a ship does NOT auto-assign a Captain — the crew must earn and
|
||||||
|
# choose one. Completing Crew Objective 1 leaves the captaincy vacant.
|
||||||
crud.toggle_objective(session, game.id, p1.id, "crew_1", True)
|
crud.toggle_objective(session, game.id, p1.id, "crew_1", True)
|
||||||
session.refresh(game)
|
session.refresh(game)
|
||||||
assert game.captain_player_id in (p2.id, p3.id)
|
|
||||||
captain = crud.get_player(session, game.captain_player_id)
|
|
||||||
assert crud.is_player_captain(captain, game)
|
|
||||||
# Captain privilege: +1 hand size
|
|
||||||
assert crud.calculate_max_hand_size(captain, game.players, game.captain_player_id) == 5
|
|
||||||
|
|
||||||
# Losing the ship vacates the Captaincy
|
|
||||||
crud.toggle_objective(session, game.id, p1.id, "crew_1", False)
|
|
||||||
session.refresh(game)
|
|
||||||
assert game.captain_player_id is None
|
assert game.captain_player_id is None
|
||||||
|
|
||||||
|
# The crew chooses a Captain explicitly (via the Captaincy UI / set_captain).
|
||||||
|
crud.set_captain(session, game, p2.id)
|
||||||
|
session.refresh(game)
|
||||||
|
assert game.captain_player_id == p2.id
|
||||||
|
# Captain privilege: +1 hand size
|
||||||
|
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 5
|
||||||
|
|
||||||
def test_captain_tax_on_failed_challenge(session):
|
def test_captain_tax_on_failed_challenge(session):
|
||||||
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
|
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
|
||||||
# p3 (rank 2) becomes captain
|
# p3 (rank 2) becomes captain
|
||||||
@@ -443,6 +443,11 @@ def test_game_creation_with_crew_name(session):
|
|||||||
assert game.id is not None
|
assert game.id is not None
|
||||||
assert game.crew_name == "The Black Gats"
|
assert game.crew_name == "The Black Gats"
|
||||||
assert game.phase == "lobby"
|
assert game.phase == "lobby"
|
||||||
|
assert len(game.join_code) == 5
|
||||||
|
assert set(game.join_code) <= set("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
|
||||||
|
|
||||||
|
another = crud.create_game(session, crew_name="The Other Gats")
|
||||||
|
assert another.join_code != game.join_code
|
||||||
|
|
||||||
def test_create_game_endpoint():
|
def test_create_game_endpoint():
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -475,6 +480,17 @@ def test_create_game_endpoint():
|
|||||||
games = session.exec(select(Game)).all()
|
games = session.exec(select(Game)).all()
|
||||||
assert len(games) == 1
|
assert len(games) == 1
|
||||||
assert games[0].crew_name == "The Math Mutineers"
|
assert games[0].crew_name == "The Math Mutineers"
|
||||||
|
|
||||||
|
# Codes are case-insensitive at the API boundary and resolve only
|
||||||
|
# active games without exposing the UUID in the code itself.
|
||||||
|
lookup = client.get(f"/api/game/code/{games[0].join_code.lower()}")
|
||||||
|
assert lookup.status_code == 200
|
||||||
|
assert lookup.json() == {"id": games[0].id}
|
||||||
|
|
||||||
|
games[0].phase = "ended"
|
||||||
|
session.add(games[0])
|
||||||
|
session.commit()
|
||||||
|
assert client.get(f"/api/game/code/{games[0].join_code}").status_code == 404
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
@@ -631,6 +647,41 @@ def test_player_death_toggle(session):
|
|||||||
assert not player.is_dead
|
assert not player.is_dead
|
||||||
assert not player.completed_personal_3
|
assert not player.completed_personal_3
|
||||||
|
|
||||||
|
def test_story_bonus_rank_grant(session):
|
||||||
|
game, deep, (granter, mate) = make_scene_game(session, num_pirats=2)
|
||||||
|
crud.toggle_objective(session, game.id, granter.id, "personal_3", True)
|
||||||
|
session.refresh(granter)
|
||||||
|
assert granter.needs_rank_3_bonus
|
||||||
|
start_rank = mate.rank
|
||||||
|
ok, _ = crud.grant_story_bonus_rank(session, game, granter, mate)
|
||||||
|
assert ok
|
||||||
|
session.refresh(granter)
|
||||||
|
session.refresh(mate)
|
||||||
|
assert not granter.needs_rank_3_bonus
|
||||||
|
assert mate.rank == start_rank + 1
|
||||||
|
|
||||||
|
def test_story_bonus_rank_guards_and_forfeit(session):
|
||||||
|
game, deep, (granter, mate) = make_scene_game(session, num_pirats=2)
|
||||||
|
# No bonus pending yet -> refused.
|
||||||
|
ok, _ = crud.grant_story_bonus_rank(session, game, granter, mate)
|
||||||
|
assert not ok
|
||||||
|
crud.toggle_objective(session, game.id, granter.id, "personal_3", True)
|
||||||
|
session.refresh(granter)
|
||||||
|
mate_rank = mate.rank
|
||||||
|
# Can't grant to yourself, nor to the Deep -> refused, prompt stays up.
|
||||||
|
for bad_target in (granter, deep):
|
||||||
|
ok, _ = crud.grant_story_bonus_rank(session, game, granter, bad_target)
|
||||||
|
assert not ok
|
||||||
|
session.refresh(granter)
|
||||||
|
assert granter.needs_rank_3_bonus
|
||||||
|
# Forfeit (no eligible crewmate) clears the prompt without changing any Rank.
|
||||||
|
ok, _ = crud.grant_story_bonus_rank(session, game, granter, None)
|
||||||
|
assert ok
|
||||||
|
session.refresh(granter)
|
||||||
|
session.refresh(mate)
|
||||||
|
assert not granter.needs_rank_3_bonus
|
||||||
|
assert mate.rank == mate_rank
|
||||||
|
|
||||||
def test_become_ghost_flow(session):
|
def test_become_ghost_flow(session):
|
||||||
game = crud.create_game(session)
|
game = crud.create_game(session)
|
||||||
player = crud.add_player(session, game.id, "DeadRat")
|
player = crud.add_player(session, game.id, "DeadRat")
|
||||||
@@ -946,6 +997,53 @@ def test_pvp_challenge(session):
|
|||||||
session.refresh(duel)
|
session.refresh(duel)
|
||||||
assert duel.status == "succeeded"
|
assert duel.status == "succeeded"
|
||||||
|
|
||||||
|
def test_end_scene_requires_every_pirat_challenged(session):
|
||||||
|
game, deep, (p2,) = make_scene_game(session)
|
||||||
|
game.dev_mode = False
|
||||||
|
session.add(game)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Obstacles remain and p2 has not been challenged yet -> blocked.
|
||||||
|
ok, msg = crud.end_scene_and_transition(session, game.id)
|
||||||
|
assert not ok
|
||||||
|
assert p2.name in msg
|
||||||
|
session.refresh(game)
|
||||||
|
assert game.phase == "scene"
|
||||||
|
|
||||||
|
# Once p2 has faced a Deep Challenge, the scene can end.
|
||||||
|
obs = game.obstacles[0]
|
||||||
|
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
|
||||||
|
assert ok, msg
|
||||||
|
ok, msg = crud.end_scene_and_transition(session, game.id)
|
||||||
|
assert ok, msg
|
||||||
|
session.refresh(game)
|
||||||
|
assert game.phase == "between_scenes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_scene_allowed_when_obstacle_list_empty(session):
|
||||||
|
game, deep, (p2,) = make_scene_game(session)
|
||||||
|
game.dev_mode = False
|
||||||
|
session.add(game)
|
||||||
|
# Clear the Obstacle List; nobody has been challenged.
|
||||||
|
for obs in list(game.obstacles):
|
||||||
|
session.delete(obs)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ok, msg = crud.end_scene_and_transition(session, game.id)
|
||||||
|
assert ok, msg
|
||||||
|
session.refresh(game)
|
||||||
|
assert game.phase == "between_scenes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_scene_dev_mode_bypasses_challenge_requirement(session):
|
||||||
|
game, deep, (p2,) = make_scene_game(session)
|
||||||
|
assert game.dev_mode # defaults on for a local checkout
|
||||||
|
ok, msg = crud.end_scene_and_transition(session, game.id)
|
||||||
|
assert ok, msg
|
||||||
|
session.refresh(game)
|
||||||
|
assert game.phase == "between_scenes"
|
||||||
|
|
||||||
|
|
||||||
def test_obstacles_persist_across_scenes(session):
|
def test_obstacles_persist_across_scenes(session):
|
||||||
game, deep, (p2,) = make_scene_game(session)
|
game, deep, (p2,) = make_scene_game(session)
|
||||||
assert len(game.obstacles) == 2
|
assert len(game.obstacles) == 2
|
||||||
@@ -2078,7 +2176,7 @@ def test_rollback_round_trip_restores_state(session):
|
|||||||
cp_known = crud.capture_checkpoint(session, game)
|
cp_known = crud.capture_checkpoint(session, game)
|
||||||
|
|
||||||
# A destructive action: open a challenge and play a card against the obstacle.
|
# A destructive action: open a challenge and play a card against the obstacle.
|
||||||
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="x")
|
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_failure="x")
|
||||||
crud.capture_checkpoint(session, game)
|
crud.capture_checkpoint(session, game)
|
||||||
crud.play_challenge_card(session, p2.id, obs.id, "10D")
|
crud.play_challenge_card(session, p2.id, obs.id, "10D")
|
||||||
crud.capture_checkpoint(session, game)
|
crud.capture_checkpoint(session, game)
|
||||||
@@ -2242,7 +2340,7 @@ def test_rollback_middleware_capture_and_route_end_to_end():
|
|||||||
obs.current_value = 5
|
obs.current_value = 5
|
||||||
session.add_all([p2, obs])
|
session.add_all([p2, obs])
|
||||||
session.commit()
|
session.commit()
|
||||||
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="x")
|
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_failure="x")
|
||||||
|
|
||||||
# A floor checkpoint representing the pre-play state.
|
# A floor checkpoint representing the pre-play state.
|
||||||
floor = crud.capture_checkpoint(session, game)
|
floor = crud.capture_checkpoint(session, game)
|
||||||
@@ -2328,3 +2426,162 @@ def test_rollback_route_enforces_permission():
|
|||||||
assert r.json()["status"] == "ok"
|
assert r.json()["status"] == "ok"
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_game(session, crew_name, phase, last_event_ts):
|
||||||
|
"""A game whose most-recent GameEvent is at `last_event_ts`."""
|
||||||
|
game = Game(crew_name=crew_name, phase=phase)
|
||||||
|
session.add(game)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(game)
|
||||||
|
session.add(GameEvent(game_id=game.id, message="move", timestamp=last_event_ts - 10))
|
||||||
|
session.add(GameEvent(game_id=game.id, message="move", timestamp=last_event_ts))
|
||||||
|
session.commit()
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_old_games(session):
|
||||||
|
from pirats import maintenance
|
||||||
|
|
||||||
|
now = 2_000_000_000.0
|
||||||
|
DAY = 86400.0
|
||||||
|
|
||||||
|
active = _seed_game(session, "Active", "scene", now - 2 * DAY)
|
||||||
|
finished_old = _seed_game(session, "DoneOld", "ended", now - 20 * DAY)
|
||||||
|
finished_recent = _seed_game(session, "DoneNew", "ended", now - 3 * DAY)
|
||||||
|
inactive = _seed_game(session, "Ghosted", "scene", now - 40 * DAY)
|
||||||
|
|
||||||
|
# A game with no events at all is undatable and must be left alone.
|
||||||
|
empty = Game(crew_name="Empty", phase="lobby")
|
||||||
|
session.add(empty)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(empty)
|
||||||
|
|
||||||
|
# Give the finished-old game a player + checkpoint to prove children are cleared.
|
||||||
|
session.add(Player(game_id=finished_old.id, name="Crewmate"))
|
||||||
|
session.add(Checkpoint(game_id=finished_old.id, state_json="{}"))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
purged = maintenance.purge_old_games(session, finished_days=14, inactive_days=30, now=now)
|
||||||
|
purged_ids = {p["id"] for p in purged}
|
||||||
|
|
||||||
|
assert finished_old.id in purged_ids # ended > 14 days ago
|
||||||
|
assert inactive.id in purged_ids # no activity for > 30 days
|
||||||
|
assert active.id not in purged_ids # recently active
|
||||||
|
assert finished_recent.id not in purged_ids # ended only 3 days ago
|
||||||
|
assert empty.id not in purged_ids # undatable
|
||||||
|
|
||||||
|
remaining = {g.id for g in session.exec(select(Game)).all()}
|
||||||
|
assert finished_old.id not in remaining
|
||||||
|
assert inactive.id not in remaining
|
||||||
|
assert active.id in remaining
|
||||||
|
assert empty.id in remaining
|
||||||
|
|
||||||
|
# Children of a purged game are gone.
|
||||||
|
assert session.exec(select(GameEvent).where(GameEvent.game_id == finished_old.id)).first() is None
|
||||||
|
assert session.exec(select(Player).where(Player.game_id == finished_old.id)).first() is None
|
||||||
|
assert session.exec(select(Checkpoint).where(Checkpoint.game_id == finished_old.id)).first() is None
|
||||||
|
|
||||||
|
# Summaries carry the reason and a positive byte estimate.
|
||||||
|
summary = next(p for p in purged if p["id"] == finished_old.id)
|
||||||
|
assert summary["reason"] == "finished"
|
||||||
|
assert summary["crew_name"] == "DoneOld"
|
||||||
|
assert summary["estimated_bytes"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_text_and_name():
|
||||||
|
from pirats.validation import (
|
||||||
|
sanitize_text, sanitize_name, MAX_TEXT_LENGTH, MAX_NAME_LENGTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Control characters dropped, surrounding whitespace trimmed.
|
||||||
|
assert sanitize_text(" hi\x00\x07there ") == "hithere"
|
||||||
|
# Tabs/newlines kept inside the text.
|
||||||
|
assert sanitize_text("a\nb\tc") == "a\nb\tc"
|
||||||
|
# Zero-width joiner (emoji sequences) survives.
|
||||||
|
assert sanitize_text("ab") == "ab"
|
||||||
|
# Lone surrogate removed without raising.
|
||||||
|
assert sanitize_text("a" + chr(0xD800) + "b") == "ab"
|
||||||
|
# Length capped.
|
||||||
|
assert len(sanitize_text("x" * (MAX_TEXT_LENGTH + 50))) == MAX_TEXT_LENGTH
|
||||||
|
# Empty / falsy in -> empty out.
|
||||||
|
assert sanitize_text("") == ""
|
||||||
|
|
||||||
|
# Names collapse internal whitespace and use the tighter cap.
|
||||||
|
assert sanitize_name(" Captain Redbeard\n ") == "Captain Redbeard"
|
||||||
|
assert len(sanitize_name("y" * (MAX_NAME_LENGTH + 10))) == MAX_NAME_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_join_sanitize_names():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from pirats.main import app
|
||||||
|
from pirats.database import get_session
|
||||||
|
from sqlmodel import SQLModel, create_engine, Session
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
|
||||||
|
def override():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = override
|
||||||
|
client = TestClient(app)
|
||||||
|
try:
|
||||||
|
r = client.post("/api/game", data={"crew_name": " The\x00 Salty\x07 Dogs "})
|
||||||
|
gid = r.json()["id"]
|
||||||
|
assert session.get(Game, gid).crew_name == "The Salty Dogs"
|
||||||
|
|
||||||
|
r = client.post(f"/api/game/{gid}/join", data={"name": " Bad\x01name "})
|
||||||
|
player = session.get(Player, r.json()["id"])
|
||||||
|
assert player.player_name == "Badname"
|
||||||
|
assert "\x01" not in player.name
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_body_size_limit_middleware():
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pirats.main import LimitRequestBodyMiddleware
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(LimitRequestBodyMiddleware, max_bytes=1000)
|
||||||
|
|
||||||
|
@app.post("/echo")
|
||||||
|
async def echo(request: Request):
|
||||||
|
body = await request.body()
|
||||||
|
return {"len": len(body)}
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Under the limit: passes through and the handler sees the full body.
|
||||||
|
r = client.post("/echo", content=b"x" * 500)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["len"] == 500
|
||||||
|
|
||||||
|
# Over the limit with a declared Content-Length: rejected up front.
|
||||||
|
r = client.post("/echo", content=b"x" * 5000)
|
||||||
|
assert r.status_code == 413
|
||||||
|
|
||||||
|
# Over the limit via a streamed (length-omitting) body: rejected once the
|
||||||
|
# counted bytes exceed the cap.
|
||||||
|
def oversized_chunks():
|
||||||
|
for _ in range(10):
|
||||||
|
yield b"x" * 500 # 5000 bytes total
|
||||||
|
|
||||||
|
r = client.post("/echo", content=oversized_chunks())
|
||||||
|
assert r.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_body_size_limit_on_real_app():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pirats.main import app, DEFAULT_MAX_BODY_BYTES
|
||||||
|
|
||||||
|
# No DB override needed: the middleware rejects before routing/dependencies.
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/api/game", content=b"x" * (DEFAULT_MAX_BODY_BYTES + 1))
|
||||||
|
assert r.status_code == 413
|
||||||
|
|||||||
Reference in New Issue
Block a user