Compare commits

...
42 Commits
Author SHA1 Message Date
ottoandClaude Sonnet 5.5 b31936235d Tape measure, area templates, map markers with notes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-25 15:43:26 +00:00
ottoandClaude Sonnet 5.5 0f97129243 Map ping with sound (custom MP3 per lobby)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-25 09:18:46 +00:00
ottoandClaude Sonnet 5.5 2087ce9960 Fog: current view is transparent outside its shape so explored ground stays visible
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 20:38:04 +00:00
ottoandClaude Sonnet 5.5 1405381096 Prop library page, DM notes on tokens and props, fog/sight without walls, finer explored mask
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 20:23:23 +00:00
ottoandClaude Sonnet 5.5 6880b58de2 Props added in the lobby start hidden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 20:15:28 +00:00
ottoandClaude Sonnet 5.5 779978ad02 Lower, readable table buttons; keep DM buttons in player view
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 19:50:56 +00:00
ottoandClaude Sonnet 5.5 c1fdb7cc76 Scenes (switch maps without losing state), decor props with rotation, editor visibility toggle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 19:37:55 +00:00
Claude 928e018fcd Fix map editor crash, readable radial menu, new lobby tokens start hidden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 19:09:15 +00:00
Claude ab3c35aab7 Sight groups, DM reveal areas (cones), client-side image shrink for map upload
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 18:33:08 +00:00
Claude 6aac03f112 Token HP and conditions with DM visibility toggles, fog of war, live sight range
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 18:21:39 +00:00
Claude a2103566a0 Sheet: large portrait slot, writable notes in summary and lobby
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 18:10:12 +00:00
Claude 7940fcfa3f Compact lobby top bar, scrollable initiative cards, hidden NPC names, resizable panels
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 17:05:38 +00:00
Claude 72d8113ece Initiative tracker, radial token menu, slimmer lobby header
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 15:55:23 +00:00
Claude f6448aff85 Lobby: online presence indicator
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 14:36:25 +00:00
Claude 1db8a45cae NPC editor, NPC tokens from library, admin management
- NPC library (own + shared) built on the character wizard with a free NPC preset
- NPC tokens in map editor and lobby, DM sheet on token click, DM checks for NPCs
- Admins can promote/demote other users

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 21:36:09 +00:00
Claude 57f262b6aa Lobby: live refresh after saving a character, slimmer player bar
A saved character now refreshes the player bar and the map tokens right
away (new portrait or name). The player bar shows only the character name.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 20:50:55 +00:00
Claude d4d7b4d22e Character portrait
Crop-and-zoom picture editor on the Concept page, stored (re-encoded to a
256 px WebP data URL) inside the character; shown on the sheet header, in
the lobby player bar and as token icon on the map.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 19:33:28 +00:00
Claude 03ebc3a138 Lobby: MP3 dice sound with pitch per die, DM sound settings
Default dice sound is public/dice.mp3 with a small random pitch and a
base pitch per die size (d4 lighter ... d100 heavier). The DM can upload
an own dice sound and sounds for critical success / failure per lobby,
reset them, and switch result and critical sounds on or off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 19:04:12 +00:00
Claude ce24a0ba6f Lobby: heavier dice sound
Low thump with a dull knock per bounce, fewer bounces, darker filter and
lower result cues.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 18:47:35 +00:00
Claude 2195ecce94 Lobby: dice sounds
Clatter per die and a result cue for checks, synthesized with the Web
Audio API; mute button and volume slider next to the dice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 18:42:46 +00:00
Claude 82e4ac9458 Maps tab with sharing, walls/doors and strict line of sight
Maps page (list, upload, edit, share, copy), wall/room/door tools in the
map editor, server-side visibility polygon: the image is cut per player
and other tokens are filtered, DM can view as a player, open doors and
switch sight on/off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 14:23:17 +00:00
Claude e8755582d5 Lobby: maps, map editor and tokens
DM map library (upload re-encoded with Pillow), map editor with prepared
enemies/NPCs, map on the lobby table with personal pan/zoom, draggable
tokens (players move their own, DM all), full/player view, live sync via
the WebSocket.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 13:59:21 +00:00
Claude 162d8c9224 Lobby: WebSockets for real-time sync
Server pushes small change notes (messages / lobby / sheet) per lobby;
clients fetch data through the normal API so visibility rules stay in one
place. Polling stays as slow fallback. nginx WebSocket headers documented.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 13:41:02 +00:00
Claude 1c672f0ab8 Lobby: private rolls and checks for players, DM tab with lobby actions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 13:32:57 +00:00
Claude 78f0de5333 Rules editor: admins can edit items, skills, professions (and categories/groups) in the app
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 14:48:55 +00:00
Claude a8c9740a05 Skills: +-5 buttons, advanced skills use 50% of the attribute (creation.advanced_attr_percent)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 14:37:52 +00:00
Claude 3e67aba75c Lobby: dice are thrown onto the table and rest at random spots; fix duplicate dice for the roller
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 13:20:55 +00:00
Claude 890cbfa993 Lobby: 3-column layout with player area, skill checks, difficulty, colours, own dice, mystery dice
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 11:44:36 +00:00
Claude 59df681cfa Lobby: sheet theme switch in the tab bar, remove extra toolbar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 11:12:32 +00:00
Claude 7e69b68b37 Revert "Lobby: CSS 3D dice tray at the top right next to the chat"
Back to the card-style dice tray above the hotbar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 18:13:56 +00:00
Claude d730022447 Lobby: CSS 3D dice tray at the top right next to the chat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 18:07:07 +00:00
Claude 968d168ff1 Sheet: light/dark switch, slimmer lobby header, remove wide view
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 17:24:48 +00:00
Claude 8711b7fe0e A4 character sheet (summary and lobby), A4/wide switch, print to PDF
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 17:09:41 +00:00
Claude 69e53c1520 Lobby: chat at the right window edge, DM rolls hidden from players
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 17:00:02 +00:00
Claude a88b8d2fa3 Lobby: desktop layout with chat column, dice hotbar and shared dice tray, server-side rolls
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 16:51:16 +00:00
Claude 94d3505b82 Lobby: tabs, live chat with whispers, event log, embedded read-only character sheets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 16:38:28 +00:00
Claude aad8e0515b Add lobbies: DM sees all characters, players only a short card of others
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 16:23:36 +00:00
Claude e8584e043d Make host port configurable via AW_PORT (default 8090, AMP uses 8080)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 12:38:03 +00:00
Claude 2ae64420d9 Add server deployment guide, bind port to localhost
DEPLOY.md: dedicated user with rootless Docker, deploy key, nginx + HTTPS,
updates, backups. The container port is only published on 127.0.0.1 so
nginx is the only way in (Docker would otherwise bypass the firewall).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-20 20:54:20 +00:00
Claude a2e719afd0 Advanced skills can no longer be unlocked through proficiency picks
Advanced skills must be unlocked with the pool cost. Only a profession's
own proficiency still unlocks its skill for free. Proficiency picks need
an unlocked skill, locking a skill returns its picks, old characters that
used picks to unlock a skill get it unlocked automatically.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-20 20:48:45 +00:00
Claude 4c0d7906cd Add explanation tooltips for derived values
Hover/focus/tap tooltips show how attributes, derived stats, skill target
numbers and limits, capacity, carry weight and point pools are calculated.
Used in the wizard pages, the side sheet and the summary.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-20 20:40:08 +00:00
Claude 5f3b2b2874 Add accounts, presets, live sheet panel and equipment tiles
- Login with registration code, sessions, rate limiting, per-user characters
- Share characters with other users (read-only), admin CLI
- Rules presets with a Presets tab, applied/pending changes with diff in a side sheet
- Equipment as tiles with amounts, starting kit removed
- Rename nav tab to "Character", add "New character" button
- Stop tracking _to_delete/

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-20 20:37:09 +00:00
48 changed files with 7241 additions and 329 deletions
+9
View File
@@ -0,0 +1,9 @@
# Copy this file to ".env" (it is ignored by git) and fill it in.
# The code people must enter to create an account:
AW_REGISTRATION_CODE=
# 1 when running behind a reverse proxy that sets X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host:
AW_TRUST_PROXY=0
# Port on the host (bound to 127.0.0.1 only). nginx proxies to it. Pick a free one (check: ss -tlnp | grep :8090):
AW_PORT=8090
# Who may use the rules editor (comma separated usernames). Empty = only the first account that was ever created:
AW_ADMINS=
+2
View File
@@ -5,3 +5,5 @@ backend/__pycache__/
backend/app/__pycache__/
backend/aw.db
db/
.env
_to_delete/
+137
View File
@@ -0,0 +1,137 @@
# Running it on a Linux server
Goal: the app runs as its own unprivileged user, only nginx can reach it, and everything is behind HTTPS.
## 1. Dedicated user with rootless Docker
Members of the `docker` group are effectively root on the machine, so do **not** add the new user to it. Use *rootless* Docker instead: the daemon, the images and the container processes all belong to that one user. If something breaks out of the container, it only gets that user's rights. This runs separately from any existing (root) Docker, for example the one AMP uses.
```bash
# as an admin user
sudo adduser --disabled-password --gecos "" awcc # no password, no sudo
sudo apt install -y uidmap dbus-user-session slirp4netns docker-ce-rootless-extras
sudo loginctl enable-linger awcc # user services start at boot
```
Leave the system Docker alone if other things (AMP) use it; rootless Docker runs next to it.
`docker-ce-rootless-extras` comes from Docker's own apt repository. If Docker was installed from your distribution (`docker.io`), add Docker's repository first (see docs.docker.com/engine/install).
Switch to the user and install the rootless daemon:
```bash
sudo -iu awcc
export XDG_RUNTIME_DIR=/run/user/$(id -u)
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
dockerd-rootless-setuptool.sh install
systemctl --user enable --now docker
echo 'export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock' >> ~/.bashrc
echo 'export XDG_RUNTIME_DIR=/run/user/$(id -u)' >> ~/.bashrc
source ~/.bashrc
docker run --rm hello-world # test
```
## 2. Get the code
Use a read-only **deploy key** so this user never holds your personal Gitea credentials:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "awcc deploy key"
cat ~/.ssh/id_ed25519.pub # Gitea: repository -> Settings -> Deploy Keys -> add, leave "write access" off
git clone ssh://git@git.bitrux.de/otto/AWCharacterCreator.git # adjust the SSH port/URL to your Gitea
cd AWCharacterCreator
```
## 3. Configure and start
```bash
cp .env.example .env
nano .env # set AW_REGISTRATION_CODE, and AW_TRUST_PROXY=1 (nginx is in front)
chmod 600 .env
docker compose up -d --build
curl -sI http://127.0.0.1:8090 | head -1 # should print HTTP/1.1 200 OK
```
The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`; changes from the rules editor are in `./db/rules_overrides.json` (back up the whole `db` folder). Set `AW_ADMINS` in `.env` to choose who may use the editor (default: the first account).
## 4. nginx + HTTPS
Point a DNS name (for example `aw.yourdomain.tld`) at the server, then create `/etc/nginx/sites-available/aw`:
```nginx
server {
listen 80;
server_name aw.yourdomain.tld;
client_max_body_size 13m; # map uploads (the app itself limits them to 12 MB and keeps characters small)
location / {
proxy_pass http://127.0.0.1:8090; # same as AW_PORT in .env
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, so clients cannot fake their address
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# the lobby uses a WebSocket for live updates (without these three lines it falls back to polling)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
```
Already running an older config? Add the last four lines inside `location /` and change `client_max_body_size` to `13m` (needed for map uploads, otherwise nginx answers 413), then `sudo nginx -t && sudo systemctl reload nginx`. Nothing else changes; the lobby still works without them, just with a few seconds of delay.
```bash
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d aw.yourdomain.tld # gets the certificate, adds HTTPS and the redirect
```
Then add inside the HTTPS `server` block: `add_header Strict-Transport-Security "max-age=31536000" always;`
Firewall: only SSH, 80 and 443 should be open. The app port (AW_PORT, default 8090) is not published to the outside at all.
Open the site, choose "Create account" and register with your registration code.
## 5. Updating
```bash
cd ~/AWCharacterCreator
git pull
docker compose up -d --build # only needed when code changed
```
If only `backend/data/rules.json` changed, `git pull` is enough (it is re-read on every request).
## 6. Backups
```bash
sudo apt install -y sqlite3
mkdir -p ~/backups
crontab -e
# add:
0 3 * * * sqlite3 $HOME/AWCharacterCreator/db/aw.db ".backup '$HOME/backups/aw-$(date +\%F).db'" && find $HOME/backups -name 'aw-*.db' -mtime +14 -delete
```
Copy `~/backups` somewhere off the server now and then.
## 7. Handy commands
```bash
docker compose logs -f # logs
docker compose exec awcc python -m app.admin list # users
docker compose exec awcc python -m app.admin passwd NAME # reset a password
docker compose down # stop
```
If `docker` says it cannot connect to the daemon, check the two `export` lines from step 1 and `systemctl --user status docker`.
## Updating
```bash
cd ~/AWCharacterCreator
git pull
docker compose up -d --build
```
The `db` folder (database and rules editor changes) is kept. New settings from `.env.example` have to be added to your `.env` by hand.
+165 -11
View File
@@ -12,7 +12,29 @@ Website for creating characters for the Arcane Wasteland TTRPG, with a searchabl
docker compose up -d --build
```
Then open `http://<server>:8080`. Characters are stored in `./db/aw.db`. For HTTPS and a domain, put the container behind a reverse proxy (nginx, Caddy, Traefik).
Then open `http://localhost:8090` (the port is bound to the machine itself; see **DEPLOY.md** for running it on a server with its own user, nginx and HTTPS). Users, characters and presets are stored in `./db/aw.db`.
### Login and accounts
Everything (the whole API, rules, characters) is behind a login. The only public things are the login page itself and the app's JavaScript.
1. Copy `.env.example` to `.env` and set `AW_REGISTRATION_CODE`. People need this code **once** to create an account; with an empty code registration is disabled.
2. Open the site, choose "Create account", enter a username, a password (min. 8 characters) and the registration code.
3. Sessions last 30 days (HttpOnly cookie, stored server-side). Logging out or changing the password ends them.
Wrong logins and wrong registration codes are throttled (10 failures per address, 8 per username, 5 wrong codes per address and 30 per hour for the whole server), so a short code is not trivially guessable, but a long one is still better. Passwords are stored as scrypt hashes.
**HTTPS is a must when the site is reachable from the internet**, otherwise passwords travel in plain text. Put the container behind a reverse proxy (Caddy, nginx, Traefik) that terminates TLS, pass the `Host` header through, and set `AW_TRUST_PROXY=1` in `.env` so the app sees the real client address and marks the cookie `Secure`.
Admin tasks (list users, reset a password, delete a user):
```bash
docker compose exec awcc python -m app.admin list
docker compose exec awcc python -m app.admin passwd <username>
docker compose exec awcc python -m app.admin delete <username>
```
Characters and presets made before accounts existed are adopted automatically by the account that logs in from the same browser (the old secret keys are used once and then removed).
## Edit the rules
@@ -30,13 +52,14 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are
### Skills, proficiency and expertise
- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if a profession or proficiency covers it).
- `creation.advanced_attr_percent`: share of the attribute that counts for **advanced skills** (default 50, rounded down; 100 = full attribute like basic skills).
- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if the character's profession grants proficiency in it; proficiency picks do not unlock a skill).
- `creation.free_proficiencies`: free proficiency picks every character gets. Talents can add more with `"proficiency_picks": N` (see "Versatile Training").
- Proficiency is +`proficiency_bonus`; two picks on one skill (a profession proficiency counts as one) give Expertise, +`expertise_bonus`.
### Equipment
`equipment` lists every item with `id`, `name`, `cat` (category), `stat` (damage / armor / type), `kg` and `note`. The starting-kit rules are in `creation.kit` (allowed weapon categories, armor tiers, number of resources). Weights are approximate; for ranges in the source document the upper value is used.
`equipment` lists every item with `id`, `name`, `cat` (category), `stat` (damage / armor / type), `kg` and `note`. There are no starting-kit rules: players pick any items (with an amount) on the Equipment page and the total weight is checked against the carry capacity. Weights are approximate; for ranges in the source document the upper value is used.
## Development
@@ -51,22 +74,153 @@ cd frontend && npm install && npm run dev
## Saving and sharing
There are no user accounts. When saved, a character gets an ID and a secret write key that stays in the browser ("My characters" page). The link `/#creator/<ID>` lets others view the character; only the holder of the key can change it. To move to another browser use the JSON backup (Summary page: "JSON backup" and "Import JSON").
Characters are saved in your account ("My characters" page). Only the owner can see a character, change it or delete it. On "My characters" the **Share** button lets you add usernames (e.g. your DM): they get read-only access, see the character under "Shared with me" and can open it through `/#creator/<ID>`. Saving a viewed character creates their own copy. Use the JSON backup (Summary page) to move a character between servers.
If the site is reachable from the internet, protect it with Basic Auth at the reverse proxy.
Hover (or tap / focus) the dotted-underlined labels and the small "i" icons to see where a number comes from, e.g. how a skill's target number and maximum are derived, or what a derived stat is made of. The explanations are generated from the rules, so they follow presets and `rules.json` changes.
While creating, the full character sheet stays visible next to the wizard. Edits are shown as a difference (old → new, added / removed items) until you press **Apply** (or **Apply & next**); **Discard** drops them.
Each wizard page has a **Reset this page** button that clears only that page's data, plus **Start over** for the whole character.
### Character sheet (A4)
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
### Character portrait
On the **Concept** page *Add picture* takes a PNG, JPEG or WebP: move and zoom it inside the square window, *Use this* stores a small 256 px picture inside the character (so it is saved, shared, exported as JSON and loaded with it). The server checks and re-encodes it. The picture appears in the header of the character sheet (also when printing), as icon in the player bar of the lobby and as the token of the character on the map (in the player's colour ring). Only members of the lobby get the picture through `/api/lobbies/{id}/portrait/{username}`. The PDF / Word export from the server does not contain it yet.
The portrait sits next to the name and identity fields on page 1 of the sheet. The two ruled note areas ("Conditions & notes" on page 1, "Notes" on page 2) can be typed into on the Summary page and, for your own character, in the lobby (saved automatically through `PUT /api/characters/{id}/notes`, which only touches those two fields). They are included in print, PDF and Word.
### Rules editor
Admins get an **Editor** tab to add, change and delete **items, item categories, skills, skill groups and professions** with forms. Admins are the usernames in `AW_ADMINS` (comma separated, set in `.env`); without that setting only the first account ever created is admin. Admins can also promote other users: the **Users and admins** card above the tabs lists all accounts with *Make admin* / *Remove admin*. Names in `AW_ADMINS` stay admin and can only be changed in `.env`; the last admin cannot be removed.
- Changes are saved in `db/rules_overrides.json` (next to the database, so it is part of your backup). For each edited list it **replaces** the list from `rules.json`; the shipped file is not touched. A previous copy is kept as `rules_overrides.json.bak`. "Reset to shipped" removes the override of one list.
- New entries get their id from the name (lowercase, `_`). Ids of existing entries cannot be changed, because characters refer to them.
- The server checks every reference: an item needs an existing category, a skill a group and attributes, a profession existing skills. Removing a category, group or skill that is still used is refused. Removing an item, skill or profession that saved characters use asks for confirmation first (their data stays, the entry disappears from the sheets).
- Talents, mutations, implants, classes and the rules text are still edited in `rules.json`. Players see changes after reloading the page.
### Lobbies
The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
The lobby page is built for desktop with three columns: the **character sheet** on the left (collapsible with the arrow), the **Player Area** in the middle and the **chat** at the right edge of the window.
- **Chat** (right): live chat for everyone, plus **whispers** to a single person (dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes, can be hidden), dice rolls and skill checks. Names are shown in the player's colour. Everything in the lobby (chat, dice, difficulty, colours, sheets) updates live over a **WebSocket** (`/api/lobbies/{id}/ws`); it only sends a short "something changed" note and the data is fetched through the normal API, so the visibility rules (whispers, hidden numbers) stay in one place. If the socket is blocked or drops, the page reconnects and meanwhile polls every few seconds. The chat keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages/rolls per minute.
- **Character bar** (top of the Player Area): one card per player with the character, class and the player's colour. The DM clicks a card to show that character's sheet on the left (always the current saved version, refreshed every 15 s) and can enter a personal **bonus / malus** per character in the small number field. Players see their own sheet only and just a short card of the others. (The bar is also meant for initiative and enemies later.)
- **Skill and attribute checks**: players click an attribute or an unlocked skill on their own sheet. The server rolls a d100 (roll under): target = attribute or skill total + the DM's difficulty + the character's personal modifier. 01-05 is always a critical success, 96-100 always a critical failure. The chat and the table show the roll, the target and by how much it succeeded or failed. Combat is not handled specially: roll the normal skill and let the DM adjust with the modifiers.
- **Difficulty** (bar under the characters): the DM picks a level from the rules table (Trivial +60 ... Hellish -60) or a custom number. It applies to all checks until changed. With **Hide numbers from players** the players only see their roll and success / failure, not the target, difficulty or margin.
- **Dice** (below the table): d4 ... d100 with a count (1 to 10 dice). The server rolls, the dice tumble into the middle of the table in the colour of whoever threw them, and the result goes into the chat. With a person selected as recipient in the chat dropdown it is a private roll. **DM rolls** have three modes: *Mystery* (default: players see grey dice with "?" but not the result), *Hidden* (only the DM sees it) and *Public*.
- **Sound**: rolling plays `frontend/public/dice.mp3` (the default dice sound) with a slight random pitch; every die has its own base pitch, from a little higher and lighter (d4) to a little lower and heavier (d100). Many dice layer the sound up to three times. Checks add a short cue after the dice: built-in tones for success and failure, and for a critical success or failure either a built-in tone or a sound the DM uploaded. The speaker button next to the dice mutes everything for yourself, the slider sets the volume (remembered per browser). Browsers only allow sound after the page was clicked once, and nothing plays while the tab is in the background.
- **DM sound settings** (*Lobby actions* -> *Sounds*): replace the dice sound with an own MP3 (up to 1.5 MB, per lobby) or reset it to the default, upload sounds for *Critical success* and *Critical failure* (reset = built-in tone), and switch the critical sounds and the short success / failure tones on or off for the whole lobby. *Test* plays a sound locally.
- **Own dice**: with this switch on, a click on a die or a skill asks for the result of your real dice instead of rolling. The server only checks the range and marks the roll as "own dice" in the chat.
- **Colours**: every player has a different colour (lobby menu -> Your colour); the DM is gold.
- **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby.
### NPCs, enemies and creatures
The **NPCs** tab is a library of rules-based NPCs. It uses the same wizard as the character creator (attributes, skills, talents, mutations, equipment, portrait), with a free "NPC / creature" preset that has large pools and no profession requirement, plus a type (NPC, enemy, monster, creature, animal, robot), a default token size, a description, special abilities and attacks, and loot.
- NPCs are stored like characters (flag `npc`) and are listed separately; they can be duplicated and shared with other DMs (read-only).
- In the map editor and at the table, **+ From NPC library** / **+ NPC** places a token that takes the NPC's name, size and portrait. Plain **+ Token** still works.
- The DM clicks an NPC token to see its sheet in the left column and can click attributes and skills to roll checks for it (shown in the chat as "NPC: Skill"). Players never see the NPC link or sheet.
Players and the DM show a green dot in the lobby while they have it open (online presence over the WebSocket; without WebSockets everyone appears offline).
### Initiative and fights
The DM presses **⚔ Start fight** in the lobby. Every player with a character joins automatically and rolls initiative (1d20 + the character's Initiative value, as in the rules). **Right click** a token (DM, full view) for a radial menu: add it to the fight, open its sheet, hide/reveal or remove an NPC token. Players who join later can be added with the **+ name** buttons in the initiative bar.
The bar shows the order, the round and whose turn it is (the token pulses on the map). The DM can overwrite every number, reroll, remove entries, step **◀ / Next turn ▶** and **End fight**. Hidden NPCs are not shown to players. Fight start, joins and the end are logged in the chat feed.
The top of the lobby is one compact row: small buttons on the left (start fight, previous/next turn, end fight, ▾ to expand players and difficulty), the player bar – replaced by a scrollable initiative bar during a fight – and the lobby settings button. Players only see the name of whoever's turn it is; NPC names are not sent to players otherwise, and token names show on hover, selection or turn. The sheet and the chat can be resized by dragging the thin lines next to them (remembered per browser).
### HP, conditions and fog of war
- **HP and conditions:** select a token (DM, full view) to edit its HP (−5/−1/+1/+5, max) and conditions from the rules (click raises the level 1 → 3 → off). NPC tokens from the library start with the NPC's Health value. Right click the token and use the **HP** and **Cond.** toggles of the radial menu to decide when players may see them; by default players see their own and other players' values but not NPCs'.
- **Fog of war:** with line of sight on, ground a player has already seen stays visible as a dimmed memory (**Fog** button; **↺** forgets it for everybody). The memory is a small mask per player and lobby, the dim picture is made on the server, so players never get pixels they have not seen.
- **Sight range:** the map's value is the default (map editor); the number next to the Sight button changes it live for the lobby (reset when you switch maps).
### Sight groups and revealing far away areas
- **Sight groups** (Lobby actions, DM): players in the same group (A–D) share what they see; "Everyone shares sight" / "Everyone alone" are shortcuts. Tokens and ground seen by one member are visible to the whole group.
- **👁 Reveal** (DM, line of sight on): click the spot the view starts from, then click where it ends (distance = range, direction = where it looks), pick the cone width and who sees it (everyone or chosen players), then **Reveal**. Walls block it like any other view. The area stays visible until you press **Hide**; afterwards it remains as dimmed memory (fog of war). Everything is computed on the server, so players only receive the pixels you showed them.
### Maps, tokens and line of sight
- **Maps tab** (top navigation): all your maps with preview. Upload PNG, JPEG or WebP (up to 12 MB, 30 maps each; the server checks and re-encodes every image as WebP, max 4096 px per side, into `db/maps/`). **Edit** opens the map editor, **Share** gives other users access by username (like characters: they can put the map on their own table or **Copy** it, but not change it), **Duplicate** copies a map.
- **Map editor**: grid (pixels per square, token sizes are counted in squares), **enemies and NPCs** placed in advance (name, size, colour, *Hidden from players*, duplicate), and **walls and doors** for line of sight: tools *Wall* (click points, chains lines), *Room* (two corners), *Door* (two ends), *Erase*, *Undo last*; new lines snap to existing corners, optionally to grid corners. An optional **sight range** in squares limits how far characters see (0 = unlimited).
- **Show on table** (lobby, DM, *Lobby actions*): shows one of your or a shared map; the prepared tokens are copied into the lobby, so moving them during play never changes the map. Doors start closed. Every player has a token of their own. Without walls the players drag it onto the map themselves; with walls (line of sight) the DM places it from the tray.
- **At the table**: pan (drag the background), zoom (wheel, pinch, buttons) and *Grid* are personal. Players move only their own token; the DM moves everything, edits NPC tokens (click one), adds NPCs (*+ NPC*) and clicks doors to open or close them (*Walls* shows the layout in the full view).
- **Line of sight is strict**: the server calculates what each character can see from the walls and closed doors, cuts the map image to that area (everything else is black, only that piece is ever sent) and leaves out all other tokens. Walls are never sent to players. The DM chooses **Full view** or **View as <player>** (exactly what that player gets) and can switch **Sight on/off** for the table.
Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters.
### Scenes and props
- **Scenes**: every map you put on the table becomes a *scene* of the lobby. Switching to another map (or back, in *Lobby actions → Scenes*) first saves the current one - token positions, HP and conditions, doors, explored ground, revealed areas, sight settings and the running fight - and restores the other exactly as you left it. State also survives pauses, since the table lives in the database. Per lobby up to 12 scenes; rename, delete, *Save now* and *Save a copy of the current table* (checkpoint) are available. Only one scene runs at a time (no simultaneous split party).
- **Edit live**: the map editor can be opened for the running map. Walls, grid and sight range apply to the game at once; the prepared tokens are only used the next time the map is put on the table.
- **Visibility in the editor**: the 👁 button beside each prepared token toggles "hidden from players".
- **Props**: decor tokens (chest, barrel, table, chair, bed, door, campfire, tree, rug, stairs, ... 19 built-in drawings, plus your own PNG/WebP uploads up to 3 MB, 40 per DM). Add them in the map editor (*+ Prop*) or on the table (*+ Prop*); drag like tokens, size width/height in squares, rotate by slider, 45° or 90° steps (also in the right click menu), hide from players. They are drawn below tokens; players cannot move them.
- **Prop library**: the *Maps* page lists all props (built-in and your own) and lets you upload, rename and remove your own ones.
- **DM notes**: NPC tokens and props have a "Notes (only you see these)" field (map editor and table; e.g. the contents of a chest). On the table click the token to edit; a 📝 marks tokens with notes and the text shows as tooltip. Players never receive notes.
- **Fog without walls**: sight range and fog also work on maps without walls (circle around the token). Sight needs walls or a range above 0; the explored memory is now stored at up to 1024 px.
- **Map ping**: everybody (players and DM) can point at a spot: double-click the map, or press *📍 Ping* and click. All see a ring in the pinger's colour with their name, and hear a short ping (rate limited to one per second and person). The DM can switch the sound off or upload an MP3 for it under *Lobby actions → Sounds → Map ping*; players can still mute sounds for themselves.
- **Measure, areas, markers** (for everybody, buttons above the map): *📏 Measure* - click start and end, the distance in squares is shown to the whole table for a few seconds. *⬡ Area* - spell templates (circle, cone with adjustable angle, line with width, square) in the colour of whoever placed them, visible to all until their author or the DM removes them (list top left, DM can clear all); they disappear when the map changes. *📌 Marker* - pins with icon, title and note on the map. Markers are private to their author until "Show to everybody at the table" is ticked; the DM can remove shared markers. They belong to the map, so they are back when you return to that map.
## API
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/rules` | Rule data |
| POST | `/api/characters` | Create a character, returns `id` and `token` |
| GET | `/api/characters/{id}` | Read a character |
| PUT / DELETE | `/api/characters/{id}` | Change / delete, header `X-Edit-Token` |
| POST | `/api/presets` | Create a preset, returns `id` and `token` |
| PUT / DELETE | `/api/presets/{id}` | Change / delete, header `X-Edit-Token` |
| GET | `/api/auth/me` | Current user (or `null`) |
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
| GET | `/api/rules` | Rule data including presets |
| GET / POST | `/api/characters[?npc=1]` | Own and shared-with-me characters (or the NPC library) / create one |
| GET | `/api/characters/{id}/portrait` | Portrait image (readers of the character) |
| POST / PUT / DELETE | `/api/lobbies/{id}/combat` | Start a fight / add, remove, set, reroll, next, prev / end (DM) |
| GET | `/api/lobbies/{id}/token-portrait/{token}` | Portrait of an NPC token (lobby members) |
| GET / PUT | `/api/admin/users[/{username}]` | List accounts / make or remove an admin (admins) |
| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
| GET / POST | `/api/lobbies` | Own and joined lobbies / create one (you are the DM) |
| POST | `/api/lobbies/join` | Join with `{code, character_id}` |
| GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) |
| POST | `/api/lobbies/{id}/code` | New join code (DM only) |
| GET / POST | `/api/maps` | Your maps and the ones shared with you / upload (raw image body, `?name=`) |
| GET / PUT / DELETE | `/api/maps/{id}` | Map with tokens and walls / change (name, grid, sight, tokens, walls) / delete |
| POST / DELETE | `/api/maps/{id}/shares[/{username}]` | Share with a user / stop sharing |
| POST | `/api/maps/{id}/copy` | Your own editable copy |
| GET | `/api/maps/{id}/thumb` | Preview image |
| GET / PUT | `/api/lobbies/{id}/map` | Map and tokens as the viewer may see them (`?as=<player>` for the DM) / DM: show map (`map_id`, null clears) |
| GET | `/api/lobbies/{id}/map/view` | The map image for this viewer, cut to their line of sight (`X-Crop` = x,y,w,h) |
| PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off |
| GET / POST | `/api/lobbies/{id}/scenes` | DM: saved scenes / save a copy of the table (`name`) |
| PUT / DELETE | `/api/lobbies/{id}/scenes/{sid}` | DM: rename (`name`), save now (`save`) / delete |
| POST | `/api/lobbies/{id}/measure` | Share a measured distance (`x1,y1,x2,y2`) |
| POST / DELETE | `/api/lobbies/{id}/templates[/{tid}]` | Place an area (`kind, x, y, dir, size, width`) / remove (author or DM; DELETE without id: DM clears all) |
| POST / PUT / DELETE | `/api/lobbies/{id}/marks[/{kid}]` | Marker with note (`x, y, icon, title, note, shared`) / edit (author) / remove |
| POST | `/api/lobbies/{id}/ping` | Ping a spot (`x`, `y`); everybody gets a push |
| PUT | `/api/lobbies/{id}/scene` | DM: switch to a scene (`scene_id`); the current one is saved first |
| GET / POST / DELETE | `/api/props[/{id}]` | Your own prop images / upload (raw body, `?name=`) / delete |
| GET | `/api/props/{id}/image`, `/api/lobbies/{id}/prop-image/{pid}` | Prop image (owner / lobby members, DM's props) |
| POST / PUT / DELETE | `/api/lobbies/{id}/tokens[/{tid}]` | Add NPC token (DM) / move (own token or DM) and edit (DM) / remove (DM) |
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet / map}` for lobby members (cookie login, same origin) |
| PUT | `/api/lobbies/{id}/sounds` | DM: switch result / critical sounds on or off |
| GET / POST / DELETE | `/api/lobbies/{id}/sounds/{roll\|crit_success\|crit_failure}` | Custom lobby sound (MP3): everyone gets it / DM uploads (raw body) / DM resets |
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` |
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
| PUT | `/api/lobbies/{id}/difficulty` | `{label, mod, hidden}` (DM only) |
| PUT | `/api/lobbies/{id}/members/{username}/mod` | Personal bonus / malus `{mod}` (DM only) |
| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character and / or `{color}` / leave |
| DELETE | `/api/lobbies/{id}/members/{username}` | Remove a player (DM only) |
| POST | `/api/presets` | Create a preset |
| PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) |
| POST | `/api/export/pdf` or `/docx` | Character JSON in the body, file in the response |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
"""Small admin CLI: python -m app.admin list | passwd <user> | delete <user>"""
import getpass
import sys
from . import auth
from .main import db
def main(argv):
if not argv:
print(__doc__)
return 1
cmd = argv[0]
with db() as con:
if cmd == "list":
for r in con.execute("SELECT username, created FROM users ORDER BY id"):
print(r["username"])
elif cmd == "passwd" and len(argv) == 2:
pw = getpass.getpass("New password: ")
if len(pw) < auth.MIN_PASSWORD:
print(f"Password must have at least {auth.MIN_PASSWORD} characters.")
return 1
cur = con.execute("UPDATE users SET pw_hash=? WHERE username=?", (auth.hash_password(pw), argv[1]))
if not cur.rowcount:
print("No such user.")
return 1
con.execute("DELETE FROM sessions WHERE user_id=(SELECT id FROM users WHERE username=?)", (argv[1],))
print("Password changed, user logged out everywhere.")
elif cmd == "delete" and len(argv) == 2:
row = con.execute("SELECT id FROM users WHERE username=?", (argv[1],)).fetchone()
if not row:
print("No such user.")
return 1
con.execute("DELETE FROM sessions WHERE user_id=?", (row["id"],))
con.execute("DELETE FROM shares WHERE user_id=?", (row["id"],))
con.execute("DELETE FROM lobby_members WHERE user_id=? OR lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"], row["id"]))
con.execute("DELETE FROM lobby_messages WHERE lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"],))
con.execute("DELETE FROM lobbies WHERE owner_id=?", (row["id"],))
con.execute("DELETE FROM users WHERE id=?", (row["id"],))
print("User deleted. Their lobbies are removed; their characters and presets stay on the server without an owner.")
else:
print(__doc__)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+106
View File
@@ -0,0 +1,106 @@
"""Accounts, sessions and brute-force protection (standard library only)."""
import hashlib
import hmac
import os
import re
import secrets
import sqlite3
import threading
import time
SESSION_COOKIE = "aw_session"
SESSION_DAYS = 30
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
MIN_PASSWORD = 8
MAX_PASSWORD = 200
# scrypt parameters (about 16 MB of memory per hash)
_N, _R, _P = 2 ** 14, 8, 1
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
h = hashlib.scrypt(password.encode(), salt=salt, n=_N, r=_R, p=_P, dklen=32)
return f"scrypt${_N}${_R}${_P}${salt.hex()}${h.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
_, n, r, p, salt, h = stored.split("$")
calc = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=int(n), r=int(r), p=int(p), dklen=32)
return hmac.compare_digest(calc.hex(), h)
except Exception:
return False
_DUMMY = hash_password("dummy-password-for-timing")
def init_tables(con: sqlite3.Connection) -> None:
con.execute("""CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE,
pw_hash TEXT NOT NULL, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires REAL NOT NULL)""")
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def create_session(con: sqlite3.Connection, user_id: int) -> str:
token = secrets.token_urlsafe(32)
con.execute("DELETE FROM sessions WHERE expires < ?", (time.time(),))
con.execute("INSERT INTO sessions VALUES (?,?,?)", (_hash_token(token), user_id, time.time() + SESSION_DAYS * 86400))
return token
def user_for_token(con: sqlite3.Connection, token: str | None):
if not token:
return None
row = con.execute(
"SELECT u.id, u.username, s.expires FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?",
(_hash_token(token),)).fetchone()
if not row or row["expires"] < time.time():
return None
return {"id": row["id"], "username": row["username"]}
def end_session(con: sqlite3.Connection, token: str | None) -> None:
if token:
con.execute("DELETE FROM sessions WHERE token_hash = ?", (_hash_token(token),))
def timing_dummy(password: str) -> None:
"""Burn the same time as a real check when the user does not exist."""
verify_password(password, _DUMMY)
class Limiter:
"""In-memory failure counter: too many failures within the window block further attempts."""
def __init__(self):
self.lock = threading.Lock()
self.hits: dict[str, list[float]] = {}
def blocked(self, key: str, limit: int, window: int) -> int:
"""Returns seconds to wait (0 = allowed)."""
now = time.time()
with self.lock:
xs = [t for t in self.hits.get(key, []) if now - t < window]
self.hits[key] = xs
return int(window - (now - xs[0])) + 1 if len(xs) >= limit else 0
def fail(self, key: str) -> None:
with self.lock:
self.hits.setdefault(key, []).append(time.time())
if len(self.hits) > 20000: # keep memory bounded
self.hits.clear()
def reset(self, key: str) -> None:
with self.lock:
self.hits.pop(key, None)
def registration_code() -> str:
return os.environ.get("AW_REGISTRATION_CODE", "")
+172
View File
@@ -0,0 +1,172 @@
"""Initiative tracker. One fight per lobby, stored as JSON on the lobby row.
{"round": 1, "current": "<entry id>", "entries": [{"id", "kind": "player"|"npc", "user": username, "token": token id,
"name", "init": number, "base": initiative modifier, "roll": d20}]}
The DM starts and ends the fight; players are added automatically at the start. Initiative is 1d20 + the character's
Initiative value (Agility Mod + Perception Mod), the DM can overwrite every number."""
import json
import secrets
import uuid
from fastapi import Body, Depends, HTTPException
from .derive import compute
MAX_ENTRIES = 60
def schema(con):
if "combat" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]:
con.execute("ALTER TABLE lobbies ADD COLUMN combat TEXT")
def _sorted(c: dict):
c["entries"].sort(key=lambda e: (-(e["init"] if e["init"] is not None else -999), e.get("seq", 0)))
def install(api, db, current_user, lobby_for, dm_only, hub, event, load_rules):
def base_of(data, rules) -> int:
try:
return int(compute(json.loads(data), rules)["derived"].get("initiative", 0))
except Exception:
return 0
def new_entry(c, kind, name, base, **kw):
roll = secrets.randbelow(20) + 1
c["seq"] = c.get("seq", 0) + 1
return {"id": uuid.uuid4().hex[:10], "kind": kind, "name": name, "base": base, "roll": roll, "init": roll + base, "seq": c["seq"], **kw}
def load(lobby):
return json.loads(lobby["combat"]) if lobby["combat"] else None
def save(con, lid, c, log=None, uid=None):
con.execute("UPDATE lobbies SET combat=? WHERE id=?", (json.dumps(c) if c else None, lid))
if log:
event(con, lid, log, uid)
hub.notify(lid, "lobby")
def view(con, lobby, is_dm):
"""What the viewer gets: hidden NPCs are left out for players; NPCs whose token is gone disappear."""
c = load(lobby)
if not c:
return None
toks = {r["id"]: r["hidden"] for r in con.execute("SELECT id, hidden FROM lobby_tokens WHERE lobby_id=?", (lobby["id"],))}
out = []
for e in c["entries"]:
if e["kind"] == "npc":
if e["token"] not in toks:
continue
if toks[e["token"]] and not is_dm:
continue
if e["kind"] == "npc" and not is_dm and e["id"] != c.get("current"):
e = {**e, "name": ""} # players only learn an NPC's name when it is its turn
out.append({k: e[k] for k in ("id", "kind", "name", "init", "base", "roll") if k in e} | {k: e[k] for k in ("user", "token") if k in e})
cur = c.get("current")
return {"round": c["round"], "current": cur if any(e["id"] == cur for e in out) else None, "entries": out}
@api.post("/lobbies/{lid}/combat")
def start(lid: str, user: dict = Depends(current_user)):
rules = load_rules()
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
c = {"round": 1, "current": None, "seq": 0, "entries": []}
for r in con.execute("""SELECT u.username, ch.data FROM lobby_members m JOIN users u ON u.id = m.user_id
JOIN characters ch ON ch.id = m.character_id WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)):
nm = json.loads(r["data"]).get("name") or r["username"]
c["entries"].append(new_entry(c, "player", nm, base_of(r["data"], rules), user=r["username"]))
_sorted(c)
c["current"] = c["entries"][0]["id"] if c["entries"] else None
save(con, lid, c, "⚔ A fight begins. Initiative: " + ", ".join(f"{e['name']} {e['init']}" for e in c["entries"]), user["id"])
return {"ok": True}
@api.put("/lobbies/{lid}/combat")
def change(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
rules = load_rules()
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
c = load(lobby)
if not c:
raise HTTPException(409, "There is no fight")
op, eid = body.get("op"), str(body.get("id") or "")
ents = c["entries"]
ent = next((e for e in ents if e["id"] == eid), None)
log = None
if op == "add":
if len(ents) >= MAX_ENTRIES:
raise HTTPException(409, "Too many entries")
if body.get("token"):
t = con.execute("""SELECT t.id, t.name, t.user_id, n.data FROM lobby_tokens t LEFT JOIN characters n ON n.id = t.npc_id
WHERE t.id=? AND t.lobby_id=?""", (str(body["token"]), lid)).fetchone()
if not t or t["user_id"]:
raise HTTPException(404, "No NPC token")
if any(e.get("token") == t["id"] for e in ents):
raise HTTPException(409, "Already in the fight")
e = new_entry(c, "npc", t["name"], base_of(t["data"], rules) if t["data"] else 0, token=t["id"])
elif body.get("user"):
r = con.execute("""SELECT u.username, ch.data FROM lobby_members m JOIN users u ON u.id = m.user_id
JOIN characters ch ON ch.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, str(body["user"]))).fetchone()
if not r:
raise HTTPException(404, "No such player with a character")
if any(e.get("user") == r["username"] for e in ents):
raise HTTPException(409, "Already in the fight")
e = new_entry(c, "player", json.loads(r["data"]).get("name") or r["username"], base_of(r["data"], rules), user=r["username"])
else:
raise HTTPException(422, "Say which token or player to add")
ents.append(e)
log = f"{e['name']} joins the fight with initiative {e['init']}"
elif op == "remove":
if not ent:
raise HTTPException(404, "No such entry")
if c["current"] == eid:
i = ents.index(ent)
nxt = ents[(i + 1) % len(ents)] if len(ents) > 1 else None
c["current"] = nxt["id"] if nxt else None
ents.remove(ent)
elif op == "init":
if not ent:
raise HTTPException(404, "No such entry")
try:
ent["init"] = max(-99, min(999, int(body.get("value"))))
except (TypeError, ValueError):
raise HTTPException(422, "Initiative must be a whole number")
elif op == "reroll":
if not ent:
raise HTTPException(404, "No such entry")
ent["roll"] = secrets.randbelow(20) + 1
ent["init"] = ent["roll"] + ent["base"]
log = f"{ent['name']} rerolls initiative: {ent['init']}"
elif op in ("next", "prev"):
_sorted(c)
if ents:
i = next((k for k, e in enumerate(ents) if e["id"] == c.get("current")), -1)
if op == "next":
if i + 1 >= len(ents):
c["round"] += 1
i = (i + 1) % len(ents)
else:
if i <= 0:
c["round"] = max(1, c["round"] - 1)
i = (i - 1) % len(ents)
c["current"] = ents[i]["id"]
else:
raise HTTPException(422, "Unknown action")
cur = c.get("current")
_sorted(c)
if cur and not any(e["id"] == cur for e in c["entries"]):
c["current"] = c["entries"][0]["id"] if c["entries"] else None
save(con, lid, c, log, user["id"])
return {"ok": True}
@api.delete("/lobbies/{lid}/combat")
def end(lid: str, user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
c = load(lobby)
if c:
save(con, lid, None, f"The fight is over after {c['round']} round{'s' if c['round'] != 1 else ''}.", user["id"])
return {"ok": True}
return view
+12 -14
View File
@@ -2,20 +2,17 @@
def kit_items(ch: dict, rules: dict) -> list:
"""Returns [(label, item, qty)] for the starting kit and extra items."""
"""Returns [(category, item, qty)] for the carried items. Old starting-kit slots count as one item each."""
items = {e["id"]: e for e in rules.get("equipment", [])}
cats = {c["id"]: c["name"] for c in rules.get("equipment_categories", [])}
kit = ch.get("kit") or {}
out = []
for label, key in (("Weapon", "weapon"), ("Armor", "armor"), ("Tool", "tool")):
if kit.get(key) in items:
out.append((label, items[kit[key]], 1))
for rid in kit.get("resources", []):
if rid in items:
out.append(("Resource", items[rid], 1))
for iid, qty in (kit.get("extras") or {}).items():
if iid in items and int(qty) > 0:
out.append(("Extra", items[iid], int(qty)))
return out
qty: dict = {}
for iid in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or [])]:
if iid:
qty[iid] = qty.get(iid, 0) + 1
for iid, n in (kit.get("extras") or {}).items():
qty[iid] = qty.get(iid, 0) + int(n)
return [(cats.get(items[i]["cat"], ""), items[i], n) for i, n in qty.items() if i in items and n > 0]
def compute(ch: dict, rules: dict) -> dict:
@@ -40,10 +37,11 @@ def compute(ch: dict, rules: dict) -> dict:
pips = prof_pip + max(0, free_pips)
prof = [0, cr["proficiency_bonus"], cr["expertise_bonus"]][pips]
unlocked = s["id"] in (ch.get("unlocked") or [])
usable = s["type"] == "basic" or unlocked or prof > 0 # advanced skills must be unlocked
usable = s["type"] == "basic" or unlocked or prof_pip > 0 # advanced skills must be unlocked (a profession proficiency does it for free)
best = max(attrs[a] for a in s["attrs"]) # with several attributes the higher one counts
base = best * cr.get("advanced_attr_percent", 100) // 100 if s["type"] == "advanced" else best # advanced skills only use part of the attribute
skills.append({**s, "points": pts, "prof": prof, "pips": pips, "usable": usable, "unlocked": unlocked,
"total": best + pts + prof})
"base": base, "total": base + pts + prof})
aug_by_id = {a["id"]: a for a in rules["augments"]}
chosen = [aug_by_id[i] for i in ch.get("augments", []) if i in aug_by_id]
+6 -6
View File
@@ -40,8 +40,8 @@ def _sheet(ch: dict, rules: dict) -> dict:
"derived_rows": [[d["name"], str(c["derived"][d["id"]])] for d in rules["derived"]],
"skill_rows": [
[s["name"], "/".join(a["short"] for a in rules["attributes"] if a["id"] in s["attrs"]),
str(s["points"]), {0: "", 1: "Proficient", 2: "Expertise"}.get(s["pips"], ""), str(s["total"])]
for s in c["skills"] if s["points"] or s["prof"] or (s["unlocked"] and s["type"] == "advanced")
str(s["points"]), {1: "Proficient", 2: "Expertise"}.get(s["pips"]) or ("" if s["usable"] else "Locked"), str(s["total"])]
for s in c["skills"]
],
"augment_rows": [[a["name"], KIND.get(a["kind"], a["kind"]), SOURCE.get(a["source"]) or "-", str(a["cost"])]
for a in c["augments"]],
@@ -92,14 +92,14 @@ def to_docx(ch: dict, rules: dict) -> bytes:
doc.add_paragraph("-")
doc.add_heading(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", 1)
if s["gear_rows"]:
table(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
table(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
else:
doc.add_paragraph("-")
if s["other"]:
p = doc.add_paragraph()
p.add_run("Other: ").bold = True
p.add_run(s["other"])
for key, head in (("background", "Background"), ("notes", "Notes")):
for key, head in (("background", "Background"), ("conditions", "Conditions"), ("notes", "Notes")):
if ch.get(key):
doc.add_heading(head, 1)
doc.add_paragraph(ch[key])
@@ -166,12 +166,12 @@ def to_pdf(ch: dict, rules: dict) -> bytes:
el.append(Paragraph(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", h2))
if s["gear_rows"]:
el.append(tbl(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [22 * mm, 65 * mm, 55 * mm, 22 * mm]))
el.append(tbl(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [30 * mm, 60 * mm, 50 * mm, 22 * mm]))
else:
el.append(Paragraph("-", body))
if s["other"]:
el.append(Paragraph(f"<b>Other:</b> {esc(s['other'])}", body))
for key, head in (("background", "Background"), ("notes", "Notes")):
for key, head in (("background", "Background"), ("conditions", "Conditions"), ("notes", "Notes")):
if ch.get(key):
el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)]
+1100 -62
View File
File diff suppressed because it is too large Load Diff
+1218
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
"""Character portraits: a small square picture stored inside the character as a data URL (so it travels with saving, sharing and
loading). The browser crops it to a square; the server checks and re-encodes it (256 px, WebP) so only real images get stored."""
import base64
import binascii
import hashlib
import io
from fastapi import HTTPException
from PIL import Image, ImageOps
SIZE = 256
MAX_RAW = 400_000 # bytes of the uploaded data before re-encoding
def clean(ch: dict) -> None:
"""Validates and re-encodes ch["portrait"] in place (an empty value removes it)."""
p = ch.get("portrait")
if not p:
ch["portrait"] = ""
return
if not isinstance(p, str) or not p.startswith("data:image/") or ";base64," not in p:
raise HTTPException(422, "The portrait must be an image")
try:
raw = base64.b64decode(p.split(";base64,", 1)[1], validate=True)
if len(raw) > MAX_RAW:
raise HTTPException(413, "The portrait is too large")
im = Image.open(io.BytesIO(raw))
if im.format not in ("PNG", "JPEG", "WEBP"):
raise ValueError
im.load()
except HTTPException:
raise
except (binascii.Error, ValueError, OSError, Image.DecompressionBombError):
raise HTTPException(422, "The portrait must be a PNG, JPEG or WebP image")
im = ImageOps.fit(ImageOps.exif_transpose(im).convert("RGB"), (SIZE, SIZE))
out = io.BytesIO()
im.save(out, "WEBP", quality=85)
ch["portrait"] = "data:image/webp;base64," + base64.b64encode(out.getvalue()).decode()
def tag(portrait) -> str:
"""Short fingerprint, used as cache key in URLs ("" if there is no portrait)."""
return hashlib.sha1(portrait.encode()).hexdigest()[:10] if portrait else ""
def image(portrait) -> bytes | None:
if not portrait or ";base64," not in portrait:
return None
try:
return base64.b64decode(portrait.split(";base64,", 1)[1])
except (binascii.Error, ValueError):
return None
+79
View File
@@ -0,0 +1,79 @@
"""Real-time pushes for lobbies over WebSockets.
The sockets only carry short "something changed" notes ({"t": "messages"} or {"t": "lobby"}). The browser then loads the
data through the normal API, so all visibility rules (whispers, hidden rolls, who sees which character) stay in one place.
Without a working socket the client keeps polling, so nothing breaks when a proxy blocks WebSockets."""
import asyncio
import threading
MAX_PER_USER = 6 # open sockets per user (several tabs)
MAX_PER_LOBBY = 80
class Hub:
def __init__(self):
self.rooms: dict = {} # lobby id -> {websocket: user id}
self.loop = None
self.lock = threading.Lock()
def attach(self, loop):
self.loop = loop
def add(self, lid: str, ws, uid: int) -> bool:
with self.lock:
room = self.rooms.setdefault(lid, {})
if len(room) >= MAX_PER_LOBBY or sum(1 for u in room.values() if u == uid) >= MAX_PER_USER:
return False
room[ws] = uid
return True
def remove(self, lid: str, ws):
with self.lock:
room = self.rooms.get(lid)
if room:
room.pop(ws, None)
if not room:
del self.rooms[lid]
def online(self, lid: str) -> set:
with self.lock:
return set(self.rooms.get(lid, {}).values())
async def _send(self, lid: str, note: dict, users):
with self.lock:
targets = [ws for ws, uid in self.rooms.get(lid, {}).items() if users is None or uid in users]
for ws in targets:
try:
await ws.send_json(note)
except Exception:
self.remove(lid, ws)
def notify(self, lid: str, kind: str, users=None, delay: float = 0.08, **extra):
"""Callable from normal (threadpool) endpoints. The short delay lets the database transaction finish first."""
if not self.loop or lid not in self.rooms:
return
async def go():
await asyncio.sleep(delay)
await self._send(lid, {"t": kind, **extra}, users)
asyncio.run_coroutine_threadsafe(go(), self.loop)
def close_user(self, lid: str, uid: int):
"""Drops the sockets of someone who left or was removed."""
if not self.loop:
return
with self.lock:
targets = [ws for ws, u in self.rooms.get(lid, {}).items() if u == uid]
for ws in targets:
asyncio.run_coroutine_threadsafe(ws.close(code=4403), self.loop)
def close_lobby(self, lid: str):
if not self.loop:
return
with self.lock:
targets = list(self.rooms.get(lid, {}))
for ws in targets:
asyncio.run_coroutine_threadsafe(ws.close(code=4404), self.loop)
hub = Hub()
+125
View File
@@ -0,0 +1,125 @@
"""Validation for the rules editor (items, skills, professions and their categories).
Only whitelisted fields are kept and every reference (category, group, attribute, skill) has to exist."""
import re
SECTIONS = ("equipment", "equipment_categories", "skills", "skill_groups", "professions")
MAX_ENTRIES = 400
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
class RuleError(ValueError):
pass
def _text(v, limit, label, required=False):
v = str(v if v is not None else "").strip()
if required and not v:
raise RuleError(f"{label} must not be empty")
if len(v) > limit:
raise RuleError(f"{label} is too long (max {limit} characters)")
return v
def _num(v, lo, hi, label, integer=False):
try:
n = float(v)
except (TypeError, ValueError):
raise RuleError(f"{label} must be a number")
if not lo <= n <= hi:
raise RuleError(f"{label} must be between {lo} and {hi}")
return int(n) if integer else round(n, 2)
def _ids(v, allowed, label, lo=0, hi=50):
if not isinstance(v, list) or not lo <= len(v) <= hi:
raise RuleError(f"{label}: choose {lo} to {hi}" if hi < 50 else f"{label} must be a list")
out = []
for x in v:
if x not in allowed:
raise RuleError(f"{label}: unknown entry '{x}'")
if x not in out:
out.append(x)
return out
def clean(section: str, items, rules: dict) -> list:
"""Returns the cleaned list for one section. `rules` is the effective rules (for the other sections' ids)."""
if section not in SECTIONS:
raise RuleError("Unknown section")
if not isinstance(items, list) or len(items) > MAX_ENTRIES:
raise RuleError(f"A list of at most {MAX_ENTRIES} entries is expected")
attrs = {a["id"] for a in rules["attributes"]}
out, seen = [], set()
cats = {c["id"] for c in (items if section == "equipment_categories" else rules["equipment_categories"])}
groups = {g["id"] for g in (items if section == "skill_groups" else rules["skill_groups"])}
skills = {s["id"] for s in (items if section == "skills" else rules["skills"])}
for it in items:
if not isinstance(it, dict):
raise RuleError("Every entry has to be an object")
iid = str(it.get("id", ""))
if not ID_RE.match(iid):
raise RuleError(f"Invalid id '{iid}' (lowercase letters, digits and _ only, up to 40)")
if iid in seen:
raise RuleError(f"The id '{iid}' is used twice")
seen.add(iid)
name = _text(it.get("name"), 60, f"Name of '{iid}'", True)
e = {"id": iid, "name": name}
label = f"'{name}'"
if section == "equipment":
if it.get("cat") not in cats:
raise RuleError(f"{label}: unknown category")
e.update(cat=it["cat"], stat=_text(it.get("stat"), 40, f"{label} stat"), kg=_num(it.get("kg", 0), 0, 1000, f"{label} weight"),
note=_text(it.get("note"), 200, f"{label} note"))
if it.get("tier"):
e["tier"] = _text(it["tier"], 20, f"{label} tier")
elif section == "equipment_categories":
e["stat"] = _text(it.get("stat"), 40, f"{label} stat column")
elif section == "skills":
if it.get("group") not in groups:
raise RuleError(f"{label}: unknown group")
if it.get("type") not in ("basic", "advanced"):
raise RuleError(f"{label}: type must be basic or advanced")
a = _ids(it.get("attrs"), attrs, f"{label} attributes", 1, 3)
e.update(group=it["group"], attrs=a, type=it["type"], desc=_text(it.get("desc"), 300, f"{label} description"))
elif section == "professions":
e.update(desc=_text(it.get("desc"), 500, f"{label} description"), bonus_attrs=_ids(it.get("bonus_attrs"), attrs, f"{label} bonus attributes", 0, 7),
proficiencies=_ids(it.get("proficiencies"), skills, f"{label} proficiencies", 0, 12),
picks=_num(it.get("picks", 0), 0, 10, f"{label} free picks", True), note=_text(it.get("note"), 200, f"{label} note"))
if not e["note"]:
del e["note"]
if it.get("placeholder"):
e["placeholder"] = True
out.append(e)
return out
def dangling(section: str, new: list, rules: dict) -> list:
"""Things in the other sections that would point to an entry that no longer exists."""
ids = {x["id"] for x in new}
msgs = []
if section == "equipment_categories":
msgs += [f"Item '{e['name']}' uses the removed category" for e in rules["equipment"] if e["cat"] not in ids]
if section == "skill_groups":
msgs += [f"Skill '{s['name']}' uses the removed group" for s in rules["skills"] if s["group"] not in ids]
if section == "skills":
for p in rules["professions"]:
gone = [i for i in p["proficiencies"] if i not in ids]
if gone:
msgs.append(f"Profession '{p['name']}' grants proficiency in a removed skill ({', '.join(gone)})")
return msgs
def character_refs(ch: dict) -> dict:
"""Ids a saved character points to, per section."""
kit = ch.get("kit") or {}
items = {i for i in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or []), *(kit.get("extras") or {})] if i}
sk = set((ch.get("skill_points") or {}).keys()) | set((ch.get("proficiencies") or {}).keys()) | set((ch.get("proficiency_picks") or {}).keys()) \
| set(ch.get("unlocked") or []) | set(ch.get("profession_picks") or [])
return {"equipment": items, "skills": sk, "professions": {ch["profession"]} if ch.get("profession") else set()}
def usage(chars: list, section: str, removed: set) -> int:
"""How many saved characters use one of the removed ids."""
if section not in ("equipment", "skills", "professions") or not removed:
return 0
return sum(1 for ch in chars if character_refs(ch)[section] & removed)
+104
View File
@@ -0,0 +1,104 @@
"""Lobby sounds: the DM can replace the dice sound and add sounds for critical successes / failures (MP3, per lobby)."""
import json
from pathlib import Path
from fastapi import Body, Depends, HTTPException, Request
from fastapi.responses import Response
MAX_SOUND = 1_500_000 # bytes per file
SLOTS = ("roll", "crit_success", "crit_failure", "ping")
FLAGS = ("result", "crit_success", "crit_failure", "ping") # result = the short tones for success / failure of a check
def schema(con):
if "sounds" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]:
con.execute("ALTER TABLE lobbies ADD COLUMN sounds TEXT NOT NULL DEFAULT '{}'")
def settings(raw) -> dict:
try:
s = json.loads(raw or "{}")
except ValueError:
s = {}
return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True), "ping": s.get("ping", True),
"custom": {k: bool(s.get("custom", {}).get(k)) for k in SLOTS}, "v": int(s.get("v", 0))}
def install(api, db, current_user, lobby_for, dm_only, hub, store_dir: Path):
store_dir.mkdir(parents=True, exist_ok=True)
def path(lid: str, slot: str) -> Path:
if slot not in SLOTS:
raise HTTPException(404, "Unknown sound")
return store_dir / f"{lid}_{slot}.mp3"
def save(con, lid, s):
s["v"] += 1
con.execute("UPDATE lobbies SET sounds=? WHERE id=?", (json.dumps(s), lid))
@api.put("/lobbies/{lid}/sounds")
def set_flags(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
s = settings(lobby["sounds"])
for k in FLAGS:
if k in body:
s[k] = bool(body[k])
save(con, lid, s)
hub.notify(lid, "lobby")
return {"ok": True}
@api.post("/lobbies/{lid}/sounds/{slot}")
async def upload_sound(lid: str, slot: str, request: Request, user: dict = Depends(current_user)):
"""Raw MP3 bytes in the body."""
p = path(lid, slot)
with db() as con:
dm_only(lobby_for(con, lid, user), user)
if int(request.headers.get("content-length") or 0) > MAX_SOUND:
raise HTTPException(413, f"The file is too large (at most {MAX_SOUND // 1_000_000} MB)")
chunks, size = [], 0
async for c in request.stream():
size += len(c)
if size > MAX_SOUND:
raise HTTPException(413, f"The file is too large (at most {MAX_SOUND // 1_000_000} MB)")
chunks.append(c)
data = b"".join(chunks)
if not (data[:3] == b"ID3" or (len(data) > 4 and data[0] == 0xFF and data[1] & 0xE0 == 0xE0)):
raise HTTPException(422, "Please upload an MP3 file")
p.write_bytes(data)
with db() as con:
lobby = lobby_for(con, lid, user)
s = settings(lobby["sounds"])
s["custom"][slot] = True
save(con, lid, s)
hub.notify(lid, "lobby")
return {"ok": True}
@api.delete("/lobbies/{lid}/sounds/{slot}")
def reset_sound(lid: str, slot: str, user: dict = Depends(current_user)):
p = path(lid, slot)
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
s = settings(lobby["sounds"])
s["custom"][slot] = False
save(con, lid, s)
p.unlink(missing_ok=True)
hub.notify(lid, "lobby")
return {"ok": True}
@api.get("/lobbies/{lid}/sounds/{slot}")
def get_sound(lid: str, slot: str, v: int = 0, user: dict = Depends(current_user)):
p = path(lid, slot)
with db() as con:
lobby_for(con, lid, user)
if not p.exists():
raise HTTPException(404, "No custom sound")
return Response(p.read_bytes(), media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"})
def purge(lid: str):
for slot in SLOTS:
path(lid, slot).unlink(missing_ok=True)
return purge
+148
View File
@@ -0,0 +1,148 @@
"""Line of sight: visibility polygon from a point around wall segments, and cutting the map image to it.
The server cuts the image, so a player's browser only ever receives the pixels their character can see (no peeking through
dev tools). Pure Python on purpose (no numpy): a few hundred wall segments are fast enough, and results are cached."""
import io
import math
from collections import OrderedDict
from PIL import Image, ImageDraw
EPS = 1e-5
def polygon(origin, segs, w, h, radius=0.0, sector=None):
"""Points of the area visible from origin. segs: [(x1, y1, x2, y2)] that block sight; radius 0 means unlimited.
sector = (direction, half angle) in radians limits the view to a cone."""
ox, oy = origin
border = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)]
if radius:
r2 = (radius + 1) ** 2
def near(s):
x1, y1, x2, y2 = s
dx, dy = x2 - x1, y2 - y1
L = dx * dx + dy * dy
t = 0 if L == 0 else max(0, min(1, ((ox - x1) * dx + (oy - y1) * dy) / L))
return (x1 + t * dx - ox) ** 2 + (y1 + t * dy - oy) ** 2 <= r2
segs = [s for s in segs if near(s)]
allsegs = segs + border
angles = []
for x1, y1, x2, y2 in allsegs:
for x, y in ((x1, y1), (x2, y2)):
if radius and (x - ox) ** 2 + (y - oy) ** 2 > (radius * 3) ** 2:
continue
a = math.atan2(y - oy, x - ox)
angles += (a - EPS, a, a + EPS)
if radius:
angles += [i * (2 * math.pi / 120) for i in range(120)]
if sector:
d, half = sector
rel = [(a - d + math.pi) % (2 * math.pi) - math.pi for a in angles]
rel = [a for a in rel if abs(a) <= half] + [-half, half] + [-half + i * (2 * half / 48) for i in range(49)]
angles = [d + a for a in sorted(rel)]
else:
angles.sort()
pts = []
for a in angles:
dx, dy = math.cos(a), math.sin(a)
best = radius if radius else float("inf")
for x1, y1, x2, y2 in allsegs:
ex, ey = x2 - x1, y2 - y1
den = dx * ey - dy * ex
if abs(den) < 1e-12:
continue
px, py = x1 - ox, y1 - oy
t = (px * ey - py * ex) / den
if t < 0 or t >= best:
continue
u = (px * dy - py * dx) / den
if -1e-9 <= u <= 1 + 1e-9:
best = t
if best == float("inf"):
continue
pts.append((ox + dx * best, oy + dy * best))
return [(ox, oy)] + pts if sector else pts
def inside(pt, poly) -> bool:
x, y = pt
c = False
j = len(poly) - 1
for i in range(len(poly)):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
c = not c
j = i
return c
class Lru(OrderedDict):
def __init__(self, size):
super().__init__()
self.size = size
def put(self, k, v):
self[k] = v
self.move_to_end(k)
while len(self) > self.size:
self.popitem(last=False)
def fetch(self, k):
if k in self:
self.move_to_end(k)
return self[k]
_images = Lru(2)
_polys = Lru(128)
_cuts = Lru(24)
fog_cache = Lru(12)
def cached_polygon(key, make):
v = _polys.fetch(key)
if v is None:
v = make()
_polys.put(key, v)
return v
def source(path):
key = (str(path), path.stat().st_mtime_ns)
im = _images.fetch(key)
if im is None:
im = Image.open(path)
im.load()
im = im.convert("RGB")
_images.put(key, im)
return im
def inside_any(pt, polys) -> bool:
return any(inside(pt, p) for p in polys)
def cut(path, polys, key):
"""The part of the image inside the polygon (everything else transparent), cropped to its bounding box.
Returns (webp bytes, (x, y, w, h))."""
hit = _cuts.fetch(key)
if hit:
return hit
im = source(path)
xs, ys = [p[0] for poly in polys for p in poly], [p[1] for poly in polys for p in poly]
x0, y0 = max(0, int(math.floor(min(xs)))), max(0, int(math.floor(min(ys))))
x1, y1 = min(im.width, int(math.ceil(max(xs)))), min(im.height, int(math.ceil(max(ys))))
w, h = max(1, x1 - x0), max(1, y1 - y0)
mask = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(mask)
for poly in polys:
if len(poly) > 2:
draw.polygon([(x - x0, y - y0) for x, y in poly], fill=255)
piece = im.crop((x0, y0, x0 + w, y0 + h)).convert("RGBA")
piece.putalpha(mask) # transparent outside the view, so remembered (dimmed) ground underneath stays visible
out = io.BytesIO()
piece.save(out, "WEBP", quality=82, method=1)
res = (out.getvalue(), (x0, y0, w, h))
_cuts.put(key, res)
return res
+5 -14
View File
@@ -15,24 +15,13 @@
"skill_points_default": 150,
"skill_max_divisor": 2,
"advanced_unlock_cost": 10,
"advanced_attr_percent": 50,
"free_proficiencies": 2,
"max_proficiency_pips": 2,
"talents_min": 1,
"talents_max": 2,
"proficiency_bonus": 10,
"expertise_bonus": 20,
"kit": {
"weapon_cats": [
"melee",
"firearm"
],
"armor_tiers": [
"light"
],
"resources_min": 1,
"resources_max": 2,
"tools": 1
},
"carry_per_strength_mod": 10
},
@@ -42,6 +31,7 @@
{"id": "standard", "name": "Standard", "desc": "The numbers from the brainstorm document.", "creation": {}},
{"id": "gritty", "name": "Gritty survival", "desc": "Example preset: fewer points, one starting talent, harder to learn advanced skills.", "creation": {"attr_points": 70, "skill_points_default": 100, "talents_max": 1, "free_proficiencies": 1, "advanced_unlock_cost": 15}},
{"id": "heroic", "name": "Heroic", "desc": "Example preset: more points, higher attribute cap, three starting talents.", "creation": {"attr_points": 110, "attr_max_raise": 25, "attr_max": 50, "skill_points_default": 200, "talents_max": 3, "free_proficiencies": 3, "advanced_unlock_cost": 5}},
{"id": "npc", "name": "NPC / creature (free)", "desc": "Used by the NPC editor: huge pools, no limits.", "npc": true, "creation": {"attr_points": 500, "attr_max_raise": 100, "attr_max": 150, "skill_points_default": 1000, "talents_min": 0, "talents_max": 10, "free_proficiencies": 20, "advanced_unlock_cost": 0}},
{"id": "oneshot", "name": "One-shot", "desc": "Example preset: quick, strong characters for a single session.", "creation": {"attr_points": 100, "skill_points_default": 120, "talents_max": 2, "free_proficiencies": 3, "advanced_unlock_cost": 0}}
],
@@ -53,6 +43,7 @@
{"key": "profession_attr_bonus", "label": "Profession attribute bonus", "group": "Profession", "min": 0, "max": 50},
{"key": "free_proficiencies", "label": "Free proficiency picks", "group": "Skills", "min": 0, "max": 20},
{"key": "skill_points_default", "label": "Skill point pool", "group": "Skills", "min": 0, "max": 1000},
{"key": "advanced_attr_percent", "label": "Advanced skills: share of the attribute that counts (%)", "group": "Skills", "min": 0, "max": 100},
{"key": "advanced_unlock_cost", "label": "Cost to unlock an advanced skill", "group": "Skills", "min": 0, "max": 100},
{"key": "skill_max_divisor", "label": "Skill maximum = attribute divided by", "group": "Skills", "min": 1, "max": 10},
{"key": "proficiency_bonus", "label": "Proficiency bonus", "group": "Skills", "min": 0, "max": 50},
@@ -433,7 +424,7 @@
"Proficiency: +10. Expertise: +20.",
"Every character gets 2 free proficiency picks on top of the profession's proficiencies. A talent can grant 2 more.",
"Putting two proficiency picks into the same skill makes it Expertise (a profession proficiency counts as one of the two).",
"Proficiency in an advanced skill unlocks it for free.",
"Proficiency picks can only be used on skills you can use, so an advanced skill has to be unlocked first.",
"Groups: Combat, Survival, Social, Technical, Knowledge & Mental."
]
},
@@ -446,7 +437,7 @@
"placeholder": true
},
{
"p": "The unlock cost for advanced skills (10 points) is a house rule of the app, not part of the brainstorm. It can be changed in rules.json (creation.advanced_unlock_cost).",
"p": "The unlock cost for advanced skills (10 points) and the rule that advanced skills only use half of the attribute are house rules of the app, not part of the brainstorm. They can be changed in rules.json (creation.advanced_unlock_cost, creation.advanced_attr_percent).",
"placeholder": true
},
{
+1
View File
@@ -2,3 +2,4 @@ fastapi>=0.115
uvicorn[standard]>=0.30
python-docx>=1.1
reportlab>=4.2
Pillow>=10
+15 -3
View File
@@ -3,7 +3,19 @@ services:
build: .
restart: unless-stopped
ports:
- "8080:8000" # Host-Port bei Bedarf ändern, oder hinter einen Reverse Proxy hängen
# Only reachable from the server itself; the reverse proxy (nginx) is the public entrance.
# Docker bypasses firewall rules for published ports, so never publish this on 0.0.0.0.
- "127.0.0.1:${AW_PORT:-8090}:8000"
security_opt:
- no-new-privileges:true
environment:
# Code that new users must enter to create an account (set it in the .env file next to this file).
# Empty = registration is disabled.
AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-}
# Who may use the rules editor: comma separated usernames. Empty = only the first account that was ever created.
AW_ADMINS: ${AW_ADMINS:-}
# Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers.
AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0}
volumes:
- ./db:/db # SQLite-Datenbank (Charaktere)
- ./backend/data:/app/data:ro # rules.json: bearbeiten, Seite neu laden, fertig
- ./db:/db # SQLite database (users, characters, presets)
- ./backend/data:/app/data:ro # rules.json: edit, reload the page, done
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
import { useState } from 'react'
export default function Account({ user, onLogout }) {
const [f, setF] = useState({ old: '', new: '', new2: '' })
const [msg, setMsg] = useState('')
const [err, setErr] = useState('')
const set = (k) => (e) => setF({ ...f, [k]: e.target.value })
const change = async (e) => {
e.preventDefault(); setMsg(''); setErr('')
if (f.new !== f.new2) return setErr('The two new passwords do not match.')
const r = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old: f.old, new: f.new }) })
const data = await r.json().catch(() => ({}))
if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not change the password.')
setF({ old: '', new: '', new2: '' }); setMsg('Password changed. Other devices were logged out.')
}
return (
<section className="page narrow">
<h1>Account</h1>
<p>Logged in as <strong>{user}</strong>.</p>
<form className="card" onSubmit={change}>
<h3>Change password</h3>
<label>Current password<input type="password" value={f.old} onChange={set('old')} autoComplete="current-password" required /></label>
<label>New password<input type="password" value={f.new} onChange={set('new')} autoComplete="new-password" required /></label>
<label>Repeat new password<input type="password" value={f.new2} onChange={set('new2')} autoComplete="new-password" required /></label>
{err && <p className="warn" role="alert">{err}</p>}
{msg && <p className="toast" role="status">{msg}</p>}
<button type="submit">Change password</button>
</form>
<p><button className="ghost" onClick={onLogout}>Log out</button></p>
</section>
)
}
+50 -7
View File
@@ -1,8 +1,14 @@
import { useEffect, useState } from 'react'
import Creator from './Creator.jsx'
import Rules from './Rules.jsx'
import Maps from './Maps.jsx'
import Characters from './Characters.jsx'
import Presets from './Presets.jsx'
import Lobbies from './Lobbies.jsx'
import Login from './Login.jsx'
import Account from './Account.jsx'
import Editor from './Editor.jsx'
import { claimLegacy } from './legacy.js'
const useHash = () => {
const [h, setH] = useState(window.location.hash.slice(1) || 'creator')
@@ -16,16 +22,41 @@ const useHash = () => {
export default function App() {
const hash = useHash()
const [auth, setAuth] = useState(null) // null = checking, {user, registration_open}
const [rules, setRules] = useState(null)
const [error, setError] = useState('')
// Used to load a saved character into the creator
const [loadRequest, setLoadRequest] = useState(null)
const [npcLoad, setNpcLoad] = useState(null)
const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav
// Ask the server who we are; any later 401 (session expired) sends us back to the login
useEffect(() => {
fetch('/api/auth/me').then((r) => r.json()).then(setAuth).catch(() => setError('Could not reach the server.'))
const out = () => { setAuth((a) => ({ user: null, registration_open: a?.registration_open })); setRules(null) }
window.addEventListener('aw-unauthorized', out)
return () => window.removeEventListener('aw-unauthorized', out)
}, [])
const username = auth?.user?.username
const loadRules = () => fetch('/api/rules').then((r) => r.json()).then(setRules).catch(() => setError('Could not load the rules.'))
useEffect(() => { loadRules() }, [])
useEffect(() => {
if (!username) return
claimLegacy().finally(loadRules)
}, [username]) // eslint-disable-line
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {})
window.location.hash = 'creator'
setAuth({ user: null, registration_open: auth.registration_open }); setRules(null)
}
if (error) return <main><p className="warn">{error}</p></main>
if (!auth) return <main><p className="muted">Loading …</p></main>
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
const page = hash.split('/')[0]
const nav = [['creator', 'Create character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['maps', 'Maps'], ['characters', 'My characters'], ['npcs', 'NPCs'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])]
return (
<>
@@ -36,19 +67,31 @@ export default function App() {
</a>
<nav>
{nav.map(([id, label]) => (
<a key={id} href={`#${id}`} className={page === id ? 'active' : ''}>{label}</a>
<a key={id} href={`#${id}`} className={`${page === id ? 'active' : ''} ${id === 'creator' ? 'navchar' : ''}`}>{label}</a>
))}
<a href="#account" className={page === 'account' ? 'active' : ''} title="Account">{username}</a>
</nav>
</header>
<main>
{error && <p className="warn">{error}</p>}
{!rules && !error && <p className="muted">Loading rules …</p>}
{rules && page === 'creator' && <Creator rules={rules} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} />}
{!rules && <p className="muted">Loading rules …</p>}
{rules && page === 'creator' && <Creator rules={rules} user={username} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} onName={setCharName} />}
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
{page === 'maps' && <Maps />}
{rules && page === 'characters' && (
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
)}
{rules && page === 'npcs' && (
<Characters npc onOpen={(entry) => { setNpcLoad(entry); window.location.hash = 'npc' }}
onNew={() => { setNpcLoad({ new: true }); window.location.hash = 'npc' }} />
)}
{rules && page === 'npc' && <Creator npc rules={rules} user={username} loadRequest={npcLoad} clearLoad={() => setNpcLoad(null)} onName={() => {}} />}
{rules && page === 'lobbies' && (
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
)}
{rules && page === 'editor' && auth.user.admin && <Editor rules={rules} onReload={loadRules} />}
{page === 'account' && <Account user={username} onLogout={logout} />}
</main>
</>
)
+106 -34
View File
@@ -1,50 +1,122 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
const KEY = 'aw_chars'
export const loadList = () => {
try { return JSON.parse(localStorage.getItem(KEY)) || [] } catch { return [] }
}
export const saveList = (l) => {
try { localStorage.setItem(KEY, JSON.stringify(l)) } catch { /* storage blocked */ }
}
export const rememberCharacter = (id, token, name) => {
const l = loadList().filter((c) => c.id !== id)
l.unshift({ id, token, name: name || 'Nameless', updated: Date.now() })
saveList(l)
}
function SharePanel({ c, reload }) {
const [name, setName] = useState('')
const [err, setErr] = useState('')
export default function Characters({ onOpen }) {
const [list, setList] = useState(loadList)
const remove = async (c) => {
if (!window.confirm(`Really delete "${c.name}"?`)) return
await fetch(`/api/characters/${c.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': c.token } }).catch(() => {})
const l = list.filter((x) => x.id !== c.id)
saveList(l); setList(l)
const add = async (e) => {
e.preventDefault(); setErr('')
if (!name.trim()) return
const r = await fetch(`/api/characters/${c.id}/shares`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: name }) })
const data = await r.json().catch(() => ({}))
if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not share.')
setName(''); reload()
}
const remove = async (u) => {
await fetch(`/api/characters/${c.id}/shares/${encodeURIComponent(u)}`, { method: 'DELETE' }).catch(() => {})
reload()
}
return (
<div className="sharepanel">
<p className="small muted">These users can view this entry (read-only), for example another DM:</p>
<div className="chips">
{c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
{c.shared_with.map((u) => (
<span key={u} className="chip on">{u} <button className="linklike" onClick={() => remove(u)} aria-label={`Stop sharing with ${u}`}>×</button></span>
))}
</div>
<form className="row" onSubmit={add}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Username" aria-label="Username to share with" />
<button type="submit">Share</button>
</form>
{err && <p className="warn small">{err}</p>}
</div>
)
}
export default function Characters({ onOpen, onNew, npc = false }) {
const what = npc ? 'NPC' : 'character'
const [data, setData] = useState(null)
const [open, setOpen] = useState(null) // id of the character whose share panel is open
const [err, setErr] = useState('')
const load = () => fetch(npc ? '/api/characters?npc=1' : '/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load the list.'))
useEffect(() => { load() }, [])
const remove = async (c) => {
if (!window.confirm(`Really delete "${c.name}"?`)) return
const r = await fetch(`/api/characters/${c.id}`, { method: 'DELETE' }).catch(() => null)
if (!r || !r.ok) return setErr('Deleting failed.')
load()
}
const copy = async (c) => {
const r = await fetch(`/api/characters/${c.id}`).catch(() => null)
if (!r || !r.ok) return setErr('Duplicating failed.')
const data = await r.json()
const p = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, name: `${data.name || 'NPC'} (copy)` }) }).catch(() => null)
if (!p || !p.ok) return setErr('Duplicating failed.')
load()
}
const kindName = (k) => ({ npc: 'NPC', enemy: 'Enemy', monster: 'Monster', creature: 'Creature', animal: 'Animal', robot: 'Robot / machine' })[k] || ''
const thumb = (c) => c.portrait ? <img className="npcthumb" src={`/api/characters/${c.id}/portrait?v=${c.portrait}`} alt="" /> : <span className="npcthumb none" aria-hidden="true">{npc ? '?' : ''}</span>
const when = (t) => new Date(t * 1000).toLocaleString('en-GB')
return (
<section className="page narrow">
<h1>My characters</h1>
<div className="row">
<h1>{npc ? 'NPCs and creatures' : 'My characters'}</h1>
<button onClick={onNew}>{npc ? '+ New NPC' : '+ New character'}</button>
</div>
<p className="muted">
This list is stored in this browser. The characters themselves live on the server and can be shared by link
(<code>#creator/&lt;ID&gt;</code>), but only this browser holds the key needed to change them.
{npc ? 'Your library of NPCs, enemies, monsters and creatures. Place them as tokens in the map editor or at the table; a DM sees their sheet by clicking the token. You can share NPCs with other DMs.'
: 'These characters are saved in your account. Only you and the users you share a character with can see it. Shared users can look at it but not change it.'}
</p>
{list.length === 0 && <p className="card">Nothing saved yet. Create a character and click "Save".</p>}
{err && <p className="warn">{err}</p>}
{data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "{npc ? 'New NPC' : 'New character'}", then click "{npc ? 'Save NPC' : 'Save'}" on the Summary page.</p>}
<ul className="charlist">
{list.map((c) => (
<li key={c.id} className="card row">
<div>
<strong>{c.name}</strong>
<div className="muted small">ID {c.id} · {new Date(c.updated).toLocaleString('en-GB')}</div>
</div>
<div className="actions">
<button onClick={() => onOpen(c)}>Open</button>
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
{(data?.mine || []).map((c) => (
<li key={c.id} className="card">
<div className="row">
<div className="npchead">
{thumb(c)}
<div>
<strong>{c.name}</strong>{npc && c.kind && <span className="badge">{kindName(c.kind)}</span>}
<div className="muted small">
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
</div>
</div>
</div>
<div className="actions">
<button onClick={() => onOpen(c)}>Open</button>
{npc && <button className="ghost" onClick={() => copy(c)}>Duplicate</button>}
<button className="ghost" onClick={() => setOpen(open === c.id ? null : c.id)}>Share</button>
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
</div>
</div>
{open === c.id && <SharePanel c={c} reload={load} />}
</li>
))}
</ul>
{data && data.shared.length > 0 && (
<>
<h2>Shared with me</h2>
<ul className="charlist">
{data.shared.map((c) => (
<li key={c.id} className="card row">
<div className="npchead">
{thumb(c)}
<div><strong>{c.name}</strong>
<div className="muted small">from {c.owner} · {when(c.updated)}</div>
</div>
</div>
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
</li>
))}
</ul>
</>
)}
</section>
)
}
+114 -130
View File
@@ -1,26 +1,33 @@
import PortraitEditor from './Portrait.jsx'
import { useEffect, useMemo, useRef, useState } from 'react'
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
import { rememberCharacter } from './Characters.jsx'
import SheetPanel from './SheetPanel.jsx'
import Tip from './Tip.jsx'
import PaperSheet from './PaperSheet.jsx'
import { explainAttr, explainAttrPool, explainCapacity, explainCarry, explainDerived, explainProfPool, explainSkill, explainSkillPool } from './explain.js'
const DRAFT = 'aw_draft'
const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary']
const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' }
const Badge = () => <span className="badge" title="Not yet worked out in the brainstorm">Placeholder</span>
function readDraft() {
try { return JSON.parse(localStorage.getItem(DRAFT)) } catch { return null }
function readDraft(key) {
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
}
export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
const NPC_KINDS = [['npc', 'NPC'], ['enemy', 'Enemy'], ['monster', 'Monster'], ['creature', 'Creature'], ['animal', 'Animal'], ['robot', 'Robot / machine']]
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName, npc = false }) {
const DRAFT = `${npc ? 'aw_draft_npc' : 'aw_draft'}:${user}` // drafts are kept per account
const freshCharacter = () => npc
? { ...applyPreset(emptyCharacter(baseRules), baseRules, 'npc'), npc: true, npc_kind: 'enemy', token_size: 1, abilities: '', loot: '' }
: applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
const [ch, setCh] = useState(() => {
const d = readDraft()
const d = readDraft(DRAFT)
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
})
// `ch` is the working copy the pages edit; `committed` is the applied state shown as "before" in the side panel
const [committed, setCommitted] = useState(() => {
const d = readDraft()
const d = readDraft(DRAFT)
const src = d?.committed || d?.ch
return src ? normalize(src, baseRules) : freshCharacter()
})
@@ -28,7 +35,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const pending = JSON.stringify(ch) !== JSON.stringify(committed)
const apply = () => setCommitted(ch)
const discard = () => setCh(committed)
const [saved, setSaved] = useState(() => readDraft()?.saved || null) // {id, token}
const [saved, setSaved] = useState(() => readDraft(DRAFT)?.saved || null) // {id}
const [step, setStep] = useState(0)
const [msg, setMsg] = useState('')
const fileRef = useRef(null)
@@ -41,6 +48,9 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id)
const set = (patch) => setCh((x) => ({ ...x, ...patch }))
const cr = rules.creation
const ctx = { ch, rules, c }
useEffect(() => { onName?.(ch.name) }, [ch.name]) // eslint-disable-line
// Keep the draft in this browser
useEffect(() => {
@@ -50,8 +60,17 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
// Load a character picked in "My characters"
useEffect(() => {
if (!loadRequest) return
if (loadRequest.new) { // "New character" button on the My characters page
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
if (untouched || window.confirm('Start a new ' + (npc ? 'NPC' : 'character') + '? Your current draft is discarded unless you saved it.')) {
setBoth(freshCharacter()); setSaved(null); setStep(0)
}
clearLoad()
return
}
fetch(`/api/characters/${loadRequest.id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved({ id: loadRequest.id, token: loadRequest.token }); setStep(7) })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(loadRequest.owner ? null : { id: loadRequest.id }); setStep(7)
if (loadRequest.owner) setMsg(`This is ${loadRequest.owner}'s character. You can look at it; "Save" creates your own copy.`) })
.catch(() => setMsg('Could not load the character.'))
.finally(clearLoad)
}, [loadRequest]) // eslint-disable-line
@@ -59,10 +78,10 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
// Direct link #creator/<id> (read only; saving creates a copy)
useEffect(() => {
const id = window.location.hash.split('/')[1]
if (!id || loadRequest) return
if (!id || loadRequest || npc) return
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
.catch(() => setMsg('Shared character not found.'))
.catch(() => setMsg('Character not found, or it is not shared with you.'))
}, []) // eslint-disable-line
// Moving between pages: pending edits are applied first (Next applies silently, other jumps ask).
@@ -79,7 +98,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const resetAll = () => {
if (!window.confirm('Discard all input and start over?')) return
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation }); setSaved(null); setStep(0)
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation, ...(npc ? { npc: true, npc_kind: ch.npc_kind, token_size: ch.token_size, abilities: '', loot: '' } : {}) }); setSaved(null); setStep(0)
}
const resetThisStep = () => {
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
@@ -89,15 +108,14 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const save = async () => {
try {
if (saved) {
const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Edit-Token': saved.token }, body: JSON.stringify(ch) })
const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
if (!r.ok) throw new Error()
rememberCharacter(saved.id, saved.token, ch.name)
flash('Saved.')
} else {
const r = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
if (!r.ok) throw new Error()
const { id, token } = await r.json()
setSaved({ id, token }); rememberCharacter(id, token, ch.name)
const { id } = await r.json()
setSaved({ id })
flash('Saved. The share link is shown below.')
}
} catch { flash('Saving failed.') }
@@ -122,6 +140,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
// Click on pip k (1 = Proficient, 2 = Expertise) of a skill: sets the skill's total pips to k, or one lower if already there.
const clickPip = (s, k) => {
if (!s.usable) return flash('Unlock this advanced skill first.')
const target = s.pips >= k ? k - 1 : k
const wantFree = Math.max(0, target - s.profPip)
if (wantFree - s.freePips > c.profLeft) return flash('No proficiency picks left.')
@@ -139,10 +158,25 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const stepConcept = (
<>
<h2>Concept</h2>
<div className="card presetbox">
{npc && (
<div className="card presetbox">
<div className="grid2">
<label>Type
<select value={ch.npc_kind || 'npc'} onChange={(e) => set({ npc_kind: e.target.value })}>
{NPC_KINDS.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
</select>
</label>
<label>Token size (grid squares)
<input type="number" min="1" max="8" value={ch.token_size || 1} onChange={(e) => set({ token_size: Math.max(1, Math.min(8, Number(e.target.value) || 1)) })} />
</label>
</div>
<p className="small muted">NPCs use generous pools (attributes, skills, talents), so you can build anything from a rat to a boss.</p>
</div>
)}
{!npc && <div className="card presetbox">
<label>Rules preset
<select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
{(baseRules.presets || []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
{(baseRules.presets || []).filter((p) => !p.npc).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
{ch.preset === 'custom' && <option value="custom">Custom</option>}
</select>
</label>
@@ -152,10 +186,11 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
return diff.length > 0 && <p className="small muted">Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}</p>
})()}
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
</div>
</div>}
<div className="card portraitbox"><PortraitEditor value={ch.portrait} onChange={(p) => set({ portrait: p })} /></div>
<div className="grid2">
<label>Character name<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
<label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>
<label>{npc ? 'NPC name' : 'Character name'}<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
{!npc && <label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>}
</div>
<div className="grid2">
<label>Class (optional)
@@ -173,7 +208,9 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
</div>
{sub && <p className="small muted">{sub.desc}</p>}
<label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label>
<label>Background<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
<label>{npc ? 'Description' : 'Background'}<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
{npc && <label>Special abilities and attacks<textarea rows="5" value={ch.abilities || ''} onChange={(e) => set({ abilities: e.target.value })} placeholder="e.g. Bite 2d6, poison spit (range 10, VIT check or poisoned), regenerates 2 HP per round" /></label>}
{npc && <label>Loot and drops<textarea rows="3" value={ch.loot || ''} onChange={(e) => set({ loot: e.target.value })} placeholder="e.g. 2 scrap, hide, rusty knife" /></label>}
</>
)
@@ -181,7 +218,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
<>
<h2>Attributes</h2>
<p className="muted">All attributes start at {cr.attr_start}. Distribute {cr.attr_points} points (at most +{cr.attr_max_raise} per attribute, cap {cr.attr_max}).</p>
<div className={`pool ${c.attrLeft < 0 ? 'over' : ''}`}>Points left: <strong>{c.attrLeft}</strong> / {cr.attr_points}</div>
<div className={`pool ${c.attrLeft < 0 ? 'over' : ''}`}><Tip text={explainAttrPool(ctx)}>Points left</Tip>: <strong>{c.attrLeft}</strong> / {cr.attr_points}</div>
<div className="attrs">
{rules.attributes.map((a) => {
const v = ch.attributes[a.id]
@@ -189,14 +226,14 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const setV = (n) => set({ attributes: { ...ch.attributes, [a.id]: n } })
return (
<div key={a.id} className="attr card">
<div><strong>{a.name}</strong> <span className="muted small">{a.short}</span><div className="small muted">{a.desc}</div></div>
<div><strong>{a.name}</strong> <span className="muted small">{a.short}</span><Tip icon text={explainAttr(a, ctx)} /><div className="small muted">{a.desc}</div></div>
<div className="stepper">
<button onClick={() => setV(Math.max(v - 5, cr.attr_start))} aria-label={`${a.name} minus 5`}>−5</button>
<button onClick={() => setV(Math.max(v - 1, cr.attr_start))} aria-label={`${a.name} minus 1`}>−</button>
<span className="val">{v}</span>
<button onClick={() => setV(Math.min(v + 1, cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 1`}>+</button>
<button onClick={() => setV(Math.min(v + Math.min(5, c.attrLeft), cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 5`}>+5</button>
<span className="mod" title="Modifier">Mod {c.mods[a.id]}</span>
<Tip text={explainAttr(a, ctx)}><span className="mod">Mod {c.mods[a.id]}</span></Tip>
</div>
</div>
)
@@ -251,19 +288,19 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
<h2>Skills</h2>
<p className="muted">
At most half the attribute per skill. Advanced skills must be unlocked first for {c.unlockCost} points from the pool
(free if your profession grants them). The pool size comes from the rules preset.
(free if your profession grants proficiency in them). The pool size comes from the rules preset.
</p>
<div className="pools">
<div className={`pool ${c.skillLeft < 0 ? 'over' : ''}`} title="Change the pool size in the rules preset on the Concept page">
Points left: <strong>{c.skillLeft}</strong> / {cr.skill_points_default}
<div className={`pool ${c.skillLeft < 0 ? 'over' : ''}`}>
<Tip text={[...explainSkillPool(ctx), 'The pool size comes from the rules preset (Concept page).']}>Points left</Tip>: <strong>{c.skillLeft}</strong> / {cr.skill_points_default}
</div>
<div className={`pool ${c.profLeft < 0 ? 'over' : ''}`} title="Two picks on the same skill make it Expertise">
Proficiency picks left: <strong>{c.profLeft}</strong> / {c.profAllowed}
<div className={`pool ${c.profLeft < 0 ? 'over' : ''}`}>
<Tip text={explainProfPool(ctx)}>Proficiency picks left</Tip>: <strong>{c.profLeft}</strong> / {c.profAllowed}
</div>
</div>
<p className="small muted">
Proficiency adds +{cr.proficiency_bonus}. Two picks on the same skill make it Expertise (+{cr.expertise_bonus}); a profession proficiency counts as one of them.
Proficiency in an advanced skill unlocks it for free. The talent "Versatile Training" grants 2 more picks.
Proficiency picks only work on skills you can use, so unlock an advanced skill first. The talent "Versatile Training" grants 2 more picks.
</p>
{rules.skill_groups.map((g) => (
<div key={g.id}>
@@ -274,7 +311,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
return (
<div key={s.id} className={`skill ${s.usable ? '' : 'locked'}`} title={s.desc}>
<div>
<strong>{s.name}</strong> {s.placeholder && <Badge />}
<strong>{s.name}</strong> {s.placeholder && <Badge />}<Tip icon text={explainSkill(s, ctx)} />
<div className="small muted">
{s.attrs.map((id) => rules.attributes.find((a) => a.id === id).short).join('/')} · {s.type === 'basic' ? 'Basic' : s.usable ? 'Advanced (unlocked)' : 'Advanced (locked)'}
{s.pips === 1 && ' · Proficient'}{s.pips === 2 && ' · Expertise'}
@@ -283,8 +320,8 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
<span className="small muted">Training</span>
{[1, 2].map((k) => (
<button key={k} className={`pip ${s.pips >= k ? 'on' : ''} ${s.profPip >= k ? 'from-prof' : ''}`}
disabled={s.profPip >= k} onClick={() => clickPip(s, k)}
title={s.profPip >= k ? 'From your profession' : k === 2 ? 'Expertise (uses 2 picks in total)' : 'Proficiency'}
disabled={s.profPip >= k || !s.usable} onClick={() => clickPip(s, k)}
title={!s.usable ? 'Unlock the skill first' : s.profPip >= k ? 'From your profession' : k === 2 ? 'Expertise (uses 2 picks in total)' : 'Proficiency'}
aria-label={`${s.name} proficiency ${k}`}>{s.pips >= k ? '◆' : '◇'}</button>
))}
</div>
@@ -297,13 +334,15 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
) : (
<div className="stepper">
{s.type === 'advanced' && !s.free && (
<button className="ghost small-btn" title={`Lock again and get ${c.unlockCost} points back`}
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 } }))}>Lock</button>
<button className="ghost small-btn" title={`Lock again: you get ${c.unlockCost} points back, its points and proficiency picks are returned`}
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 }, proficiency_picks: { ...x.proficiency_picks, [s.id]: 0 } }))}>Lock</button>
)}
<button onClick={() => setPts(s.points - 1)} aria-label={`${s.name} minus`}>−</button>
<button className="step5" onClick={() => setPts(s.points - 5)} disabled={s.points <= 0} aria-label={`${s.name} minus 5`}>−5</button>
<button onClick={() => setPts(s.points - 1)} disabled={s.points <= 0} aria-label={`${s.name} minus`}>−</button>
<span className="val">{s.points}</span>
<button onClick={() => setPts(s.points + 1)} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
<span className="mod" title="Target number">{s.total}</span>
<button onClick={() => setPts(Math.min(s.points + 1, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
<button className="step5" onClick={() => setPts(Math.min(s.points + 5, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus 5`}>+5</button>
<Tip text={explainSkill(s, ctx)}><span className="mod">{s.total}</span></Tip><span className="small muted" title="Most points you can add"> max {s.max}</span>
</div>
)}
</div>
@@ -357,75 +396,51 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
)
// --- Equipment ---
const kitRule = cr.kit
const items = rules.equipment
const catName = (id) => rules.equipment_categories.find((x) => x.id === id).name
const setKit = (patch) => set({ kit: { ...ch.kit, ...patch } })
const weaponItems = items.filter((e) => kitRule.weapon_cats.includes(e.cat))
const armorItems = items.filter((e) => e.cat === 'armor' && kitRule.armor_tiers.includes(e.tier))
const resourceItems = items.filter((e) => e.cat === 'resource')
const toolItems = items.filter((e) => e.cat === 'tool')
const ItemCard = ({ e, on, onClick, disabled }) => (
<button className={`card choice ${on ? 'on' : ''}`} onClick={onClick} disabled={disabled}>
<strong>{e.name}</strong>
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
{e.note && <span className="small">{e.note}</span>}
</button>
)
const SingleSlot = ({ title, list, slot }) => (
<>
<h3>{title}</h3>
<div className="cards">{list.map((e) => (
<ItemCard key={e.id} e={e} on={ch.kit[slot] === e.id} onClick={() => setKit({ [slot]: ch.kit[slot] === e.id ? '' : e.id })} />
))}</div>
</>
)
const setExtra = (id, qty) => {
const extras = { ...ch.kit.extras }
if (qty <= 0) delete extras[id]; else extras[id] = qty
setKit({ extras })
set({ kit: { ...ch.kit, extras } })
}
const overWeight = c.weight > c.carryMax
const stepGear = (
<>
<h2>Equipment</h2>
<p className="muted">Starting gear: one weapon (melee or firearm), light armor or clothing, {kitRule.resources_min}-{kitRule.resources_max} resources and one tool. Everything else can be added as extra items below.</p>
<div className={`pool ${overWeight ? 'over' : ''}`}>Carried: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
<SingleSlot title="Weapon" list={weaponItems} slot="weapon" />
<SingleSlot title="Armor or clothing" list={armorItems} slot="armor" />
<h3>Resources ({ch.kit.resources.length}/{kitRule.resources_max})</h3>
<div className="cards">{resourceItems.map((e) => {
const on = ch.kit.resources.includes(e.id)
return <ItemCard key={e.id} e={e} on={on} disabled={!on && ch.kit.resources.length >= kitRule.resources_max}
onClick={() => setKit({ resources: on ? ch.kit.resources.filter((x) => x !== e.id) : [...ch.kit.resources, e.id] })} />
})}</div>
<SingleSlot title="Tool or utility item" list={toolItems} slot="tool" />
<h3>Extra items</h3>
<p className="small muted">Anything else from the document's item lists. Ask your GM what you may take along.</p>
{rules.equipment_categories.map((cat) => (
<details key={cat.id}>
<summary>{cat.name} <span className="muted small">({items.filter((e) => e.cat === cat.id).reduce((n, e) => n + (ch.kit.extras[e.id] || 0), 0)} chosen)</span></summary>
<div className="extras">{items.filter((e) => e.cat === cat.id).map((e) => (
<div key={e.id} className="extra">
<div><strong>{e.name}</strong><div className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg{e.note ? ` · ${e.note}` : ''}</div></div>
<div className="stepper">
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) - 1)} aria-label={`${e.name} minus`}>−</button>
<span className="val">{ch.kit.extras[e.id] || 0}</span>
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) + 1)} aria-label={`${e.name} plus`}>+</button>
</div>
</div>
))}</div>
</details>
))}
<p className="muted">Pick everything your character carries. Ask your GM what is allowed. Use + and − on a tile to change the amount.</p>
<div className={`pool ${overWeight ? 'over' : ''}`}><Tip text={explainCarry(ctx)}>Carried</Tip>: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
{rules.equipment_categories.map((cat) => {
const list = items.filter((e) => e.cat === cat.id)
const n = list.reduce((sum, e) => sum + (ch.kit.extras[e.id] || 0), 0)
return (
<div key={cat.id}>
<h4>{cat.name} {n > 0 && <span className="muted small">({n} chosen)</span>}</h4>
<div className="cards">{list.map((e) => {
const qty = ch.kit.extras[e.id] || 0
return (
<div key={e.id} className={`card tile ${qty > 0 ? 'on' : ''}`}>
<strong>{e.name}</strong>
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
{e.note && <span className="small">{e.note}</span>}
<div className="stepper tilestepper">
<button className="ghost" onClick={() => setExtra(e.id, qty - 1)} disabled={qty === 0} aria-label={`${e.name} minus`}>−</button>
<span className="val">{qty}</span>
<button onClick={() => setExtra(e.id, qty + 1)} aria-label={`${e.name} plus`}>+</button>
</div>
</div>
)
})}</div>
</div>
)
})}
<label>Other items (free text)<textarea rows="3" value={ch.gear.other} onChange={(e) => set({ gear: { ...ch.gear, other: e.target.value } })} placeholder="Anything not in the lists, e.g. a unique starter multi-tool" /></label>
<label>Notes<textarea rows="4" value={ch.notes} onChange={(e) => set({ notes: e.target.value })} /></label>
</>
)
const warnings = []
if (npc) { if (c.attrLeft < 0) warnings.push('Too many attribute points spent.'); if (c.skillLeft < 0) warnings.push('More skill points spent than the pool allows.') } else {
if (c.attrLeft < 0) warnings.push('Too many attribute points spent.')
if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`)
if (!ch.profession) warnings.push('No profession chosen.')
@@ -438,55 +453,24 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
if (c.profLeft > 0) warnings.push(`${c.profLeft} proficiency pick${c.profLeft > 1 ? 's' : ''} left.`)
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
if (over) warnings.push('Augmentation Capacity exceeded.')
if (!ch.kit.weapon) warnings.push('No starting weapon chosen.')
if (!ch.kit.armor) warnings.push('No armor or clothing chosen.')
if (ch.kit.resources.length < kitRule.resources_min) warnings.push('No starting resource chosen.')
if (!ch.kit.tool) warnings.push('No tool or utility item chosen.')
if (overWeight) warnings.push('Carrying more than the carry capacity.')
}
const gearLines = kitItems(ch, rules)
const stepSummary = (
<>
<h2>Summary</h2>
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
<div className="sheet card">
<h3 className="sheet-name">{ch.name || 'Nameless'}</h3>
<p className="muted">{ch.player && `Player: ${ch.player} · `}Profession: {prof?.name || '–'}{cls && ` · Class: ${[cls.name, sub?.name].filter(Boolean).join(' / ')}`}</p>
<div className="sheet-cols">
<div>
<h4>Attributes</h4>
<table><tbody>{rules.attributes.map((a) => <tr key={a.id}><td>{a.name}</td><td>{c.attrs[a.id]}</td><td className="muted">Mod {c.mods[a.id]}</td></tr>)}</tbody></table>
<h4>Derived stats</h4>
<table><tbody>{rules.derived.map((d) => <tr key={d.id}><td>{d.name}</td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
</div>
<div>
<h4>Skills</h4>
<table><tbody>
{c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced')).map((s) => <tr key={s.id}><td>{s.name}</td><td>{s.total}</td><td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : ''}</td></tr>)}
{!c.skills.some((s) => s.points || s.prof || s.unlocked) && <tr><td className="muted">None</td></tr>}
</tbody></table>
<h4>Talents</h4>
<ul className="desclist">{rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => <li key={t.id}><strong>{t.name}</strong><div className="rowdesc">{t.desc}</div></li>)}</ul>
{!ch.talents.length && <p>–</p>}
<h4>Mutations / implants ({c.capacityUsed}/{c.capacityMax})</h4>
<ul className="desclist">{rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => <li key={a.id}><strong>{a.name}</strong> <span className="muted small">cost {a.cost}</span><div className="rowdesc">{a.desc}</div></li>)}</ul>
{!ch.augments.length && <p>–</p>}
<h4>Equipment ({c.weight}/{c.carryMax} kg)</h4>
<ul className="desclist">{gearLines.map((g) => <li key={g.item.id}><strong>{g.item.name}{g.qty > 1 ? ` x${g.qty}` : ''}</strong><div className="rowdesc">{[g.item.stat, `${Math.round(g.item.kg * g.qty * 10) / 10} kg`, g.item.note].filter(Boolean).join(' · ')}</div></li>)}</ul>
{!gearLines.length && <p>–</p>}
{ch.gear.other && <p className="small"><strong>Other:</strong> {ch.gear.other}</p>}
</div>
</div>
</div>
<PaperSheet data={ch} baseRules={baseRules} onNotes={(p) => setBoth({ ...ch, ...p })} />
<div className="actions wrap">
<button onClick={() => download('pdf')}>Download as PDF</button>
<button onClick={() => download('docx')}>Download as Word</button>
<button className="ghost" onClick={save}>{saved ? 'Save changes' : 'Save on server'}</button>
{!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
{!npc && <button onClick={() => download('docx')}>Download as Word</button>}
<button className="ghost" onClick={save}>{saved ? 'Save changes' : npc ? 'Save NPC' : 'Save on server'}</button>
<button className="ghost" onClick={exportJson}>JSON backup</button>
<button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button>
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
</div>
{saved && (
{saved && !npc && (
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
)}
</>
@@ -496,7 +480,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
return (
<div className="page creator">
<p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>
{!npc && <p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>}
<ol className="steps">
{STEPS.map((s, i) => (
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
+222
View File
@@ -0,0 +1,222 @@
import { useEffect, useMemo, useState } from 'react'
const J = { 'Content-Type': 'application/json' }
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40)
// What can be edited: fields per entry and the columns of the list
const defs = (saved, attrs) => {
const cats = (saved.equipment_categories || []).map((c) => [c.id, c.name])
const groups = (saved.skill_groups || []).map((g) => [g.id, g.name])
const skills = (saved.skills || []).map((s) => [s.id, s.name])
const name = (list, id) => (list.find(([k]) => k === id) || [])[1] || id
return {
equipment: {
label: 'Items', noun: 'item',
fields: [['name', 'Name', 'text'], ['cat', 'Category', 'select', cats], ['stat', 'Stat (damage, armor ...)', 'text'], ['tier', 'Tier (optional, e.g. light)', 'text'],
['kg', 'Weight (kg)', 'number'], ['note', 'Note', 'text'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { cat: cats[0]?.[0] || '', stat: '', kg: 1, note: '' },
cols: [['Name', (e) => e.name], ['Category', (e) => name(cats, e.cat)], ['Stat', (e) => `${e.stat}${e.tier ? ` (${e.tier})` : ''}`], ['kg', (e) => e.kg]],
},
equipment_categories: {
label: 'Item categories', noun: 'category',
fields: [['name', 'Name', 'text'], ['stat', 'Name of the stat column', 'text']],
blank: { stat: '' }, cols: [['Name', (e) => e.name], ['Stat column', (e) => e.stat]],
},
skills: {
label: 'Skills', noun: 'skill',
fields: [['name', 'Name', 'text'], ['group', 'Group', 'select', groups], ['type', 'Type', 'select', [['basic', 'Basic'], ['advanced', 'Advanced']]],
['attrs', 'Attributes (the higher one counts)', 'multi', attrs], ['desc', 'Description', 'textarea'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { group: groups[0]?.[0] || '', type: 'basic', attrs: [], desc: '' },
cols: [['Name', (e) => e.name], ['Group', (e) => name(groups, e.group)], ['Type', (e) => e.type], ['Attributes', (e) => e.attrs.map((a) => name(attrs, a)).join(' / ')]],
},
skill_groups: {
label: 'Skill groups', noun: 'group', fields: [['name', 'Name', 'text']], blank: {}, cols: [['Name', (e) => e.name]],
},
professions: {
label: 'Professions', noun: 'profession',
fields: [['name', 'Name', 'text'], ['desc', 'Description', 'textarea'], ['bonus_attrs', 'Attributes that can get the profession bonus', 'multi', attrs],
['proficiencies', 'Skills with fixed proficiency', 'multi', skills], ['picks', 'Free proficiency picks', 'number'], ['note', 'Note', 'text'],
['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { desc: '', bonus_attrs: [], proficiencies: [], picks: 0 },
cols: [['Name', (e) => e.name], ['Bonus', (e) => e.bonus_attrs.map((a) => name(attrs, a)).join(', ')], ['Proficiencies', (e) => e.proficiencies.map((s) => name(skills, s)).join(', ')], ['Picks', (e) => e.picks]],
},
}
}
function Field({ f, value, onChange }) {
const [key, label, type, opts] = f
if (type === 'bool') return <label className="small"><input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} style={{ width: 'auto' }} /> {label}</label>
return (
<label className="edfield">
<span className="small muted">{label}</span>
{type === 'text' && <input value={value ?? ''} onChange={(e) => onChange(e.target.value)} />}
{type === 'number' && <input type="number" step="any" value={value ?? ''} onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))} />}
{type === 'textarea' && <textarea rows={3} value={value ?? ''} onChange={(e) => onChange(e.target.value)} />}
{type === 'select' && <select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>{opts.map(([k, n]) => <option key={k} value={k}>{n}</option>)}</select>}
{type === 'multi' && (
<span className="multi">
{opts.map(([k, n]) => (
<label key={k} className="small"><input type="checkbox" style={{ width: 'auto' }} checked={(value || []).includes(k)}
onChange={(e) => onChange(e.target.checked ? [...(value || []), k] : (value || []).filter((x) => x !== k))} /> {n}</label>
))}
</span>
)}
</label>
)
}
// Admins can make other accounts admins (and take it away again)
function AdminUsers() {
const [users, setUsers] = useState(null)
const [err, setErr] = useState('')
const [open, setOpen] = useState(false)
const load = () => fetch('/api/admin/users').then((r) => (r.ok ? r.json() : Promise.reject())).then(setUsers).catch(() => setErr('Could not load the users.'))
useEffect(() => { if (open && !users) load() }, [open]) // eslint-disable-line
const set = async (u, admin) => {
setErr('')
const r = await fetch(`/api/admin/users/${encodeURIComponent(u.username)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ admin }) })
if (!r.ok) setErr((await r.json().catch(() => ({}))).detail || 'That did not work.')
load()
}
return (
<div className="card adminusers">
<button className="linklike" aria-expanded={open} onClick={() => setOpen(!open)}>{open ? '▾' : '▸'} Users and admins</button>
{open && (
<>
<p className="small muted">Admins can use this editor and make other users admins. Admins named in <code>AW_ADMINS</code> on the server can only be changed there.</p>
{err && <p className="warn small">{err}</p>}
<ul className="userlist">
{(users || []).map((u) => (
<li key={u.username} className="row">
<span><strong>{u.username}</strong>{u.me ? ' (you)' : ''} {u.admin && <span className="chip on">admin</span>}{u.fixed && <span className="muted small"> set on the server</span>}</span>
{u.admin
? <button className="ghost" disabled={u.fixed} onClick={() => window.confirm(`Take admin rights from ${u.username}?`) && set(u, false)}>Remove admin</button>
: <button className="ghost" onClick={() => window.confirm(`Make ${u.username} an admin? Admins can change the rules and make others admins.`) && set(u, true)}>Make admin</button>}
</li>
))}
</ul>
</>
)}
</div>
)
}
export default function Editor({ rules, onReload }) {
const [state, setState] = useState(null)
const [tab, setTab] = useState('equipment')
const [drafts, setDrafts] = useState({}) // section -> unsaved list
const [editing, setEditing] = useState('')
const [q, setQ] = useState('')
const [msg, setMsg] = useState({ text: '', bad: false })
const [busy, setBusy] = useState(false)
const load = () => fetch('/api/admin/rules').then(async (r) => {
if (!r.ok) throw new Error(r.status)
setState(await r.json())
}).catch((e) => setMsg({ text: String(e.message) === '403' ? 'Only admins can use the rules editor.' : 'Could not load the editor.', bad: true }))
useEffect(() => { load() }, [])
const D = useMemo(() => state && defs(state.sections, rules.attributes.map((a) => [a.id, a.name])), [state, rules.attributes])
if (!state) return <section className="page"><p className={msg.bad ? 'warn' : 'muted'}>{msg.text || 'Loading …'}</p></section>
const d = D[tab]
const saved = state.sections[tab]
const list = drafts[tab] || saved
const dirty = !!drafts[tab]
const setList = (l) => setDrafts((x) => ({ ...x, [tab]: l }))
const patch = (id, key, v) => setList(list.map((e) => {
if (e.id !== id) return e
const n = { ...e, [key]: v }
if (e._new && key === 'name') { // new entries get their id from the name
let base = slug(v) || 'entry', cand = base, i = 2
while (list.some((x) => x !== e && x.id === cand)) cand = `${base}_${i++}`
const old = e.id
n.id = cand
if (editing === old) setEditing(cand)
}
return n
}))
const add = () => {
let id = 'new_entry', i = 2
while (list.some((x) => x.id === id)) id = `new_entry_${i++}`
setList([...list, { id, name: '', ...d.blank, _new: true }])
setEditing(id); setQ('')
}
const del = (e) => { if (editing === e.id) setEditing(''); setList(list.filter((x) => x.id !== e.id)) }
const save = async (confirm = false) => {
setBusy(true); setMsg({ text: '', bad: false })
const items = list.map(({ _new, ...e }) => e)
const r = await fetch(`/api/admin/rules/${tab}`, { method: 'PUT', headers: J, body: JSON.stringify({ items, confirm }) }).catch(() => null)
const body = r ? await r.json().catch(() => ({})) : {}
setBusy(false)
if (r && r.status === 409 && window.confirm(`${body.detail}\n\nSave anyway?`)) return save(true)
if (!r || !r.ok) return setMsg({ text: typeof body.detail === 'string' ? body.detail : 'Could not save.', bad: true })
setDrafts((x) => { const n = { ...x }; delete n[tab]; return n })
setEditing(''); setMsg({ text: `Saved ${body.count} entries. Everybody sees the change after reloading the page.`, bad: false })
await load(); onReload?.()
}
const reset = async (confirm = false) => {
if (!confirm && !window.confirm(`Throw away all your changes to "${d.label}" and go back to the shipped rules?`)) return
const r = await fetch(`/api/admin/rules/${tab}?confirm=${confirm}`, { method: 'DELETE' }).catch(() => null)
const body = r ? await r.json().catch(() => ({})) : {}
if (r && r.status === 409 && window.confirm(`${body.detail}\n\nReset anyway?`)) return reset(true)
if (!r || !r.ok) return setMsg({ text: typeof body.detail === 'string' ? body.detail : 'Could not reset.', bad: true })
setDrafts((x) => { const n = { ...x }; delete n[tab]; return n })
setMsg({ text: 'Back to the shipped rules.', bad: false })
await load(); onReload?.()
}
const shown = list.filter((e) => !q || e.name.toLowerCase().includes(q.toLowerCase()) || e.id.includes(q.toLowerCase()))
const cur = list.find((e) => e.id === editing)
return (
<section className="page editor">
<h1>Rules editor</h1>
<p className="muted small">Changes are stored next to the database and replace the entries of rules.json for that list; the shipped file stays as it is.
Existing characters keep working. Talents, mutations and implants are still edited in rules.json.</p>
<AdminUsers />
<div className="tabs" role="tablist">
{Object.entries(D).map(([id, x]) => (
<button key={id} role="tab" aria-selected={tab === id} className={tab === id ? 'on' : ''} onClick={() => { setTab(id); setEditing(''); setQ(''); setMsg({ text: '', bad: false }) }}>
{x.label}{drafts[id] ? ' *' : ''}{state.customized.includes(id) ? ' •' : ''}
</button>
))}
</div>
<div className="row wrapform">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${d.label.toLowerCase()} …`} aria-label="Search" style={{ maxWidth: '18rem' }} />
<span className="muted small">{list.length} entries{state.customized.includes(tab) ? ' · customised (•)' : ' · as shipped'}</span>
<span className="actions">
<button onClick={add}>+ Add {d.noun}</button>
<button onClick={() => save()} disabled={!dirty || busy}>Save changes</button>
<button className="ghost" onClick={() => { setDrafts((x) => { const n = { ...x }; delete n[tab]; return n }); setEditing('') }} disabled={!dirty}>Discard</button>
<button className="ghost danger" onClick={() => reset()} disabled={!state.customized.includes(tab)}>Reset to shipped</button>
</span>
</div>
{msg.text && <p className={msg.bad ? 'warn' : 'small'} role={msg.bad ? 'alert' : 'status'}>{msg.text}</p>}
{cur && (
<div className="card edform">
<div className="row"><strong>{cur.name || `New ${d.noun}`}</strong><span className="muted small">id: {cur.id}{cur._new ? ' (from the name)' : ''}</span></div>
<div className="edgrid">
{d.fields.map((f) => <Field key={f[0]} f={f} value={cur[f[0]]} onChange={(v) => patch(cur.id, f[0], v)} />)}
</div>
<div className="actions"><button className="ghost" onClick={() => setEditing('')}>Done</button></div>
</div>
)}
<div className="tablewrap"><table className="edtable">
<thead><tr>{d.cols.map(([h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
<tbody>
{shown.map((e) => (
<tr key={e.id} className={editing === e.id ? 'sel' : ''}>
{d.cols.map(([h, fn]) => <td key={h}>{fn(e)}{h === 'Name' && e.placeholder ? <span className="badge">Placeholder</span> : ''}</td>)}
<td className="actions"><button className="ghost" onClick={() => setEditing(e.id)}>Edit</button><button className="ghost danger" onClick={() => del(e)}>Delete</button></td>
</tr>
))}
{shown.length === 0 && <tr><td colSpan={d.cols.length + 1} className="muted">Nothing found.</td></tr>}
</tbody>
</table></div>
</section>
)
}
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react'
export default function Login({ registrationOpen, onDone }) {
const [mode, setMode] = useState('login')
const [f, setF] = useState({ username: '', password: '', password2: '', code: '' })
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const set = (k) => (e) => setF({ ...f, [k]: e.target.value })
const register = mode === 'register'
const submit = async (e) => {
e.preventDefault()
setErr('')
if (register && f.password !== f.password2) return setErr('The two passwords do not match.')
setBusy(true)
try {
const r = await fetch(`/api/auth/${register ? 'register' : 'login'}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(register ? { username: f.username, password: f.password, code: f.code } : { username: f.username, password: f.password }),
})
const data = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Something went wrong.')
onDone(data.user)
} catch (ex) { setErr(ex.message) } finally { setBusy(false) }
}
return (
<section className="page narrow loginbox">
<h1>Arcane Wasteland</h1>
<p className="muted">Character creator. Please {register ? 'create an account' : 'log in'} to continue.</p>
<div className="chips">
<button className={`chip ${!register ? 'on' : ''}`} onClick={() => { setMode('login'); setErr('') }}>Log in</button>
{registrationOpen && <button className={`chip ${register ? 'on' : ''}`} onClick={() => { setMode('register'); setErr('') }}>Create account</button>}
</div>
<form className="card" onSubmit={submit}>
<label>Username<input value={f.username} onChange={set('username')} autoComplete="username" autoFocus required /></label>
<label>Password<input type="password" value={f.password} onChange={set('password')} autoComplete={register ? 'new-password' : 'current-password'} required /></label>
{register && (
<>
<label>Repeat password<input type="password" value={f.password2} onChange={set('password2')} autoComplete="new-password" required /></label>
<label>Registration code
<input type="password" value={f.code} onChange={set('code')} autoComplete="off" required />
<span className="small muted">Ask the person who runs this server.</span>
</label>
<p className="small muted">Username: 3-32 characters (letters, digits, . _ -). Password: at least 8 characters.</p>
</>
)}
{err && <p className="warn" role="alert">{err}</p>}
<button type="submit" disabled={busy}>{register ? 'Create account' : 'Log in'}</button>
</form>
</section>
)
}
+619
View File
@@ -0,0 +1,619 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { BUILTIN, PropArt } from './props.jsx'
const COLORS = ['#c0392b', '#8e44ad', '#16a085', '#7f8c8d', '#d35400', '#2c3e50', '#27ae60', '#b9770e']
const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
export function MapView({ map, image, fog, portraitUrl, propSrc, pings, onPing, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
const box = useRef(null)
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
const [drag, setDrag] = useState(null)
const viewRef = useRef(view)
viewRef.current = view
const pointers = useRef(new Map())
const fitted = useRef('')
const fit = useCallback(() => {
const r = box.current?.getBoundingClientRect()
if (!r || !r.width || !map) return
const s = Math.min(r.width / map.w, r.height / map.h) * 0.96
setView({ s, tx: (r.width - map.w * s) / 2, ty: (r.height - map.h * s) / 2 })
}, [map?.id, map?.w, map?.h]) // eslint-disable-line
useEffect(() => { // fit once per map, as soon as the box has a size
if (!map || fitted.current === map.id) return
const t = setInterval(() => { if (box.current?.getBoundingClientRect().width) { fit(); fitted.current = map.id; clearInterval(t) } }, 50)
return () => clearInterval(t)
}, [map?.id, fit]) // eslint-disable-line
const zoomAt = (mx, my, f) => setView((v) => {
const s = clamp(v.s * f, 0.05, 8)
return { s, tx: mx - (mx - v.tx) * (s / v.s), ty: my - (my - v.ty) * (s / v.s) }
})
useEffect(() => {
const el = box.current
if (!el) return
const wheel = (e) => {
e.preventDefault()
const r = el.getBoundingClientRect()
zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)))
}
el.addEventListener('wheel', wheel, { passive: false })
return () => el.removeEventListener('wheel', wheel)
}, [])
const down = (e) => {
if (e.target.closest('.maptoken, .mapui, .mapdoor, .mappin')) return
box.current.setPointerCapture(e.pointerId)
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY })
if (pointers.current.size === 1) onSelect?.(null)
else pointers.current.forEach((p, k) => pointers.current.set(k, { ...p, sx: -1e9 })) // two fingers: pinch, not a click
}
const move = (e) => {
if (onHover) onHover(toWorld(e), viewRef.current.s)
const p = pointers.current.get(e.pointerId)
if (!p) return
const r = box.current.getBoundingClientRect()
if (pointers.current.size === 2) { // pinch
const [a, b] = [...pointers.current.values()]
const before = Math.hypot(a.x - b.x, a.y - b.y)
const o = pointers.current.get(e.pointerId)
const now = { ...o, x: e.clientX, y: e.clientY, sx: -1e9 }
pointers.current.set(e.pointerId, now)
const [c, d] = [...pointers.current.values()]
const after = Math.hypot(c.x - d.x, c.y - d.y)
if (before > 0) zoomAt((c.x + d.x) / 2 - r.left, (c.y + d.y) / 2 - r.top, after / before)
return
}
const dx = e.clientX - p.x, dy = e.clientY - p.y
pointers.current.set(e.pointerId, { ...p, x: e.clientX, y: e.clientY })
setView((v) => ({ ...v, tx: v.tx + dx, ty: v.ty + dy }))
}
const up = (e) => {
const p = pointers.current.get(e.pointerId)
pointers.current.delete(e.pointerId)
// a press that barely moved is a click on the map (used by the drawing tools)
if (p && onClick && e.type === 'pointerup' && Math.hypot(e.clientX - p.sx, e.clientY - p.sy) < 5) onClick(toWorld(e), viewRef.current.s, e)
}
const toWorld = (ev) => {
const r = box.current.getBoundingClientRect(), v = viewRef.current
return { x: (ev.clientX - r.left - v.tx) / v.s, y: (ev.clientY - r.top - v.ty) / v.s,
inside: ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom }
}
const startToken = (e, t) => {
e.stopPropagation()
if (!t.can_move) { onSelect?.(t.id); return }
e.preventDefault()
const from = { x: e.clientX, y: e.clientY }
let moved = false, last = 0
const clampPos = (p) => ({ x: clamp(p.x, 0, map.w), y: clamp(p.y, 0, map.h) })
const mv = (ev) => {
if (!moved && Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < 4) return
moved = true
const p = toWorld(ev)
setDrag({ id: t.id, ...clampPos(p), out: !p.inside })
if (t.x != null && p.inside && Date.now() - last > 120) { last = Date.now(); const c = clampPos(p); onMove(t.id, c.x, c.y) }
}
const end = (ev) => {
window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end)
if (!moved) { setDrag(null); onSelect?.(t.id); return }
const p = toWorld(ev)
if (!p.inside) { setDrag(null); return }
const c = clampPos(p)
Promise.resolve(onMove(t.id, c.x, c.y)).finally(() => setDrag(null)) // keep the local position until the server has it
}
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end)
}
const unplaced = tokens.filter((t) => t.x == null && t.can_move && t.kind !== 'prop')
const d = (t) => t.size * map.grid
return (
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}
onDoubleClick={(e) => { if (onPing && !e.target.closest('.maptoken, .mapui, .mapdoor, .mappin')) { const p = toWorld(e); if (p.inside) onPing(p.x, p.y) } }}>
<div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}>
{fog && <img className="mapfog" src={fog} width={map.w} height={map.h} alt="" draggable={false} />}
{image && <img src={image.src} style={{ position: 'absolute', left: image.x, top: image.y }} width={image.w} height={image.h} alt="" draggable={false} />}
{showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />}
{overlay}
{pings?.map((p) => (
<div key={p.key} className="mapping" style={{ left: p.x, top: p.y, '--pc': p.color, '--ps': `${map.grid * 1.6}px` }}>
<i /><i /><b style={{ fontSize: Math.max(11, map.grid * 0.3) }}>{p.name}</b>
</div>
))}
{[...tokens].sort((a, b) => (b.kind === 'prop') - (a.kind === 'prop')).map((t) => {
const dr = drag?.id === t.id ? drag : null
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
if (x == null || (dr && dr.out)) return null
if (t.kind === 'prop') { // decor: below the tokens, no label, rotated
const w = t.size * map.grid, h = (t.size_h || t.size) * map.grid
return (
<div key={t.id} className={`maptoken prop ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
style={{ left: x - w / 2, top: y - h / 2, width: w, height: h, transform: `rotate(${t.rot || 0}deg)` }}
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '') + (t.note ? `\n\n${t.note}` : '')}>
<PropArt prop={t.prop} customSrc={propSrc} />
{t.note && <b className="toknote" aria-hidden="true">📝</b>}
</div>
)
}
return (
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''} ${isTurn?.(t) ? 'turn' : ''}`}
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
style={{ left: x - d(t) / 2, top: y - d(t) / 2, width: d(t), height: d(t), '--tc': t.color, fontSize: Math.max(8, d(t) * 0.45) }}
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '') + (t.note ? `\n\n${t.note}` : '')}>
{t.note && <b className="toknote" aria-hidden="true">📝</b>}
{portraitUrl?.(t) ? <img className="tokpic" src={portraitUrl(t)} alt="" draggable={false} /> : <span>{(t.name || '?').trim().slice(0, 2)}</span>}
<em style={{ fontSize: Math.max(9, map.grid * 0.28) }}>{t.name}</em>
{t.hp_max > 0 && t.hp != null && <i className="tokhp" title={`${t.hp} / ${t.hp_max}`}><b style={{ width: `${Math.max(0, Math.min(100, (t.hp / t.hp_max) * 100))}%`, background: t.hp / t.hp_max > 0.5 ? '#3fb950' : t.hp / t.hp_max > 0.25 ? '#e6c52e' : '#e0524d' }} /></i>}
{t.conds && Object.keys(t.conds).length > 0 && <span className="tokconds">{Object.entries(t.conds).map(([k, lv]) => <u key={k} title={`${k} ${lv}`}>{k.slice(0, 2)}{lv}</u>)}</span>}
</div>
)
})}
</div>
{unplaced.length > 0 && (
<div className="maptray mapui" role="group" aria-label="Tokens not on the map yet">
<span className="small muted">Drag onto the map:</span>
{unplaced.map((t) => (
<button key={t.id} className="traytoken" style={{ '--tc': t.color }} onPointerDown={(e) => startToken(e, t)} title="Drag onto the map">{t.name}</button>
))}
</div>
)}
<div className="mapzoom mapui">
<button className="ghost" onClick={() => { const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1.3) }} aria-label="Zoom in">+</button>
<button className="ghost" onClick={() => { const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1 / 1.3) }} aria-label="Zoom out">-</button>
<button className="ghost" onClick={fit} aria-label="Fit the map to the window" title="Fit">⤢</button>
</div>
{children}
</div>
)
}
// Small ring of round buttons around a point (right click on a token). items: [{icon, label, onClick, disabled}]
export function RadialMenu({ x, y, title, items, onClose }) {
useEffect(() => {
const k = (e) => e.key === 'Escape' && onClose()
window.addEventListener('keydown', k)
return () => window.removeEventListener('keydown', k)
}, [onClose])
const R = items.length > 4 ? 74 : 62
return createPortal(
<div className="radialback" onPointerDown={onClose} onContextMenu={(e) => { e.preventDefault(); onClose() }}>
<div className="radial" style={{ left: x, top: y }} onPointerDown={(e) => e.stopPropagation()}>
<span className="radialtitle">{title}</span>
{items.map((it, i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / items.length
return (
<button key={it.label} className="radialbtn" disabled={it.disabled} title={it.label}
style={{ transform: `translate(${Math.cos(a) * R}px, ${Math.sin(a) * R}px)` }}
onClick={() => { it.onClick(); onClose() }}>
<span className="ri">{it.icon}</span><span className="rl">{it.label}</span>
</button>
)
})}
</div>
</div>, document.body)
}
// DM-only note on a token or prop (loot of a chest, tactics of an enemy ...). Saves shortly after typing stops.
export function NoteField({ t, onChange }) {
const [v, setV] = useState(t.note || '')
const timer = useRef(null)
const last = useRef(t.note || '')
useEffect(() => { if ((t.note || '') !== last.current) { last.current = t.note || ''; setV(t.note || '') } }, [t.note])
useEffect(() => () => clearTimeout(timer.current), [])
const type = (e) => {
const val = e.target.value
setV(val); last.current = val
clearTimeout(timer.current)
timer.current = setTimeout(() => onChange({ note: val }), 500)
}
return (
<label className="small notefield">Notes (only you see these)
<textarea rows="3" maxLength={2000} value={v} onChange={type} onBlur={() => { clearTimeout(timer.current); if (v !== (t.note || '')) onChange({ note: v }) }} placeholder="Contents, tactics, secrets ..." />
</label>
)
}
// Name, size, colour and visibility of one token (used in the live table and in the map editor)
export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
return (
<div className="tokenform">
<input value={t.name} maxLength={40} aria-label="Token name" onChange={(e) => onChange({ name: e.target.value })} />
<label className="small chk">Size <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /> sq.</label>
<span className="swatches">{COLORS.map((c) => <button key={c} className={`swatch ${t.color === c ? 'on' : ''}`} style={{ background: c }} aria-label={`Colour ${c}`} onClick={() => onChange({ color: c })} />)}</span>
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
<NoteField key={t.id} t={t} onChange={onChange} />
<span className="row">
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
<button className="ghost danger" onClick={onDelete}>Remove</button>
</span>
</div>
)
}
// HP and conditions of one token (DM). conditions = rules.conditions
export function StatusForm({ t, conditions, onChange }) {
const conds = t.conds || {}
const cycle = (id) => { const lv = ((conds[id] || 0) + 1) % 4; const n = { ...conds }; if (lv) n[id] = lv; else delete n[id]; onChange({ conds: n }) }
const tracking = t.hp_max > 0 && t.hp != null
const hint = t.hp_hint > 0 ? t.hp_hint : 10
return (
<div className="statusform">
<div className="row hprow">
<b className="small">HP</b>
{tracking ? <>
<button className="ghost mini" onClick={() => onChange({ hp: t.hp - 5 })}>−5</button>
<button className="ghost mini" onClick={() => onChange({ hp: t.hp - 1 })}>−1</button>
<input type="number" value={t.hp} aria-label="Current HP" onChange={(e) => onChange({ hp: e.target.value })} />
<span className="small">/</span>
<input type="number" min="1" value={t.hp_max} aria-label="Maximum HP" onChange={(e) => onChange({ hp_max: e.target.value })} />
<button className="ghost mini" onClick={() => onChange({ hp: t.hp + 1 })}>+1</button>
<button className="ghost mini" onClick={() => onChange({ hp: t.hp + 5 })}>+5</button>
<button className="ghost mini" onClick={() => onChange({ hp_max: null })} title="Stop tracking HP">×</button>
</> : <button className="ghost" onClick={() => onChange({ hp_max: hint, hp: hint })}>Track HP ({hint})</button>}
</div>
<div className="condrow">
{conditions.map((c) => (
<button key={c.id} className={conds[c.id] ? 'on mini' : 'ghost mini'} onClick={() => cycle(c.id)} title={`${c.levels?.[(conds[c.id] || 1) - 1] || ''} (click to raise the level, 3 → off)`}>
{c.name}{conds[c.id] ? ` ${conds[c.id]}` : ''}
</button>
))}
</div>
<p className="small muted">Players see HP and conditions only where you switched them on (right click the token).</p>
</div>
)
}
// Picks an NPC from the library (own and shared). onPick(npc) gets {id, name, kind, size, portrait}.
export function NpcPicker({ onPick, onClose }) {
const [list, setList] = useState(null)
const [q, setQ] = useState('')
useEffect(() => { fetch('/api/characters?npc=1').then((r) => r.json()).then((d) => setList([...d.mine, ...d.shared])).catch(() => setList([])) }, [])
const shown = (list || []).filter((n) => n.name.toLowerCase().includes(q.toLowerCase()))
return (
<div className="npcpicker card" role="dialog" aria-label="NPC library">
<div className="row"><strong>NPC library</strong><button className="ghost" onClick={onClose} aria-label="Close">×</button></div>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search" aria-label="Search NPCs" />
<ul className="npcpick">
{list === null && <li className="muted small">Loading …</li>}
{list && !list.length && <li className="muted small">No NPCs yet. Create some on the <a href="#npcs">NPCs</a> page.</li>}
{shown.map((n) => (
<li key={n.id}><button className="ghost" onClick={() => onPick(n)}>
{n.portrait ? <img className="npcthumb" src={`/api/characters/${n.id}/portrait?v=${n.portrait}`} alt="" /> : <span className="npcthumb none" aria-hidden="true">?</span>}
<span>{n.name}{n.owner ? <span className="muted small"> · {n.owner}</span> : null}</span>
</button></li>
))}
</ul>
</div>
)
}
// Library of decor tokens: built-in drawings and the DM's own uploaded images. onPick({prop, name, w, h}).
export function PropPicker({ onPick, onClose }) {
const [mine, setMine] = useState([])
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(() => fetch('/api/props').then((r) => (r.ok ? r.json() : [])).then(setMine).catch(() => {}), [])
useEffect(() => { load() }, [load])
const upload = async (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr(''); setBusy(true)
const r = await fetch(`/api/props?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f })
setBusy(false)
if (!r.ok) { setErr(r.status === 413 ? 'The image is too large (at most 3 MB).' : (await r.json().catch(() => ({}))).detail || 'Upload failed'); return }
load()
}
return (
<div className="npcpicker card proppicker" role="dialog" aria-label="Prop library">
<div className="row"><strong>Props</strong><button className="ghost" onClick={onClose} aria-label="Close">×</button></div>
<div className="propgrid">
{BUILTIN.map((p) => (
<button key={p.id} className="ghost propbtn" title={p.name} onClick={() => onPick({ prop: `b:${p.id}`, name: p.name, w: p.w, h: p.h })}>
<svg viewBox="0 0 100 100" aria-hidden="true">{p.art}</svg><span>{p.name}</span>
</button>
))}
{mine.map((p) => (
<span key={p.id} className="propbtn own">
<button className="ghost" title={p.name} onClick={() => onPick({ prop: `c:${p.id}`, name: p.name, w: 1, h: 1 })}><img src={`/api/props/${p.id}/image`} alt="" /><span>{p.name}</span></button>
<button className="ghost mini danger" aria-label={`Delete ${p.name}`} onClick={() => window.confirm(`Delete the prop "${p.name}"? It disappears from all maps.`) && fetch(`/api/props/${p.id}`, { method: 'DELETE' }).then(load)}>×</button>
</span>
))}
</div>
<label className="uploadbtn">{busy ? 'Uploading…' : '+ Own image (PNG with transparency)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn small">{err}</p>}
</div>
)
}
// Name, size, rotation and visibility of a decor token
export function PropForm({ t, onChange, onDelete, onDuplicate }) {
const rot = Math.round(t.rot || 0)
return (
<div className="tokenform">
<input value={t.name} maxLength={40} aria-label="Prop name" onChange={(e) => onChange({ name: e.target.value })} />
<span className="row">
<label className="small chk">Width <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /></label>
<label className="small chk">Height <input type="number" min="0.25" max="8" step="0.25" value={t.size_h || t.size} onChange={(e) => onChange({ size_h: Number(e.target.value) || 1 })} /></label>
</span>
<label className="small">Rotation {rot}°
<input type="range" min="0" max="359" step="5" value={rot} onChange={(e) => onChange({ rot: Number(e.target.value) })} /></label>
<span className="row">
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 315) % 360 })} title="Rotate left 45°">⟲ 45°</button>
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 45) % 360 })} title="Rotate right 45°">⟳ 45°</button>
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 90) % 360 })} title="Rotate right 90°">⟳ 90°</button>
</span>
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
<NoteField key={t.id} t={t} onChange={onChange} />
<span className="row">
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
<button className="ghost danger" onClick={onDelete}>Remove</button>
</span>
</div>
)
}
const uid = () => Math.random().toString(36).slice(2, 12)
// Areas the DM shows to players: a circle or cone. zones = revealed ones, draft = being placed. Units: pixels / degrees.
export function RevealLayer({ map, zones, draft }) {
const shape = (z, key, cls) => {
const R = z.radius * map.grid
if (z.fov >= 359) return <circle key={key} className={cls} cx={z.x} cy={z.y} r={R} vectorEffect="non-scaling-stroke" />
const a0 = ((z.dir - z.fov / 2) * Math.PI) / 180, a1 = ((z.dir + z.fov / 2) * Math.PI) / 180
const d = `M ${z.x} ${z.y} L ${z.x + R * Math.cos(a0)} ${z.y + R * Math.sin(a0)} A ${R} ${R} 0 ${z.fov > 180 ? 1 : 0} 1 ${z.x + R * Math.cos(a1)} ${z.y + R * Math.sin(a1)} Z`
return <path key={key} className={cls} d={d} vectorEffect="non-scaling-stroke" />
}
return (
<svg className="revealsvg" width={map.w} height={map.h} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
{(zones || []).map((z) => shape(z, z.id, 'rz'))}
{draft && shape(draft, 'draft', 'rz draft')}
{draft && <circle cx={draft.x} cy={draft.y} r="6" className="rzo" vectorEffect="non-scaling-stroke" />}
</svg>
)
}
// Walls and doors as a line layer on top of the map (DM only). In the live view doors can be clicked to open or close them.
export function WallLayer({ map, walls, draft, hover, onDoor }) {
const line = (w, extra = {}) => (
<line key={w.id} x1={w.x1} y1={w.y1} x2={w.x2} y2={w.y2} vectorEffect="non-scaling-stroke"
className={`wl ${w.kind} ${w.open ? 'open' : ''}`} {...extra} />
)
return (
<svg className="walllayer" width={map.w} height={map.h} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', pointerEvents: 'none' }}>
{walls.filter((w) => !(onDoor && w.kind === 'door')).map((w) => line(w))}
{walls.filter((w) => onDoor && w.kind === 'door').map((w) => (
<g key={w.id} className="mapdoor" onClick={() => onDoor(w)} style={{ pointerEvents: 'stroke', cursor: 'pointer' }}>
<title>{w.open ? 'Open door, click to close' : 'Closed door, click to open'}</title>
<line x1={w.x1} y1={w.y1} x2={w.x2} y2={w.y2} stroke="transparent" strokeWidth="16" vectorEffect="non-scaling-stroke" style={{ pointerEvents: 'stroke' }} />
{line(w, { style: { pointerEvents: 'none' } })}
</g>
))}
{draft && hover && <line x1={draft.x} y1={draft.y} x2={hover.x} y2={hover.y} vectorEffect="non-scaling-stroke" className="wl draft" />}
{draft && <circle cx={draft.x} cy={draft.y} r="4" vectorEffect="non-scaling-stroke" className="wldot" />}
</svg>
)
}
const distToSeg = (p, w) => {
const dx = w.x2 - w.x1, dy = w.y2 - w.y1, L = dx * dx + dy * dy
const t = L === 0 ? 0 : clamp(((p.x - w.x1) * dx + (p.y - w.y1) * dy) / L, 0, 1)
return Math.hypot(w.x1 + t * dx - p.x, w.y1 + t * dy - p.y)
}
// Full-screen editor for a map in the DM's library: grid, enemies and NPCs, walls and doors for line of sight.
export function MapEditor({ id, onClose, live, onSaved }) {
const [m, setM] = useState(null)
const [tokens, setTokens] = useState([])
const [walls, setWalls] = useState([])
const [name, setName] = useState('')
const [grid, setGrid] = useState(40)
const [sight, setSight] = useState(0)
const [showGrid, setShowGrid] = useState(true)
const [snap, setSnap] = useState(false)
const [tool, setTool] = useState('select')
const [draft, setDraft] = useState(null)
const [hover, setHover] = useState(null)
const [sel, setSel] = useState(null)
const [err, setErr] = useState('')
const [dirty, setDirty] = useState(false)
useEffect(() => {
fetch(`/api/maps/${id}`).then((r) => r.json()).then((d) => {
setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: t.prop ? 'prop' : 'npc', can_move: true }))); setWalls(d.walls); setName(d.name); setGrid(d.grid); setSight(d.sight || 0)
})
}, [id])
useEffect(() => {
const k = (e) => { if (e.key === 'Escape') { setDraft(null); if (tool !== 'select') setTool('select') } }
window.addEventListener('keydown', k)
return () => window.removeEventListener('keydown', k)
}, [tool])
// the full-size image, loaded once for the editor
const [image, setImage] = useState(null)
useEffect(() => { if (m) setImage({ src: `/api/maps/${id}/image`, x: 0, y: 0, w: m.w, h: m.h }) }, [m?.id]) // eslint-disable-line
const [picker, setPicker] = useState(false)
const [propPick, setPropPick] = useState(false)
if (!m) return null
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
const cur = tokens.find((t) => t.id === sel)
const patch = (tid, p) => { setTokens((ts) => ts.map((t) => (t.id === tid ? { ...t, ...p } : t))); setDirty(true) }
const addWalls = (list) => { setWalls((w) => [...w, ...list.map((x) => ({ id: uid(), ...x }))]); setDirty(true) }
const addNpc = (n) => {
const k = tokens.length + 1
const t = { id: uid(), kind: 'npc', can_move: true, name: n.name, npc_id: n.id, portrait: n.portrait || '', x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: n.size || 1, color: COLORS[0], hidden: false, hp_max: n.hp > 0 ? n.hp : null, hp: n.hp > 0 ? n.hp : null }
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPicker(false)
}
const addProp = (p) => {
const k = tokens.length + 1
const t = { id: uid(), kind: 'prop', can_move: true, name: p.name, prop: p.prop, rot: 0, x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: p.w, size_h: p.h, color: COLORS[0], hidden: false }
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPropPick(false)
}
const add = () => {
const n = tokens.length + 1
const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * gridNum, y: m.h / 2 + (n % 3) * gridNum, size: 1, color: COLORS[0], hidden: false }
setTokens([...tokens, t]); setSel(t.id); setDirty(true)
}
const snapPt = (p, s) => { // join onto existing corners first, then the grid
let best = null, bd = 12 / s
for (const w of walls) for (const [x, y] of [[w.x1, w.y1], [w.x2, w.y2]]) { const d = Math.hypot(x - p.x, y - p.y); if (d < bd) { bd = d; best = { x, y } } }
if (best) return best
if (snap) return { x: clamp(Math.round(p.x / gridNum) * gridNum, 0, m.w), y: clamp(Math.round(p.y / gridNum) * gridNum, 0, m.h) }
return { x: clamp(p.x, 0, m.w), y: clamp(p.y, 0, m.h) }
}
const click = (world, s) => {
if (tool === 'select') return
if (tool === 'erase') {
let best = null, bd = 10 / s
for (const w of walls) { const d = distToSeg(world, w); if (d < bd) { bd = d; best = w } }
if (best) { setWalls(walls.filter((w) => w.id !== best.id)); setDirty(true) }
return
}
const p = snapPt(world, s)
if (!draft) { setDraft(p); return }
if (Math.hypot(p.x - draft.x, p.y - draft.y) < 1) { setDraft(null); return } // click the same point again: finish
if (tool === 'wall') { addWalls([{ x1: draft.x, y1: draft.y, x2: p.x, y2: p.y, kind: 'wall' }]); setDraft(p) }
else if (tool === 'door') { addWalls([{ x1: draft.x, y1: draft.y, x2: p.x, y2: p.y, kind: 'door' }]); setDraft(null) }
else if (tool === 'rect') {
addWalls([[draft.x, draft.y, p.x, draft.y], [p.x, draft.y, p.x, p.y], [p.x, p.y, draft.x, p.y], [draft.x, p.y, draft.x, draft.y]].map(([x1, y1, x2, y2]) => ({ x1, y1, x2, y2, kind: 'wall' })))
setDraft(null)
}
}
const save = async () => {
setErr('')
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls,
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h, note }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h, note })) }) })
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
setDirty(false); onSaved?.(); return true
}
const close = async () => { if (!dirty || window.confirm('Close without saving your changes?')) onClose() }
const tools = [['select', 'Move tokens'], ['wall', 'Wall'], ['rect', 'Room'], ['door', 'Door'], ['erase', 'Erase']]
return createPortal(
<div className="mapeditor" role="dialog" aria-label="Map editor">
<aside className="card">
<h3>Map editor</h3>
<label className="small">Name <input value={name} maxLength={60} onChange={(e) => { setName(e.target.value); setDirty(true) }} /></label>
<label className="small">Grid: pixels per square <input type="number" min="4" value={grid} onChange={(e) => { setGrid(e.target.value); setDirty(true) }} /></label>
<label className="small chk"><input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> Show grid</label>
<p className="small muted">Match the grid to the squares on your image. Token sizes are counted in squares.</p>
<h4>Walls and doors</h4>
<div className="toolrow" role="group" aria-label="Tool">
{tools.map(([k, label]) => <button key={k} className={tool === k ? 'on' : 'ghost'} aria-pressed={tool === k} onClick={() => { setTool(k); setDraft(null) }}>{label}</button>)}
</div>
<label className="small chk"><input type="checkbox" checked={snap} onChange={(e) => setSnap(e.target.checked)} /> Snap to grid corners</label>
<p className="small muted">
{tool === 'wall' && 'Click to set points, the line continues from the last one. Click the same point twice or press Esc to stop.'}
{tool === 'rect' && 'Click two opposite corners of a room.'}
{tool === 'door' && 'Click both ends of the door. The DM opens and closes it during play.'}
{tool === 'erase' && 'Click a wall or door to remove it.'}
{tool === 'select' && `${walls.length} wall segment${walls.length === 1 ? '' : 's'}. Walls end at existing corners automatically. Players only see what their token can see past them.`}
</p>
<div className="row">
<button className="ghost" disabled={!walls.length} onClick={() => { setWalls(walls.slice(0, -1)); setDirty(true) }}>Undo last</button>
<button className="ghost danger" disabled={!walls.length} onClick={() => window.confirm('Remove all walls and doors of this map?') && (setWalls([]), setDirty(true))}>Remove all</button>
</div>
<label className="small">Sight range in squares (0 = unlimited)
<input type="number" min="0" max="1000" value={sight} onChange={(e) => { setSight(e.target.value); setDirty(true) }} /></label>
<h4>{live ? 'Prepared tokens (used when the map is put on the table again)' : 'Enemies, NPCs and props'}</h4>
<div className="row"><button className="on" onClick={add}>+ Add token</button><button className="ghost" onClick={() => { setPicker(!picker); setPropPick(false) }}>+ From NPC library</button><button className="ghost" onClick={() => { setPropPick(!propPick); setPicker(false) }}>+ Prop</button></div>
{picker && <NpcPicker onPick={addNpc} onClose={() => setPicker(false)} />}
{propPick && <PropPicker onPick={addProp} onClose={() => setPropPick(false)} />}
<ul className="tokenlist">
{tokens.map((t) => (
<li key={t.id} className="tokrow">
<button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}>{t.kind === 'prop' ? <i className="propdot" /> : <i style={{ background: t.color }} />} {t.name}{t.npc_id ? ' ★' : ''}{t.hidden ? ' (hidden)' : ''}</button>
<button className="ghost mini" aria-pressed={!t.hidden} aria-label={t.hidden ? `Show ${t.name} to players` : `Hide ${t.name} from players`} title={t.hidden ? 'Hidden from players - click to show' : 'Visible to players - click to hide'}
onClick={() => patch(t.id, { hidden: !t.hidden })}>{t.hidden ? '🚫' : '👁'}</button>
</li>
))}
{!tokens.length && <li className="muted small">None yet. Add a token or a prop, then drag it onto the map.</li>}
</ul>
{cur && (() => {
const props = { t: cur, onChange: (p) => patch(cur.id, p),
onDelete: () => { setTokens(tokens.filter((t) => t.id !== cur.id)); setSel(null); setDirty(true) },
onDuplicate: () => { const c = { ...cur, id: uid(), name: cur.name.replace(/\s*\d*$/, '') + ' ' + (tokens.length + 1), x: cur.x + gridNum, y: cur.y + gridNum }; setTokens([...tokens, c]); setSel(c.id); setDirty(true) } }
return cur.kind === 'prop' ? <PropForm {...props} /> : <TokenForm {...props} />
})()}
{err && <p className="warn small">{err}</p>}
<div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div>
</aside>
<div className="mapstage">
<MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} propSrc={(pid) => `/api/props/${pid}/image`} portraitUrl={(t) => (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
onClick={click} onHover={draft ? (p, s) => setHover(p) : undefined}
overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />}
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
</div>
</div>, document.body)
}
// DM: the map library (in the "Lobby actions" tab)
export function MapLibrary({ activeId, lobbyId, act }) {
const [list, setList] = useState([])
const [err, setErr] = useState('')
const [editing, setEditing] = useState(null)
const [busy, setBusy] = useState(false)
const [scenes, setScenes] = useState([])
const loadScenes = useCallback(() => (lobbyId ? fetch(`/api/lobbies/${lobbyId}/scenes`).then((r) => (r.ok ? r.json() : [])).then(setScenes).catch(() => {}) : null), [lobbyId])
useEffect(() => { loadScenes() }, [loadScenes, activeId])
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => {}), [])
useEffect(() => { load() }, [load, editing])
const upload = async (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr(''); setBusy(true)
const r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f })
setBusy(false)
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Upload failed'); return }
load()
}
return (
<div className="maplib">
<h4>Maps</h4>
<label className="uploadbtn">{busy ? 'Uploading…' : '+ Upload map (PNG, JPEG, WebP)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn small">{err}</p>}
<ul className="maplist">
{list.map((m) => (
<li key={m.id} className={m.id === activeId ? 'active' : ''}>
<strong>{m.name}</strong> <span className="muted small">{m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}, {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}</span>
<span className="row">
{m.id === activeId
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null }, () => { loadScenes() })}>Clear table</button>
: <button className="on" onClick={() => (!activeId || window.confirm('Put this map on the table? The current table is saved as a scene first - you can switch back to it below.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id }, () => { loadScenes() })}>Show on table</button>}
{m.mine && <button className="ghost" onClick={() => setEditing(m.id)} title={m.id === activeId ? 'Walls, grid and sight apply to the running game at once' : ''}>{m.id === activeId ? 'Edit live' : 'Edit'}</button>}
{m.mine && <button className="ghost danger" onClick={() => window.confirm(`Delete the map "${m.name}"?`) && fetch(`/api/maps/${m.id}`, { method: 'DELETE' }).then(load)}>Delete</button>}
</span>
</li>
))}
{!list.length && <li className="muted small">No maps yet. Upload an image, then prepare enemies in the editor.</li>}
</ul>
{lobbyId && (
<>
<h4>Scenes</h4>
<p className="small muted">Every map you put on the table is a scene. Switching saves the current one (tokens, doors, explored ground, revealed areas, fight) and loads the other exactly as you left it.</p>
<ul className="maplist">
{scenes.map((sc) => (
<li key={sc.id} className={sc.current ? 'active' : ''}>
<strong>{sc.name}</strong> <span className="muted small">{sc.map_name ? `Map: ${sc.map_name}` : 'map deleted'} · saved {new Date(sc.updated * 1000).toLocaleString()}{sc.current ? ' · running' : ''}</span>
<span className="row">
{!sc.current && <button className="on" disabled={!sc.map_name} onClick={() => act(`/api/lobbies/${lobbyId}/scene`, 'PUT', { scene_id: sc.id }, () => { loadScenes(); load() })}>Switch to</button>}
{sc.current && <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'PUT', { save: true }, () => loadScenes())}>Save now</button>}
<button className="ghost" onClick={() => { const n = window.prompt('New name', sc.name); if (n) act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'PUT', { name: n }, () => loadScenes()) }}>Rename</button>
<button className="ghost danger" onClick={() => window.confirm(`Delete the scene "${sc.name}"?${sc.current ? ' The table keeps running, but this state is no longer stored.' : ''}`) && act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'DELETE', undefined, () => loadScenes())}>Delete</button>
</span>
</li>
))}
{!scenes.length && <li className="muted small">No scenes yet. Put a map on the table.</li>}
</ul>
{activeId && <button className="ghost" onClick={() => { const n = window.prompt('Name for the checkpoint', ''); if (n !== null) act(`/api/lobbies/${lobbyId}/scenes`, 'POST', { name: n }, () => loadScenes()) }}>Save a copy of the current table</button>}
</>
)}
{editing && <MapEditor id={editing} live={editing === activeId} onClose={() => setEditing(null)} />}
</div>
)
}
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useEffect, useState } from 'react'
import { MapEditor } from './MapView.jsx'
import { BUILTIN } from './props.jsx'
const J = { 'Content-Type': 'application/json' }
const errText = async (r, fallback) => {
if (r.status === 413) return 'The file is too big for the web server (nginx client_max_body_size). Use a smaller image or ask the server admin to raise the limit.'
return (await r.json().catch(() => ({}))).detail || fallback
}
// Big images are scaled down and re-encoded in the browser first (the server does it again anyway), so they fit through proxies with small upload limits.
async function shrink(f) {
if (f.size < 900_000) return f
try {
const bmp = await createImageBitmap(f)
const k = Math.min(1, 4096 / Math.max(bmp.width, bmp.height))
const c = document.createElement('canvas')
c.width = Math.max(1, Math.round(bmp.width * k)); c.height = Math.max(1, Math.round(bmp.height * k))
c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height)
const blob = await new Promise((res) => c.toBlob(res, 'image/webp', 0.9))
return blob && blob.size < f.size ? blob : f
} catch { return f }
}
function SharePanel({ m, reload }) {
const [name, setName] = useState('')
const [err, setErr] = useState('')
const add = async (e) => {
e.preventDefault()
setErr('')
const r = await fetch(`/api/maps/${m.id}/shares`, { method: 'POST', headers: J, body: JSON.stringify({ username: name }) })
if (!r.ok) return setErr(await errText(r, 'Could not share.'))
setName(''); reload()
}
const remove = async (u) => { await fetch(`/api/maps/${m.id}/shares/${encodeURIComponent(u)}`, { method: 'DELETE' }).catch(() => {}); reload() }
return (
<div className="sharepanel">
<div className="small muted">Shared with</div>
<div className="chips">
{m.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
{m.shared_with.map((u) => <span key={u} className="chip">{u} <button className="ghost" aria-label={`Stop sharing with ${u}`} onClick={() => remove(u)}>×</button></span>)}
</div>
<form className="row" onSubmit={add}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Username" aria-label="Username to share with" />
<button type="submit" disabled={!name.trim()}>Share</button>
</form>
{err && <p className="warn small">{err}</p>}
</div>
)
}
// The DM's prop library: built-in drawings (fixed) and own images (add, rename, remove). Props can be used on every map.
function PropLibrary() {
const [mine, setMine] = useState(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(() => fetch('/api/props').then((r) => (r.ok ? r.json() : [])).then(setMine).catch(() => setMine([])), [])
useEffect(() => { load() }, [load])
const upload = async (e) => {
const files = [...(e.target.files || [])]
e.target.value = ''
if (!files.length) return
setErr(''); setBusy(true)
for (const f of files) {
const r = await fetch(`/api/props?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f }).catch(() => null)
if (!r || !r.ok) { setErr(r ? (r.status === 413 ? 'An image is too big (at most 3 MB, or the web server limit).' : await errText(r, 'Upload failed')) : 'Upload failed'); break }
}
setBusy(false); load()
}
return (
<>
<h2>Props</h2>
<p className="muted">Decor for your maps: chests, tables, doors and so on. Usable on every map, in the map editor and on the table. Your own images work best as PNGs with a transparent background, seen from above.</p>
<label className="uploadbtn">{busy ? 'Uploading …' : '+ Add own props (PNG, JPEG, WebP, up to 3 MB each)'}<input type="file" multiple accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn">{err}</p>}
<h3>Your props</h3>
{mine && !mine.length && <p className="muted small">None yet.</p>}
<ul className="propmgr">
{(mine || []).map((p) => (
<li key={p.id}>
<img src={`/api/props/${p.id}/image`} alt="" loading="lazy" />
<strong className="small">{p.name}</strong>
<span className="row">
<button className="ghost mini" onClick={() => { const n = window.prompt('New name', p.name); if (n && n.trim()) fetch(`/api/props/${p.id}`, { method: 'PUT', headers: J, body: JSON.stringify({ name: n }) }).then(load) }}>Rename</button>
<button className="ghost mini danger" onClick={() => window.confirm(`Remove the prop "${p.name}"? Places where it is used on maps lose their picture.`) && fetch(`/api/props/${p.id}`, { method: 'DELETE' }).then(load)}>Remove</button>
</span>
</li>
))}
</ul>
<h3>Built in</h3>
<ul className="propmgr">
{BUILTIN.map((p) => <li key={p.id}><svg viewBox="0 0 100 100" aria-hidden="true">{p.art}</svg><strong className="small">{p.name}</strong></li>)}
</ul>
</>
)
}
// All maps of the user (prepare, share, copy) plus the maps other users shared with them
export default function Maps() {
const [list, setList] = useState(null)
const [editing, setEditing] = useState(null)
const [open, setOpen] = useState(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => setList([])), [])
useEffect(() => { load() }, [load, editing])
const upload = async (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr(''); setBusy(true)
const body = await shrink(f)
const r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': body.type || 'application/octet-stream' }, body }).catch(() => null)
setBusy(false)
if (!r || !r.ok) return setErr(r ? await errText(r, 'Upload failed') : 'Upload failed')
load()
}
const copy = async (m) => {
const r = await fetch(`/api/maps/${m.id}/copy`, { method: 'POST' })
if (!r.ok) return setErr(await errText(r, 'Could not copy'))
load()
}
const del = async (m) => {
if (!window.confirm(`Delete the map "${m.name}"? Lobbies that show it get an empty table, shared users lose it.`)) return
await fetch(`/api/maps/${m.id}`, { method: 'DELETE' }); load()
}
const mine = (list || []).filter((m) => m.mine), shared = (list || []).filter((m) => !m.mine)
const card = (m) => (
<li key={m.id} className="mapcard card">
<img src={`/api/maps/${m.id}/thumb`} alt="" loading="lazy" />
<div className="mapcardbody">
<strong>{m.name}</strong>
<span className="small muted">{m.w}×{m.h} · {m.tokens} token{m.tokens === 1 ? '' : 's'} · {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}{m.mine && m.shared_with.length > 0 && ` · shared with ${m.shared_with.join(', ')}`}</span>
<span className="row">
{m.mine && <button className="on" onClick={() => setEditing(m.id)}>Edit</button>}
{m.mine && <button className="ghost" onClick={() => setOpen(open === m.id ? null : m.id)}>Share</button>}
<button className="ghost" onClick={() => copy(m)} title="Make your own editable copy">{m.mine ? 'Duplicate' : 'Copy to my maps'}</button>
{m.mine && <button className="ghost danger" onClick={() => del(m)}>Delete</button>}
</span>
{open === m.id && m.mine && <SharePanel m={m} reload={load} />}
</div>
</li>
)
return (
<section className="page">
<h1>Maps</h1>
<p className="muted">Prepare maps with grid, enemies, NPCs, walls and doors. The DM puts one on the table from a lobby (<em>Lobby actions</em>). Shared maps can be used on your table or copied, but not changed.</p>
<label className="uploadbtn">{busy ? 'Uploading …' : '+ Upload map (PNG, JPEG, WebP, up to 12 MB)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn">{err}</p>}
{list === null && <p className="muted">Loading …</p>}
{list && !mine.length && <p className="muted">You have no maps yet.</p>}
<ul className="mapcards">{mine.map(card)}</ul>
{shared.length > 0 && <><h2>Shared with me</h2><ul className="mapcards">{shared.map(card)}</ul></>}
<PropLibrary />
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
</section>
)
}
+238
View File
@@ -0,0 +1,238 @@
import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import Tip from './Tip.jsx'
import { compute, effectiveRules, kitItems, normalize } from './calc.js'
import { explainAttr, explainCapacity, explainCarry, explainDerived, explainSkill } from './explain.js'
const PAPER_PX = 794 // 210 mm at 96 dpi
// Light/dark choice for the sheet, shared by all sheets and switches on the page and remembered in the browser
const THEME_KEY = 'aw_sheettheme'
const listeners = new Set()
const readTheme = () => { try { return localStorage.getItem(THEME_KEY) === 'dark' } catch { return false } }
let darkNow = readTheme()
export function useSheetTheme() {
const dark = useSyncExternalStore((cb) => { listeners.add(cb); return () => listeners.delete(cb) }, () => darkNow)
const set = (d) => { darkNow = d; try { localStorage.setItem(THEME_KEY, d ? 'dark' : 'light') } catch { /* storage may be blocked */ } listeners.forEach((f) => f()) }
return [dark, set]
}
export function ThemeSwitch() {
const [dark, setDark] = useSheetTheme()
return (
<span className="seg" role="group" aria-label="Sheet colours">
<button className={dark ? 'ghost' : 'on'} onClick={() => setDark(false)} title="Light sheet">Light</button>
<button className={dark ? 'on' : 'ghost'} onClick={() => setDark(true)} title="Dark sheet (prints light)">Dark</button>
</span>
)
}
// Opens the sheet pages in a clean window (only the paper, A4, no margins) and starts the print dialog:
// "Save as PDF" there gives an A4 PDF that looks exactly like the screen.
export function printSheet(root, title) {
const win = window.open('', '_blank')
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
const clone = root.cloneNode(true)
const src = root.querySelectorAll('textarea'), dst = clone.querySelectorAll('textarea')
dst.forEach((t, i) => { t.textContent = src[i].value }) // typed text does not survive cloning on its own
clone.style.zoom = ''
clone.classList.remove('dark') // printed pages are always light
const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('')
win.document.open()
win.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>${(title || 'Character').replace(/[<>&]/g, '')}</title>${head}
<style>@page { size: A4; margin: 0 } html, body { background: #fff !important; margin: 0; padding: 0 }
.papers { display: block !important; gap: 0 !important } .paper { border: 0 !important; box-shadow: none !important; margin: 0 !important; min-height: 296.5mm !important; break-after: page; }
.paper:last-child { break-after: auto }</style></head><body>${clone.outerHTML}</body></html>`)
win.document.close()
const go = () => { win.focus(); win.print() }
const links = [...win.document.querySelectorAll('link[rel=stylesheet]')]
let pending = links.length
if (!pending) return go()
links.forEach((l) => { l.addEventListener('load', () => { if (--pending === 0) setTimeout(go, 150) }); l.addEventListener('error', () => { if (--pending === 0) go() }) })
}
// Ruled note area. With onChange (own sheet in the summary or the lobby) it can be typed into, otherwise it just shows the text.
function Notes({ title, value, onChange }) {
return (
<div className="psec pnotes"><h3>{title}</h3>
{onChange
? <textarea className="plines pwrite" value={value || ''} maxLength={5000} onChange={(e) => onChange(e.target.value)} placeholder="Click here to write …" aria-label={title} />
: <div className="plines pwrite">{value}</div>}
</div>
)
}
// Scales the A4 pages down when the space is narrower than the paper
function Fit({ children, rootRef, dark }) {
const wrap = useRef(null)
const [z, setZ] = useState(1)
useLayoutEffect(() => {
const f = () => wrap.current && setZ(Math.min(1, wrap.current.clientWidth / PAPER_PX))
f()
const ro = new ResizeObserver(f)
ro.observe(wrap.current)
return () => ro.disconnect()
}, [])
return <div ref={wrap} className="paperfit"><div ref={rootRef} className={`papers${dark ? ' dark' : ''}`} style={{ zoom: z }}>{children}</div></div>
}
const Pips = ({ n, locked }) => (
<span className={`ppips ${locked ? 'locked' : ''}`} aria-label={n === 2 ? 'Expertise' : n === 1 ? 'Proficient' : locked ? 'Locked' : ''}>
<i className={n >= 1 ? 'on' : ''} /><i className={n >= 2 ? 'on' : ''} />
</span>
)
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
export default function PaperSheet({ data, baseRules, tools, bare, onRoll, onNotes }) {
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
const c = useMemo(() => compute(ch, rules), [ch, rules])
const x = { ch, rules, c }
const root = useRef(null)
const [dark] = useSheetTheme()
const prof = rules.professions.find((p) => p.id === ch.profession)
const cls = rules.classes.find((k) => k.id === ch.class_id)
const sub = cls?.subclasses.find((s) => s.id === ch.subclass_id)
const preset = ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name || 'Custom'
const short = (s) => rules.attributes.filter((a) => s.attrs.includes(a.id)).map((a) => a.short).join('/')
const talents = rules.talents.filter((t) => ch.talents.includes(t.id))
const augments = rules.augments.filter((a) => ch.augments.includes(a.id))
const gear = kitItems(ch, rules)
const name = ch.name || 'Nameless'
const sign = (n) => (n > 0 ? `+${n}` : `${n}`)
// With onRoll (own character in a lobby) attributes and usable skills can be clicked to roll a check
const roll = (kind, id, label, ok = true) => (onRoll && ok ? {
className: 'rollable', role: 'button', tabIndex: 0,
onClick: () => onRoll(kind, id, label), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRoll(kind, id, label) } },
} : {})
const Field = ({ label, children }) => <div className="pfield"><span>{label}</span><b>{children || '–'}</b></div>
const Foot = ({ n }) => <div className="pfoot"><span>Arcane Wasteland</span><span>{name}</span><span>Page {n} / 2</span></div>
return (
<div className="papersheet">
{!bare && (
<div className="papertools">
{tools}
<span className="toolright">
<ThemeSwitch />
<button className="ghost" onClick={() => printSheet(root.current, name)} title="Opens the pages for printing, choose Save as PDF there">Print / PDF (A4)</button>
</span>
</div>
)}
<Fit rootRef={root} dark={dark}>
<section className="paper">
{(() => {
const head = (
<header className="phead">
<div className="pname">{name}</div>
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
</header>
)
const fields = (
<div className="pfields">
<Field label="Player">{ch.player}</Field>
<Field label="Concept">{ch.concept}</Field>
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
<Field label="Profession">{prof?.name}</Field>
<Field label="Rules">{preset}</Field>
</div>
)
return ch.portrait
? <div className="pidentity"><img className="pportrait" src={ch.portrait} alt="" /><div className="pidright">{head}{fields}</div></div>
: <>{head}{fields}</>
})()}
<div className="pattrs">
{rules.attributes.map((a) => (
<div key={a.id} {...roll('attr', a.id, a.name)} className={`pattr${onRoll ? ' rollable' : ''}`}>
<Tip text={explainAttr(a, x)}><span className="pattr-n">{a.name}</span></Tip>
<b className="pattr-v">{c.attrs[a.id]}</b>
<span className="pattr-m">Mod {sign(c.mods[a.id])}</span>
</div>
))}
</div>
<div className="pderived">
{rules.derived.map((d) => (
<div key={d.id} className="pstat">
<Tip text={explainDerived(d, x)}><span>{d.name}</span></Tip>
<b>{c.derived[d.id]}</b>
</div>
))}
</div>
<div className="psec">
<h3>Skills <small>● proficient · ●● expertise · attribute · points · total</small></h3>
<div className="pskills">
{rules.skill_groups.map((g) => (
<div key={g.id} className="pgroup">
<h4>{g.name}</h4>
{c.skills.filter((s) => s.group === g.id).map((s) => (
<div key={s.id} {...roll('skill', s.id, s.name, s.usable)} className={`prow ${s.usable ? '' : 'locked'}${onRoll && s.usable ? ' rollable' : ''}`}>
<Pips n={s.pips} locked={!s.usable} />
<Tip text={explainSkill(s, x)}><span className="pn">{s.name}{s.type === 'advanced' && <em> adv</em>}</span></Tip>
<span className="pa">{short(s)}</span>
<span className="pp">{s.points || ''}</span>
<b className="pt">{s.total}</b>
</div>
))}
</div>
))}
</div>
</div>
{data.npc && (data.abilities || data.loot || data.background) && (
<div className="psec pnpc">
{data.background && <><h3>Description</h3><p className="pre">{data.background}</p></>}
{data.abilities && <><h3>Abilities and attacks</h3><p className="pre">{data.abilities}</p></>}
{data.loot && <><h3>Loot</h3><p className="pre">{data.loot}</p></>}
</div>
)}
<Notes title="Conditions &amp; notes" value={ch.conditions} onChange={onNotes && ((v) => onNotes({ conditions: v }))} />
<Foot n={1} />
</section>
<section className="paper">
<header className="phead small">
<div className="pname">{name}</div>
<div className="pbrand">{[cls?.name, prof?.name].filter(Boolean).join(' · ')}</div>
</header>
<div className="psec">
<h3>Talents</h3>
{talents.length === 0 && <p className="pnone">–</p>}
<div className="pcols">
{talents.map((t) => <div key={t.id} className="pitem"><b>{t.name}</b><p>{t.desc}</p></div>)}
</div>
</div>
<div className="psec">
<h3><Tip text={explainCapacity(x)}>Mutations &amp; implants</Tip> <small>Capacity {c.capacityUsed} / {c.capacityMax}</small></h3>
{augments.length === 0 && <p className="pnone">–</p>}
<div className="pcols">
{augments.map((a) => (
<div key={a.id} className="pitem"><b>{a.name}</b> <span className="ptag">{a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost {a.cost}</span><p>{a.desc}</p></div>
))}
</div>
</div>
<div className="psec">
<h3><Tip text={explainCarry(x)}>Equipment</Tip> <small>Carried {c.weight} / {c.carryMax} kg</small></h3>
{gear.length === 0 && <p className="pnone">–</p>}
{gear.length > 0 && (
<table className="pgear"><thead><tr><th>Item</th><th>Type</th><th>Stats</th><th>Qty</th><th>kg</th></tr></thead>
<tbody>{gear.map((g) => (
<tr key={g.item.id}><td><b>{g.item.name}</b>{g.item.note && <span className="pnote"> {g.item.note}</span>}</td><td>{g.label}</td><td>{g.item.stat}</td>
<td>{g.qty}</td><td>{Math.round(g.item.kg * g.qty * 10) / 10}</td></tr>
))}</tbody></table>
)}
{ch.gear?.other && <p className="pother"><b>Other:</b> {ch.gear.other}</p>}
</div>
{ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>}
<Notes title="Notes" value={ch.notes} onChange={onNotes && ((v) => onNotes({ notes: v }))} />
<Foot n={2} />
</section>
</Fit>
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
import { useRef, useState } from 'react'
const BOX = 200 // size of the crop window on screen
const OUT = 256 // size of the stored picture
// Picture of a character: choose a file, move and zoom it inside a square window, apply. Stored as a small data URL.
export default function PortraitEditor({ value, onChange }) {
const [pic, setPic] = useState(null) // { img, w, h }
const [zoom, setZoom] = useState(1)
const [off, setOff] = useState({ x: 0, y: 0 })
const [err, setErr] = useState('')
const drag = useRef(null)
const pick = (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr('')
if (!/^image\/(png|jpeg|webp)$/.test(f.type)) return setErr('Please choose a PNG, JPEG or WebP image.')
if (f.size > 12_000_000) return setErr('The image is too large (at most 12 MB).')
const url = URL.createObjectURL(f)
const img = new Image()
img.onload = () => { setPic({ img, w: img.naturalWidth, h: img.naturalHeight, url }); setZoom(1); setOff({ x: 0, y: 0 }) }
img.onerror = () => { URL.revokeObjectURL(url); setErr('This image could not be read.') }
img.src = url
}
const scale = pic ? (BOX / Math.min(pic.w, pic.h)) * zoom : 1
const clampOff = (o, z = zoom) => {
if (!pic) return o
const s = (BOX / Math.min(pic.w, pic.h)) * z
const mx = Math.max(0, (pic.w * s - BOX) / 2), my = Math.max(0, (pic.h * s - BOX) / 2)
return { x: Math.min(mx, Math.max(-mx, o.x)), y: Math.min(my, Math.max(-my, o.y)) }
}
const down = (e) => { e.currentTarget.setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, off } }
const move = (e) => { if (drag.current) setOff(clampOff({ x: drag.current.off.x + e.clientX - drag.current.x, y: drag.current.off.y + e.clientY - drag.current.y })) }
const up = () => { drag.current = null }
const close = () => { if (pic) URL.revokeObjectURL(pic.url); setPic(null) }
const apply = () => {
const size = BOX / scale // visible part of the original, in its pixels
const cx = pic.w / 2 - off.x / scale, cy = pic.h / 2 - off.y / scale
const cv = document.createElement('canvas')
cv.width = cv.height = OUT
cv.getContext('2d').drawImage(pic.img, cx - size / 2, cy - size / 2, size, size, 0, 0, OUT, OUT)
const data = cv.toDataURL('image/webp', 0.85)
onChange(data.startsWith('data:image/webp') ? data : cv.toDataURL('image/jpeg', 0.88))
close()
}
return (
<div className="portraiteditor">
{!pic && (
<div className="portraitcur">
{value ? <img src={value} alt="Character portrait" /> : <span className="portraitnone muted small">No picture</span>}
<div className="portraitbtns">
<label className="uploadbtn small">{value ? 'Change picture' : 'Add picture'}<input type="file" accept="image/png,image/jpeg,image/webp" hidden onChange={pick} /></label>
{value && <button className="ghost" onClick={() => onChange('')}>Remove</button>}
<span className="small muted">Shown on the sheet, in lobbies and on the map.</span>
</div>
</div>
)}
{pic && (
<div className="portraitcrop">
<div className="cropwin" style={{ width: BOX, height: BOX }} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
<img src={pic.url} alt="" draggable={false} style={{ width: pic.w * scale, height: pic.h * scale, left: (BOX - pic.w * scale) / 2 + off.x, top: (BOX - pic.h * scale) / 2 + off.y }} />
</div>
<div className="cropctl">
<label className="small">Zoom <input type="range" min="1" max="4" step="0.02" value={zoom} onChange={(e) => { const z = +e.target.value; setZoom(z); setOff((o) => clampOff(o, z)) }} /></label>
<p className="small muted">Drag the picture to choose the section.</p>
<div className="row"><button className="on" onClick={apply}>Use this</button><button className="ghost" onClick={close}>Cancel</button></div>
</div>
</div>
)}
{err && <p className="warn small">{err}</p>}
</div>
)
}
+7 -18
View File
@@ -1,20 +1,15 @@
import { useState } from 'react'
const KEY = 'aw_presets' // {presetId: editToken}, only for presets created in this browser
const loadTokens = () => { try { return JSON.parse(localStorage.getItem(KEY)) || {} } catch { return {} } }
const saveTokens = (t) => { try { localStorage.setItem(KEY, JSON.stringify(t)) } catch { /* storage blocked */ } }
const BLANK = { id: null, name: '', desc: '', creation: {} }
export default function Presets({ rules, reload }) {
const [tokens, setTokens] = useState(loadTokens)
const [sel, setSel] = useState(rules.default_preset || 'standard')
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
const [msg, setMsg] = useState('')
const presets = rules.presets || []
const presets = (rules.presets || []).filter((p) => !p.npc)
const current = draft || presets.find((p) => p.id === sel) || presets[0]
const editable = !!draft
const own = !!current?.id && !!tokens[current.id]
const own = !!current?.mine
const base = rules.creation
const groups = [...new Set((rules.tunables || []).map((x) => x.group))]
const value = (k) => (current.creation?.[k] ?? base[k])
@@ -36,17 +31,13 @@ export default function Presets({ rules, reload }) {
try {
const res = await fetch(draft.id ? `/api/presets/${draft.id}` : '/api/presets', {
method: draft.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json', ...(draft.id ? { 'X-Edit-Token': tokens[draft.id] } : {}) },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Saving failed')
const data = await res.json()
let id = draft.id
if (!id) {
id = data.id
const t = { ...tokens, [id]: data.token }
saveTokens(t); setTokens(t)
}
if (!id) id = data.id
await reload()
setSel(id); setDraft(null); flash('Preset saved.')
} catch (e) { flash(String(e.message || e)) }
@@ -55,22 +46,20 @@ export default function Presets({ rules, reload }) {
const remove = async () => {
if (!window.confirm(`Delete the preset "${current.name}"? Characters that already use it keep their numbers.`)) return
try {
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': tokens[current.id] } })
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Deleting failed')
const t = { ...tokens }; delete t[current.id]
saveTokens(t); setTokens(t)
await reload(); setSel(rules.default_preset || 'standard')
} catch (e) { flash(String(e.message || e)) }
}
const kind = (p) => (p.builtin ? 'Built in' : tokens[p.id] ? 'Yours' : 'Shared')
const kind = (p) => (p.builtin ? 'Built in' : p.mine ? 'Yours' : p.author ? `By ${p.author}` : 'Shared')
return (
<section className="page">
<h1>Rules presets</h1>
<p className="muted narrow">
A preset is a named set of creation numbers (points for attributes and skills, talents, proficiency ...). Pick one on the Concept page of the creator.
Presets are stored on the server, so everyone using this site can use them; only the browser that created a preset can change or delete it.
Presets are stored on the server, so everyone using this site can use them; only the account that created a preset can change or delete it.
Built-in presets come from <code>rules.json</code>. Duplicate one to make your own version.
</p>
<div className="presets-layout">
+6 -5
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react'
import Tip from './Tip.jsx'
import { countChanges, diffRows, sheetModel } from './sheetModel.js'
function Rows({ now, before, valueLabel }) {
@@ -7,8 +8,8 @@ function Rows({ now, before, valueLabel }) {
return (
<table className="difftable"><tbody>
{rows.map((r) => (
<tr key={r.k} className={`d-${r.status}`}>
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
<tr key={r.k} className={`d-${r.status} ${r.dim ? 'dim' : ''}`}>
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}<Tip text={r.tip}>{r.label}</Tip>{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
<td>
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && <span className="was">{r.old.val} → </span>}
{valueLabel ? valueLabel(r) : r.val}
@@ -51,12 +52,12 @@ export default function SheetPanel({ ch, committed, baseRules, onApply, onDiscar
<Rows now={now.skills} before={before.skills} />
<h4>Talents</h4>
<Rows now={now.talents} before={before.talents} />
<h4>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></h4>
<h4><Tip text={now.tips.capacity}>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></Tip></h4>
<Rows now={now.augments} before={before.augments} />
<h4>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></h4>
<h4><Tip text={now.tips.carry}>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></Tip></h4>
<Rows now={now.gear} before={before.gear} valueLabel={(r) => (r.val > 1 ? `x${r.val}` : '')} />
<h4>Points left</h4>
<div className="small muted">Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}</div>
<div className="small muted"><Tip text={now.tips.pools}>Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}</Tip></div>
</aside>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { useId, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
// Tooltip that works with mouse, keyboard focus and touch. `text` is a string or a list of lines.
// Without `icon` the children get a dotted underline; with `icon` a small "i" is shown after them.
export default function Tip({ text, children, icon }) {
const lines = (Array.isArray(text) ? text : [text]).filter(Boolean)
const ref = useRef(null)
const pop = useRef(null)
const id = useId()
const [open, setOpen] = useState(false)
const [pos, setPos] = useState({ left: 0, top: 0, ready: false })
useLayoutEffect(() => {
if (!open || !ref.current || !pop.current) return
const r = ref.current.getBoundingClientRect()
const w = pop.current.offsetWidth, h = pop.current.offsetHeight
const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2))
const below = r.bottom + 6 + h < window.innerHeight
setPos({ left, top: below ? r.bottom + 6 : Math.max(8, r.top - 6 - h), ready: true })
}, [open, lines.join('|')]) // eslint-disable-line
if (!lines.length) return children
const show = () => setOpen(true)
const hide = () => { setOpen(false); setPos((p) => ({ ...p, ready: false })) }
return (
<>
<span ref={ref} tabIndex={0} className={icon ? 'tipwrap' : 'tipped'} aria-describedby={open ? id : undefined}
onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide} onClick={show}
onKeyDown={(e) => e.key === 'Escape' && hide()}>
{children}{icon && <span className="tipicon" aria-label="Explanation">i</span>}
</span>
{open && createPortal(
<div ref={pop} id={id} role="tooltip" className="tippop" style={{ left: pos.left, top: pos.top, visibility: pos.ready ? 'visible' : 'hidden' }}>
{lines.map((l, i) => <div key={i}>{l}</div>)}
</div>, document.body)}
</>
)
}
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from 'react'
// Table tools for everybody: tape measure, area templates (spells) and map markers with notes.
const J = { 'Content-Type': 'application/json' }
const post = (url, body) => fetch(url, { method: 'POST', headers: J, body: JSON.stringify(body) }).catch(() => null)
const del = (url) => fetch(url, { method: 'DELETE' }).catch(() => null)
const rad = (d) => (d * Math.PI) / 180
const half = (v) => Math.round(v * 2) / 2
const KINDS = [['circle', '◯ Circle'], ['cone', '◔ Cone'], ['line', '▭ Line'], ['square', '▢ Square']]
const ICONS = ['📌', '⚠️', '💰', '🔒', '🚪', '❓', '⭐', '☠️', '🏕️', '👁️']
// shape (in map pixels) of a template: {kind, x, y, dir, size (squares), width}
function Shape({ t, g, color, ghost }) {
const L = t.size * g
const common = { fill: color, fillOpacity: ghost ? 0.18 : 0.3, stroke: color, strokeWidth: 3, strokeDasharray: ghost ? '8 6' : undefined }
if (t.kind === 'circle') return <circle cx={t.x} cy={t.y} r={L} {...common} />
if (t.kind === 'cone') {
const a1 = rad(t.dir - t.width / 2), a2 = rad(t.dir + t.width / 2)
return <path d={`M${t.x} ${t.y} L${t.x + L * Math.cos(a1)} ${t.y + L * Math.sin(a1)} A${L} ${L} 0 0 1 ${t.x + L * Math.cos(a2)} ${t.y + L * Math.sin(a2)} Z`} {...common} />
}
if (t.kind === 'line') return <rect x={t.x} y={t.y - (t.width * g) / 2} width={L} height={t.width * g} transform={`rotate(${t.dir} ${t.x} ${t.y})`} {...common} />
return <rect x={t.x - L / 2} y={t.y - L / 2} width={L} height={L} transform={`rotate(${t.dir} ${t.x} ${t.y})`} {...common} />
}
// where the label of a shape goes (its far end)
const tip = (t, g) => {
const L = t.size * g
if (t.kind === 'circle' || t.kind === 'square') return { x: t.x, y: t.y }
return { x: t.x + (L / 2) * Math.cos(rad(t.dir)), y: t.y + (L / 2) * Math.sin(rad(t.dir)) }
}
const sizeLabel = (t) => (t.kind === 'circle' ? `r ${t.size}` : t.kind === 'square' ? `${t.size}×${t.size}` : `${t.size}`) + ' sq'
export function useTableTools({ id, lobby: lobbyIn, mapState }) {
const lobby = lobbyIn || { members: [], palette: [], dm_color: '#d4af37' }
const [mode, setMode] = useState(null) // 'ruler' | 'tpl' | 'mark'
const [kind, setKind] = useState('cone')
const [angle, setAngle] = useState(60)
const [lineW, setLineW] = useState(1)
const [origin, setOrigin] = useState(null)
const [hover, setHover] = useState(null)
const [draft, setDraft] = useState(null) // new marker being written
const [selMark, setSelMark] = useState(null)
const [measures, setMeasures] = useState([])
const map = mapState.map
const g = map?.grid || 50
const colorOf = (user, dm) => (dm ? lobby.dm_color : lobby.palette.find((p) => p.id === lobby.members.find((m) => m.username === user)?.color)?.hex || '#ffffff')
const me = lobby.members.find((m) => m.me)
const myColor = lobby.is_dm ? lobby.dm_color : lobby.palette.find((p) => p.id === me?.color)?.hex || '#ffffff'
useEffect(() => {
const k = (e) => { if (e.key === 'Escape') { setOrigin(null); setDraft(null); setMode(null) } }
window.addEventListener('keydown', k)
return () => window.removeEventListener('keydown', k)
}, [])
useEffect(() => { setOrigin(null); setHover(null); if (mode !== 'mark') setDraft(null) }, [mode, kind])
const shapeFrom = (p) => {
const dx = p.x - origin.x, dy = p.y - origin.y
const dist = Math.hypot(dx, dy) / g
const size = Math.max(0.5, half(kind === 'square' ? dist * 2 : dist))
return { kind, x: origin.x, y: origin.y, dir: (Math.atan2(dy, dx) * 180) / Math.PI, size, width: kind === 'cone' ? angle : kind === 'line' ? lineW : 1 }
}
const onClick = (raw) => {
const p = { x: Math.min(Math.max(raw.x, 0), map.w), y: Math.min(Math.max(raw.y, 0), map.h) } // clicks next to the map count as its edge
if (mode === 'ruler') {
if (!origin) return setOrigin(p)
post(`/api/lobbies/${id}/measure`, { x1: origin.x, y1: origin.y, x2: p.x, y2: p.y })
setOrigin(null)
} else if (mode === 'tpl') {
if (!origin) return setOrigin(p)
post(`/api/lobbies/${id}/templates`, shapeFrom(p))
setOrigin(null); setHover(null)
} else if (mode === 'mark') {
setSelMark(null)
setDraft({ x: p.x, y: p.y, icon: '📌', title: '', note: '', shared: false })
}
}
const onHover = origin ? (p) => setHover({ x: Math.min(Math.max(p.x, 0), map.w), y: Math.min(Math.max(p.y, 0), map.h) }) : undefined
const receive = (n) => { // a measurement somebody shared
const key = `${Date.now()}${Math.random()}`
setMeasures((l) => [...l.slice(-4), { key, ...n, color: colorOf(n.user, n.dm) }])
setTimeout(() => setMeasures((l) => l.filter((x) => x.key !== key)), 6000)
}
const templates = mapState.templates || []
const marks = mapState.marks || []
const dist = (a, b) => half(Math.hypot(b.x - a.x, b.y - a.y) / g)
const overlay = !map ? null : (
<>
<svg className="toollayer" width={map.w} height={map.h} aria-hidden="true">
{templates.map((t) => {
const c = colorOf(t.user, t.dm), e = tip(t, g)
return <g key={t.id}><Shape t={t} g={g} color={c} /><circle cx={t.x} cy={t.y} r="5" fill={c} />
<text x={e.x} y={e.y} className="toolabel" fontSize={Math.max(12, g * 0.32)} textAnchor="middle">{sizeLabel(t)}</text></g>
})}
{mode === 'tpl' && origin && hover && (() => { const t = shapeFrom(hover); const e = tip(t, g)
return <g><Shape t={t} g={g} color={myColor} ghost /><text x={e.x} y={e.y} className="toolabel" fontSize={Math.max(12, g * 0.32)} textAnchor="middle">{sizeLabel(t)}</text></g> })()}
{mode === 'ruler' && origin && hover && <Line a={origin} b={hover} g={g} color={myColor} label={`${dist(origin, hover)} sq`} />}
{measures.map((m) => <Line key={m.key} a={{ x: m.x1, y: m.y1 }} b={{ x: m.x2, y: m.y2 }} g={g} color={m.color} label={`${dist({ x: m.x1, y: m.y1 }, { x: m.x2, y: m.y2 })} sq${m.dm ? '' : ` · ${m.user}`}`} />)}
</svg>
{marks.map((k) => (
<button key={k.id} className={`mappin ${selMark === k.id ? 'sel' : ''} ${k.shared ? 'shared' : ''}`} style={{ left: k.x, top: k.y, fontSize: Math.max(18, g * 0.7) }}
title={`${k.title}${k.note ? `\n${k.note}` : ''}${k.mine ? '' : `\n(${k.user})`}`}
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); setDraft(null); setSelMark(selMark === k.id ? null : k.id) }}>{k.icon}</button>
))}
{draft && <span className="mappin draft" style={{ left: draft.x, top: draft.y, fontSize: Math.max(18, g * 0.7) }}>{draft.icon}</span>}
</>
)
const buttons = (
<>
<button className={mode === 'ruler' ? 'on' : 'ghost'} aria-pressed={mode === 'ruler'} onClick={() => setMode(mode === 'ruler' ? null : 'ruler')} title="Tape measure: click the start, then the end. Everybody sees the distance for a moment.">📏 Measure</button>
<button className={mode === 'tpl' ? 'on' : 'ghost'} aria-pressed={mode === 'tpl'} onClick={() => setMode(mode === 'tpl' ? null : 'tpl')} title="Area templates for spells: circle, cone, line, square">⬡ Area</button>
<button className={mode === 'mark' ? 'on' : 'ghost'} aria-pressed={mode === 'mark'} onClick={() => setMode(mode === 'mark' ? null : 'mark')} title="Place a marker with a note">📌 Marker</button>
</>
)
const cur = marks.find((k) => k.id === selMark)
const panel = !map ? null : (
<>
{mode === 'ruler' && <div className="tokenpanel toolspanel mapui"><b className="small">Tape measure</b><p className="small muted">{origin ? 'Click the end point. Esc cancels.' : 'Click the start point.'} 1 square = 1 grid cell. The result is shown to everybody for a few seconds.</p></div>}
{mode === 'tpl' && (
<div className="tokenpanel toolspanel mapui">
<b className="small">Area template</b>
<div className="toolrow" role="group" aria-label="Shape">{KINDS.map(([k, l]) => <button key={k} className={kind === k ? 'on mini' : 'ghost mini'} onClick={() => setKind(k)}>{l}</button>)}</div>
{kind === 'cone' && <label className="small">Angle {angle}°<input type="range" min="30" max="180" step="10" value={angle} onChange={(e) => setAngle(Number(e.target.value))} /></label>}
{kind === 'line' && <label className="small">Width {lineW} sq<input type="range" min="1" max="6" step="1" value={lineW} onChange={(e) => setLineW(Number(e.target.value))} /></label>}
<p className="small muted">{!origin ? (kind === 'cone' || kind === 'line' ? 'Click where it starts.' : 'Click the centre.') : 'Click to set direction and size.'} It stays for everybody until removed.</p>
</div>
)}
{(mode === 'tpl' || templates.length > 0) && templates.length > 0 && (
<div className={`tokenpanel toolspanel tpllist mapui ${mode ? 'low' : ''}`}>
<b className="small">Areas on the map</b>
{templates.map((t) => (
<div key={t.id} className="row small"><i className="dot" style={{ background: colorOf(t.user, t.dm) }} /> {KINDS.find((k) => k[0] === t.kind)?.[1]} {sizeLabel(t)} · {t.dm ? 'DM' : t.user}
{(t.mine || lobby.is_dm) && <button className="ghost mini danger" aria-label="Remove area" onClick={() => del(`/api/lobbies/${id}/templates/${t.id}`)}>✕</button>}</div>
))}
{lobby.is_dm && templates.length > 1 && <button className="ghost mini" onClick={() => del(`/api/lobbies/${id}/templates`)}>Remove all</button>}
</div>
)}
{mode === 'mark' && !draft && <div className="tokenpanel toolspanel mapui"><b className="small">Marker</b><p className="small muted">Click the map to place a marker with a note. Markers are private unless you share them with the table.</p></div>}
{draft && <MarkForm key="new" init={draft} title="New marker" onSave={async (v) => { await post(`/api/lobbies/${id}/marks`, { ...draft, ...v }); setDraft(null); setMode(null) }} onCancel={() => setDraft(null)} />}
{cur && !draft && (cur.mine
? <MarkForm key={cur.id} init={cur} title="Your marker" onSave={async (v) => { await fetch(`/api/lobbies/${id}/marks/${cur.id}`, { method: 'PUT', headers: J, body: JSON.stringify(v) }).catch(() => null); setSelMark(null) }}
onCancel={() => setSelMark(null)} onDelete={() => { del(`/api/lobbies/${id}/marks/${cur.id}`); setSelMark(null) }} />
: <div className="tokenpanel toolspanel mapui"><div className="row"><b>{cur.icon} {cur.title}</b><button className="ghost mini" onClick={() => setSelMark(null)} aria-label="Close">×</button></div>
<p className="small notetext">{cur.note || <span className="muted">No note.</span>}</p><p className="small muted">Shared by {cur.user}</p>
{lobby.is_dm && <button className="ghost mini danger" onClick={() => { del(`/api/lobbies/${id}/marks/${cur.id}`); setSelMark(null) }}>Remove</button>}</div>)}
</>
)
return { mode, active: !!mode, tool: mode ? 'draw' : undefined, onClick, onHover, overlay, buttons, panel, receive, stop: () => setMode(null) }
}
function Line({ a, b, g, color, label }) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2
return (
<g>
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#000" strokeWidth="6" strokeLinecap="round" opacity=".5" />
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={color} strokeWidth="3" strokeLinecap="round" strokeDasharray="10 7" />
<circle cx={a.x} cy={a.y} r="6" fill={color} /><circle cx={b.x} cy={b.y} r="6" fill={color} />
<text x={mx} y={my - 8} className="toolabel" fontSize={Math.max(13, g * 0.34)} textAnchor="middle">{label}</text>
</g>
)
}
function MarkForm({ init, title, onSave, onCancel, onDelete }) {
const [v, setV] = useState({ icon: init.icon, title: init.title, note: init.note, shared: !!init.shared })
const busy = useRef(false)
const save = async () => { if (busy.current) return; busy.current = true; await onSave(v) }
return (
<div className="tokenpanel toolspanel mapui">
<div className="row"><b className="small">{title}</b><button className="ghost mini" onClick={onCancel} aria-label="Close">×</button></div>
<div className="iconrow" role="group" aria-label="Icon">{ICONS.map((i) => <button key={i} className={v.icon === i ? 'on mini' : 'ghost mini'} onClick={() => setV({ ...v, icon: i })}>{i}</button>)}</div>
<input value={v.title} maxLength={40} placeholder="Title" aria-label="Marker title" onChange={(e) => setV({ ...v, title: e.target.value })} autoFocus />
<textarea rows="4" maxLength={1000} placeholder="Your note ..." aria-label="Marker note" value={v.note} onChange={(e) => setV({ ...v, note: e.target.value })} />
<label className="small chk"><input type="checkbox" checked={v.shared} onChange={(e) => setV({ ...v, shared: e.target.checked })} /> Show to everybody at the table</label>
<div className="row"><button className="on" onClick={save}>Save</button>{onDelete && <button className="ghost danger" onClick={onDelete}>Delete</button>}</div>
</div>
)
}
+27 -20
View File
@@ -6,15 +6,15 @@ export function emptyCharacter(rules) {
const attributes = {}
rules.attributes.forEach((a) => (attributes[a.id] = rules.creation.attr_start))
return {
name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '',
name: '', player: '', concept: '', background: '', portrait: '', class_id: '', subclass_id: '',
attributes,
profession: '', profession_bonus_attr: '', profession_picks: [],
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
skill_points: {}, proficiencies: {}, proficiency_picks: {}, unlocked: [],
talents: [], augments: [],
kit: { weapon: '', armor: '', tool: '', resources: [], extras: {} },
kit: { extras: {} }, // item id -> quantity
gear: { other: '' },
notes: '',
notes: '', conditions: '',
}
}
@@ -65,7 +65,7 @@ export function normalize(ch, rules) {
const preset = ch.preset || rules.default_preset || 'standard'
const creation = ch.creation ?? presetOverrides(rules, preset)
const base = emptyCharacter(effectiveRules(rules, { creation }))
const out = { ...base, ...ch, preset, creation, kit: { ...base.kit, ...(ch.kit || {}) }, gear: { ...base.gear, ...(ch.gear || {}) } }
const out = { ...base, ...ch, preset, creation, kit: { extras: {}, ...(ch.kit || {}) }, gear: { ...base.gear, ...(ch.gear || {}) } }
if (typeof out.skill_pool === 'number') { // older characters stored the pool size on the character
if (out.skill_pool !== effectiveRules(rules, out).creation.skill_points_default) {
out.creation = { ...out.creation, skill_points_default: out.skill_pool }
@@ -73,31 +73,37 @@ export function normalize(ch, rules) {
}
}
delete out.skill_pool
// Older characters had starting-kit slots (weapon, armor, tool, resources): they become ordinary items
const k = out.kit
if (k.weapon || k.armor || k.tool || (k.resources || []).length) {
const extras = { ...k.extras }
;[k.weapon, k.armor, k.tool, ...(k.resources || [])].filter(Boolean).forEach((id) => { extras[id] = (extras[id] || 0) + 1 })
out.kit = { extras }
}
// The preset may have been deleted since: fall back to whatever matches the numbers
if (out.preset !== 'custom' && !(rules.presets || []).some((x) => x.id === out.preset)) out.preset = matchPreset(rules, out.creation)
// Older characters: advanced skills that already hold points count as unlocked
// Older characters: advanced skills that already hold points or proficiency picks count as unlocked
const unlocked = new Set(out.unlocked)
rules.skills.forEach((s) => { if (s.type === 'advanced' && (out.skill_points[s.id] || 0) > 0) unlocked.add(s.id) })
rules.skills.forEach((s) => { if (s.type === 'advanced' && ((out.skill_points[s.id] || 0) > 0 || (out.proficiency_picks[s.id] || 0) > 0)) unlocked.add(s.id) })
return { ...out, unlocked: [...unlocked] }
}
// Clears points on advanced skills that are neither unlocked nor granted by a profession proficiency.
// Clears points and proficiency picks on advanced skills that are neither unlocked nor granted by the profession.
export function pruneLocked(ch, rules) {
const points = { ...ch.skill_points }
const picks = { ...ch.proficiency_picks }
rules.skills.forEach((s) => {
if (s.type === 'advanced' && !ch.unlocked.includes(s.id) && !ch.proficiencies[s.id] && !(ch.proficiency_picks[s.id] > 0)) delete points[s.id]
if (s.type === 'advanced' && !ch.unlocked.includes(s.id) && !ch.proficiencies[s.id]) { delete points[s.id]; delete picks[s.id] }
})
return { ...ch, skill_points: points }
return { ...ch, skill_points: points, proficiency_picks: picks }
}
export function kitItems(ch, rules) {
const items = Object.fromEntries(rules.equipment.map((e) => [e.id, e]))
const k = ch.kit
const out = []
;[['Weapon', k.weapon], ['Armor', k.armor], ['Tool', k.tool]].forEach(([label, id]) => { if (items[id]) out.push({ label, item: items[id], qty: 1 }) })
k.resources.forEach((id) => { if (items[id]) out.push({ label: 'Resource', item: items[id], qty: 1 }) })
Object.entries(k.extras).forEach(([id, qty]) => { if (items[id] && qty > 0) out.push({ label: 'Extra', item: items[id], qty }) })
return out
const cats = Object.fromEntries((rules.equipment_categories || []).map((c) => [c.id, c.name]))
return Object.entries(ch.kit.extras || {})
.filter(([id, qty]) => items[id] && qty > 0)
.map(([id, qty]) => ({ label: cats[items[id].cat] || '', item: items[id], qty }))
}
export function compute(ch, rules) {
@@ -118,17 +124,18 @@ export function compute(ch, rules) {
const pips = profPip + freePips
const prof = [0, cr.proficiency_bonus, cr.expertise_bonus][pips]
const best = Math.max(...s.attrs.map((a) => attrs[a])) // with several attributes the higher one counts
const base = s.type === 'advanced' ? Math.floor((best * (cr.advanced_attr_percent ?? 100)) / 100) : best // advanced skills only use part of the attribute
const unlocked = ch.unlocked.includes(s.id)
return { ...s, points, prof, profPip, freePips, pips, unlocked, total: best + points + prof,
usable: s.type === 'basic' || unlocked || prof > 0, // advanced skills must be unlocked
free: s.type === 'advanced' && prof > 0, // unlocked by the profession, costs nothing
return { ...s, points, prof, profPip, freePips, pips, unlocked, base, total: base + points + prof,
usable: s.type === 'basic' || unlocked || profPip > 0, // advanced skills must be unlocked (a profession proficiency does it for free)
free: s.type === 'advanced' && profPip > 0, // unlocked by the profession, costs nothing
max: Math.floor(best / cr.skill_max_divisor) }
})
const profSpent = skills.reduce((s, x) => s + x.freePips, 0)
const profAllowed = (cr.free_proficiencies ?? 0) + rules.talents.filter((t) => ch.talents.includes(t.id)).reduce((s, t) => s + (t.proficiency_picks || 0), 0)
const unlockCost = cr.advanced_unlock_cost ?? 0
const paidUnlocks = skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.prof).length
const paidUnlocks = skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.profPip).length
const attrSpent = rules.attributes.reduce((s, a) => s + (ch.attributes[a.id] - cr.attr_start), 0)
const skillSpent = Object.values(ch.skill_points).reduce((s, v) => s + v, 0) + paidUnlocks * unlockCost
const chosen = rules.augments.filter((a) => ch.augments.includes(a.id))
@@ -175,7 +182,7 @@ export function setProfessionPicks(ch, rules, picks) {
export function resetStep(ch, rules, step) {
const e = emptyCharacter(rules)
switch (step) {
case 0: return { ...ch, name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '' }
case 0: return { ...ch, name: '', player: '', concept: '', background: '', portrait: '', class_id: '', subclass_id: '' }
case 1: return { ...ch, attributes: e.attributes }
case 2: return pruneLocked({ ...ch, proficiencies: stripProfessionProfs(ch, rules), profession: '', profession_bonus_attr: '', profession_picks: [] }, rules)
case 3: return { ...ch, skill_points: {}, unlocked: [], proficiency_picks: {} }
+70
View File
@@ -0,0 +1,70 @@
// Plain-language explanations of where numbers come from, shown in tooltips.
// `x` is { ch, rules, c } (character, effective rules, result of compute()).
const attrName = (rules, id) => rules.attributes.find((a) => a.id === id)?.name || id
const attrShort = (rules, id) => rules.attributes.find((a) => a.id === id)?.short || id
export function explainAttr(a, { ch, rules, c }) {
const cr = rules.creation
const base = ch.attributes[a.id] ?? cr.attr_start
const bonus = ch.profession && ch.profession_bonus_attr === a.id ? cr.profession_attr_bonus : 0
const cap = Math.min(cr.attr_max, cr.attr_start + cr.attr_max_raise)
const used = rules.derived.filter((d) => d.formula.includes(a.id)).map((d) => d.name)
return [
`${a.name} = ${cr.attr_start} start + ${base - cr.attr_start} from your points${bonus ? ` + ${bonus} profession bonus` : ''} = ${c.attrs[a.id]}`,
`Modifier = value ÷ 10, rounded down = ${c.mods[a.id]}`,
`Highest value you can reach with your points: ${cap} (start ${cr.attr_start}, at most +${cr.attr_max_raise}, cap ${cr.attr_max}). The profession bonus comes on top.`,
used.length ? `Its modifier feeds: ${used.join(', ')}.` : '',
].filter(Boolean)
}
export function explainDerived(d, { rules, c }) {
const parts = d.formula.map((id) => `${attrName(rules, id)} mod ${c.mods[id]}`)
return [`${d.name} = ${parts.join(' + ')} = ${c.derived[d.id]}`, d.note || ''].filter(Boolean)
}
export function explainSkill(s, { ch, rules, c }) {
const cr = rules.creation
const best = Math.max(...s.attrs.map((id) => c.attrs[id]))
const list = s.attrs.map((id) => `${attrShort(rules, id)} ${c.attrs[id]}`).join(', ')
const training = s.pips === 2 ? `Expertise +${s.prof}` : s.pips === 1 ? `Proficient +${s.prof}` : 'no training +0'
const lines = [
`Target number = ${s.type === 'advanced' && s.base !== best ? `${cr.advanced_attr_percent}% of the best attribute (${best}) = ${s.base}` : `best attribute ${best}`} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`,
`Most points you can put in: best attribute ÷ ${cr.skill_max_divisor}, rounded down = ${s.max}`,
]
if (s.type === 'advanced') {
lines.push(s.free ? 'Advanced skill: unlocked for free by your profession.'
: s.unlocked ? `Advanced skill: unlocked (cost ${c.unlockCost} points from the pool).`
: `Advanced skill: locked. Unlock it for ${c.unlockCost} points from the pool before you can use it.`)
}
if (s.pips === 1 && s.profPip) lines.push('Proficient through your profession.')
return lines
}
export function explainCapacity({ rules, c }) {
const d = rules.derived.find((x) => x.id === 'augmentation')
return [`Capacity = ${d ? d.formula.map((id) => `${attrName(rules, id)} mod ${c.mods[id]}`).join(' + ') : 'see rules'} = ${c.capacityMax}`,
`Each mutation or implant costs capacity; you used ${c.capacityUsed} of ${c.capacityMax}.`]
}
export function explainCarry({ rules, c }) {
return [`Carry capacity = Strength mod ${c.mods.strength} × ${rules.creation.carry_per_strength_mod} kg = ${c.carryMax} kg`,
`Carried now: ${c.weight} kg (sum of all chosen items).`]
}
export function explainAttrPool({ rules, c }) {
return [`Attribute points: ${c.attrSpent} spent of ${rules.creation.attr_points}. Each point raises an attribute by 1.`]
}
export function explainSkillPool({ rules, c }) {
const cr = rules.creation
const paid = c.skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.prof).length
return [`Skill pool: ${c.skillSpent} spent of ${cr.skill_points_default}.`,
`Skill points count 1 each; unlocking an advanced skill costs ${c.unlockCost}${paid ? ` (${paid} unlocked = ${paid * c.unlockCost})` : ''}.`]
}
export function explainProfPool({ rules, c }) {
const cr = rules.creation
return [`Proficiency picks: ${c.profSpent} used of ${c.profAllowed} (${cr.free_proficiencies} free plus talents like Versatile Training).`,
`One pick = Proficient (+${cr.proficiency_bonus}). Two picks on one skill = Expertise (+${cr.expertise_bonus}); a profession proficiency counts as one.`]
}
+16
View File
@@ -0,0 +1,16 @@
// Characters and presets made before accounts existed were protected by secret keys in this browser.
// After login they are handed over to the account once; the old keys are then removed.
export async function claimLegacy() {
const post = (kind, id, token) =>
fetch(`/api/claim/${kind}/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) }).catch(() => null)
try {
const chars = JSON.parse(localStorage.getItem('aw_chars')) || []
for (const c of chars) if (c?.id && c?.token) await post('characters', c.id, c.token)
localStorage.removeItem('aw_chars')
} catch { /* nothing to claim */ }
try {
const presets = JSON.parse(localStorage.getItem('aw_presets')) || {}
for (const [id, token] of Object.entries(presets)) await post('presets', id, token)
localStorage.removeItem('aw_presets')
} catch { /* nothing to claim */ }
}
+9
View File
@@ -3,4 +3,13 @@ import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
// A 401 from any data call means the session ended: tell the app so it shows the login page.
const realFetch = window.fetch.bind(window)
window.fetch = async (...args) => {
const res = await realFetch(...args)
const url = String(typeof args[0] === 'string' ? args[0] : args[0]?.url || '')
if (res.status === 401 && url.startsWith('/api/') && !url.startsWith('/api/auth/login')) window.dispatchEvent(new Event('aw-unauthorized'))
return res
}
createRoot(document.getElementById('root')).render(<App />)
+37
View File
@@ -0,0 +1,37 @@
// Decor tokens ("props"): drawn from above, purely visual. Built-in ones are SVG (id "b:<name>"), own uploads are images (id "c:<id>").
// w / h are the default size in grid squares.
const wood = '#8a5a2b', dark = '#5b3a18', light = '#b98545', metal = '#8d949b', stone = '#7d7f84', green = '#3f7d3a'
export const BUILTIN = [
{ id: 'chest', name: 'Chest', w: 1, h: 0.7, art: <><rect x="4" y="12" width="92" height="76" rx="8" fill={wood} stroke={dark} strokeWidth="5" /><rect x="4" y="40" width="92" height="8" fill={dark} /><rect x="42" y="34" width="16" height="20" rx="3" fill="#e0b84a" stroke={dark} strokeWidth="3" /><path d="M24 12v76M76 12v76" stroke={dark} strokeWidth="5" /></> },
{ id: 'barrel', name: 'Barrel', w: 1, h: 1, art: <><circle cx="50" cy="50" r="45" fill={wood} stroke={dark} strokeWidth="5" /><circle cx="50" cy="50" r="33" fill="none" stroke={dark} strokeWidth="4" /><circle cx="50" cy="50" r="20" fill={light} stroke={dark} strokeWidth="3" /><path d="M30 50h40" stroke={dark} strokeWidth="3" /></> },
{ id: 'crate', name: 'Crate', w: 1, h: 1, art: <><rect x="5" y="5" width="90" height="90" fill={light} stroke={dark} strokeWidth="5" /><path d="M5 5l90 90M95 5L5 95" stroke={dark} strokeWidth="5" /><rect x="5" y="5" width="90" height="90" fill="none" stroke={dark} strokeWidth="10" opacity=".4" /></> },
{ id: 'table', name: 'Table', w: 2, h: 1, art: <><rect x="3" y="12" width="94" height="76" rx="6" fill={light} stroke={dark} strokeWidth="5" /><path d="M12 30h76M12 50h76M12 70h76" stroke={wood} strokeWidth="2" opacity=".7" /></> },
{ id: 'roundtable', name: 'Round table', w: 2, h: 2, art: <><circle cx="50" cy="50" r="46" fill={light} stroke={dark} strokeWidth="5" /><circle cx="50" cy="50" r="30" fill="none" stroke={wood} strokeWidth="2" opacity=".7" /></> },
{ id: 'chair', name: 'Chair', w: 0.6, h: 0.6, art: <><rect x="12" y="25" width="76" height="68" rx="8" fill={wood} stroke={dark} strokeWidth="6" /><rect x="8" y="6" width="84" height="22" rx="6" fill={dark} /></> },
{ id: 'bed', name: 'Bed', w: 1, h: 2, art: <><rect x="4" y="3" width="92" height="94" rx="8" fill={dark} /><rect x="9" y="8" width="82" height="84" rx="6" fill="#a8423b" /><rect x="14" y="12" width="72" height="22" rx="8" fill="#eee" stroke="#bbb" strokeWidth="2" /><path d="M9 46h82" stroke="#7b2d28" strokeWidth="4" /></> },
{ id: 'door', name: 'Door (decor)', w: 1, h: 0.25, art: <><rect x="2" y="15" width="96" height="70" rx="6" fill={wood} stroke={dark} strokeWidth="8" /><circle cx="80" cy="50" r="7" fill="#e0b84a" /></> },
{ id: 'campfire', name: 'Campfire', w: 1, h: 1, art: <><circle cx="50" cy="50" r="46" fill="#3b2f2a" /><path d="M20 70L80 30M20 30L80 70" stroke={dark} strokeWidth="9" strokeLinecap="round" /><path d="M50 18c14 16 22 26 14 42-5 9-23 9-28 0-8-16 6-26 14-42z" fill="#f08a24" /><path d="M50 40c8 9 10 14 6 21-3 5-10 5-12 0-3-7 2-12 6-21z" fill="#ffd24a" /></> },
{ id: 'torch', name: 'Torch', w: 0.5, h: 0.5, art: <><circle cx="50" cy="50" r="46" fill="#ffcf5a" opacity=".35" /><circle cx="50" cy="50" r="22" fill="#f08a24" /><circle cx="50" cy="50" r="11" fill="#ffd24a" /></> },
{ id: 'tree', name: 'Tree', w: 3, h: 3, art: <><circle cx="50" cy="50" r="48" fill="#2f6a2c" /><circle cx="36" cy="40" r="26" fill={green} /><circle cx="62" cy="56" r="28" fill="#47903f" /><circle cx="50" cy="50" r="10" fill="#6b4a25" opacity=".8" /></> },
{ id: 'bush', name: 'Bush', w: 1.2, h: 1.2, art: <><circle cx="35" cy="55" r="28" fill="#2f6a2c" /><circle cx="62" cy="42" r="28" fill={green} /><circle cx="64" cy="66" r="24" fill="#47903f" /></> },
{ id: 'rock', name: 'Rock', w: 1.2, h: 1, art: <path d="M10 62L22 24l32-14 30 12 8 34-18 22-34 4z" fill={stone} stroke="#4c4e52" strokeWidth="5" strokeLinejoin="round" /> },
{ id: 'rug', name: 'Rug', w: 3, h: 2, art: <><rect x="2" y="6" width="96" height="88" rx="4" fill="#8e2f3a" stroke="#d9b45a" strokeWidth="5" /><rect x="12" y="16" width="76" height="68" fill="none" stroke="#d9b45a" strokeWidth="3" /><circle cx="50" cy="50" r="14" fill="#d9b45a" opacity=".8" /></> },
{ id: 'stairs', name: 'Stairs', w: 1, h: 2, art: <><rect x="3" y="3" width="94" height="94" fill="#9b9ea3" stroke="#555" strokeWidth="4" />{[18, 33, 48, 63, 78].map((y) => <path key={y} d={`M3 ${y}h94`} stroke="#555" strokeWidth="4" />)}</> },
{ id: 'pillar', name: 'Pillar', w: 1, h: 1, art: <><circle cx="50" cy="50" r="44" fill="#a7a9ae" stroke="#5c5e63" strokeWidth="6" /><circle cx="50" cy="50" r="28" fill="#c4c6cb" stroke="#5c5e63" strokeWidth="3" /></> },
{ id: 'altar', name: 'Altar', w: 2, h: 1, art: <><rect x="3" y="12" width="94" height="76" rx="4" fill="#6c6f77" stroke="#3d3f45" strokeWidth="5" /><path d="M50 26v48M30 44h40" stroke="#d9b45a" strokeWidth="7" strokeLinecap="round" /></> },
{ id: 'bones', name: 'Bones', w: 1, h: 1, art: <><g stroke="#4a4338" strokeWidth="3" fill="#e8e1cf"><circle cx="40" cy="42" r="17" /><rect x="30" y="52" width="20" height="12" rx="3" /><path d="M58 70l30-28" strokeWidth="9" stroke="#e8e1cf" strokeLinecap="round" /><path d="M12 82l34-14" strokeWidth="9" stroke="#e8e1cf" strokeLinecap="round" /></g><circle cx="34" cy="40" r="4" fill="#222" /><circle cx="47" cy="40" r="4" fill="#222" /></> },
{ id: 'cart', name: 'Wreck / cart', w: 2, h: 1, art: <><rect x="6" y="14" width="88" height="72" rx="5" fill="#6d5a45" stroke="#3b2f22" strokeWidth="5" /><path d="M6 36h88M6 62h88" stroke="#3b2f22" strokeWidth="3" /><rect x="14" y="2" width="14" height="12" fill="#222" /><rect x="72" y="86" width="14" height="12" fill="#222" /></> },
]
const byId = Object.fromEntries(BUILTIN.map((p) => [p.id, p]))
export const isCustom = (id) => typeof id === 'string' && id.startsWith('c:')
export const builtin = (id) => byId[String(id).replace(/^b:/, '')]
// Picture of a prop filling its box. customSrc(id) gives the image URL for own uploads.
export function PropArt({ prop, customSrc }) {
if (isCustom(prop)) return <img src={customSrc?.(prop.slice(2))} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />
const b = builtin(prop)
return <svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '100%', display: 'block', pointerEvents: 'none' }} aria-hidden="true">{b ? b.art : <rect width="100" height="100" fill="#888" />}</svg>
}
+7 -5
View File
@@ -1,26 +1,28 @@
import { compute, effectiveRules, kitItems } from './calc.js'
import { explainAttr, explainCapacity, explainCarry, explainDerived, explainSkill, explainAttrPool, explainSkillPool, explainProfPool } from './explain.js'
// Flat, comparable view of a character, used by the side panel to show what changed.
export function sheetModel(ch, baseRules) {
const rules = effectiveRules(baseRules, ch)
const c = compute(ch, rules)
const x = { ch, rules, c }
const prof = rules.professions.find((p) => p.id === ch.profession)
const cls = rules.classes.find((k) => k.id === ch.class_id)
const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id)
const preset = ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name || 'Custom'
const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : s.type === 'advanced' && s.unlocked ? 'Unlocked' : '')
const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.type === 'advanced' ? 'Unlocked' : '')
return {
c,
tips: { capacity: explainCapacity(x), carry: explainCarry(x), pools: [...explainAttrPool(x), ...explainSkillPool(x), ...explainProfPool(x)] },
head: [
{ k: 'name', label: 'Name', val: ch.name || 'Nameless' },
{ k: 'profession', label: 'Profession', val: prof?.name || '–' },
{ k: 'class', label: 'Class', val: cls ? [cls.name, sub?.name].filter(Boolean).join(' / ') : '–' },
{ k: 'preset', label: 'Rules', val: preset },
],
attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}` })),
derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id] })),
skills: c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced'))
.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s) })),
attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}`, tip: explainAttr(a, x) })),
derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id], tip: explainDerived(d, x) })),
skills: c.skills.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s), dim: !s.usable, tip: explainSkill(s, x) })),
talents: rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => ({ k: t.id, label: t.name, desc: t.desc })),
augments: rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => ({ k: a.id, label: a.name, desc: `${a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost ${a.cost}${a.desc ? ` · ${a.desc}` : ''}` })),
gear: kitItems(ch, rules).map((g) => ({ k: g.item.id, label: g.item.name, val: g.qty, desc: [g.item.stat, `${g.item.kg} kg`, g.item.note].filter(Boolean).join(' · ') })),
+114
View File
@@ -0,0 +1,114 @@
// Dice sounds. The default is public/dice.mp3 (the DM can replace it per lobby), played with a slight random pitch and a pitch that
// depends on the die: d4 a little higher and lighter, d100 a little lower and heavier. Results of checks add a short cue; the cues for
// critical results can be custom MP3s from the DM, otherwise they are synthesized tones.
let ctx = null
const get = (k) => { try { return localStorage.getItem(k) } catch { return null } }
const put = (k, v) => { try { localStorage.setItem(k, v) } catch { /* storage may be blocked */ } }
export const sound = {
enabled: get('aw_sound') !== '0',
volume: Number(get('aw_volume') ?? 0.6),
setEnabled(v) { this.enabled = v; put('aw_sound', v ? '1' : '0') },
setVolume(v) { this.volume = v; put('aw_volume', String(v)) },
}
export const DEFAULT_ROLL = '/dice.mp3'
const PITCH = { 4: 1.08, 6: 1.05, 8: 1.025, 10: 1.0, 12: 0.98, 20: 0.955, 100: 0.92 } // playback speed per die (lighter ... heavier)
const audio = () => {
if (!ctx) {
const C = window.AudioContext || window.webkitAudioContext
if (!C) return null
ctx = new C()
}
if (ctx.state === 'suspended') ctx.resume().catch(() => {}) // browsers allow sound only after the page was clicked once
return ctx
}
const buffers = new Map() // url -> Promise<AudioBuffer | null>
const buffer = (url) => {
if (!url) return Promise.resolve(null)
if (!buffers.has(url)) {
const c = audio()
buffers.set(url, !c ? Promise.resolve(null) : fetch(url).then((r) => (r.ok ? r.arrayBuffer() : null))
.then((b) => (b ? c.decodeAudioData(b) : null)).catch(() => { buffers.delete(url); return null }))
}
return buffers.get(url)
}
// load everything of a lobby up front, so the first roll does not wait for the download
export const preload = (cfg) => { if (cfg) [cfg.roll, cfg.crit_success, cfg.crit_failure, cfg.ping].forEach(buffer) }
const clip = (c, out, buf, t, rate, vol) => {
const s = c.createBufferSource()
s.buffer = buf
s.playbackRate.value = rate
const g = c.createGain()
g.gain.value = vol
s.connect(g).connect(out)
s.start(t)
return buf.duration / rate
}
const tone = (c, out, t, freq, len, type = 'triangle', vol = 0.25) => {
const o = c.createOscillator()
o.type = type
o.frequency.value = freq
const g = c.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(vol, t + 0.02)
g.gain.exponentialRampToValueAtTime(0.0001, t + len)
o.connect(g).connect(out)
o.start(t)
o.stop(t + len + 0.05)
}
const cue = {
crit_success: (c, out, t) => { tone(c, out, t, 196, 0.7, 'triangle', 0.3); tone(c, out, t + 0.16, 294, 0.8, 'triangle', 0.28); tone(c, out, t + 0.16, 392, 0.9, 'sine', 0.12) },
success: (c, out, t) => { tone(c, out, t, 147, 0.5, 'triangle', 0.3); tone(c, out, t, 220, 0.4, 'sine', 0.1) },
failure: (c, out, t) => tone(c, out, t, 82, 0.55, 'triangle', 0.35),
crit_failure: (c, out, t) => { tone(c, out, t, 98, 0.5, 'sawtooth', 0.14); tone(c, out, t + 0.22, 62, 1.0, 'sawtooth', 0.18) },
}
// sides: kind of die (100 for checks), n: number of dice, outcome: result of a check (optional)
// cfg: { roll: url, crit_success: url | null, crit_failure: url | null, result: bool, crit_success_on: bool, crit_failure_on: bool }
export async function playRoll(sides, n, outcome, cfg = {}) {
if (!sound.enabled || sound.volume <= 0 || document.hidden) return
const c = audio()
if (!c) return
const [buf, custom] = await Promise.all([buffer(cfg.roll || DEFAULT_ROLL), outcome ? buffer(cfg[outcome]) : null])
const out = c.createGain()
out.gain.value = sound.volume
out.connect(c.destination)
const t0 = c.currentTime + 0.02
let length = 0.9
if (buf) {
const base = PITCH[sides] || 1
const layers = Math.min(Math.max(n, 1), 3) // many dice: a few layers, each with its own pitch
for (let i = 0; i < layers; i++) {
const rate = base * (1 + (Math.random() - 0.5) * 0.05)
length = Math.max(length, i * 0.08 + clip(c, out, buf, t0 + i * 0.08, rate, 1 / Math.sqrt(layers)))
}
}
if (!outcome) return
const t1 = t0 + Math.min(1.2, length * 0.75)
const crit = outcome === 'crit_success' || outcome === 'crit_failure'
if (crit) {
if (cfg[`${outcome}_on`] === false) return
if (custom) clip(c, out, custom, t1, 1, 1)
else cue[outcome](c, out, t1)
} else if (cfg.result !== false) cue[outcome](c, out, t1)
}
// A ping on the map: the DM's MP3 if there is one, otherwise a short two-tone "blip". cfg.ping_on === false switches it off.
export async function playPing(cfg = {}) {
if (!sound.enabled || sound.volume <= 0 || document.hidden || cfg.ping_on === false) return
const c = audio()
if (!c) return
const custom = await buffer(cfg.ping)
const out = c.createGain()
out.gain.value = sound.volume
out.connect(c.destination)
const t = c.currentTime + 0.02
if (custom) clip(c, out, custom, t, 1, 1)
else { tone(c, out, t, 880, 0.18, 'sine', 0.3); tone(c, out, t + 0.11, 1320, 0.35, 'sine', 0.25) }
}
+524
View File
@@ -147,3 +147,527 @@ ul.desclist { list-style: none; padding: 0; margin: 0 0 .5rem; display: grid; ga
main { max-width: 1280px; }
@media (max-width: 1100px) { .creator-body { grid-template-columns: minmax(0, 1fr) 360px; } }
@media (max-width: 800px) { .creator-body { grid-template-columns: 1fr; } }
tr.dim td { opacity: .55; }
.skillcols { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0 2rem; }
.skillgroup h5 { margin: .8em 0 .2em; color: var(--diesium); font-size: .9rem; }
.loginbox { margin-top: 3rem; }
.loginbox form button[type="submit"] { margin-top: .5rem; }
.sharepanel { margin-top: .8rem; padding-top: .6rem; border-top: 1px solid var(--line); }
.sharepanel form input { flex: 1; }
.chip button.linklike { color: inherit; text-decoration: none; margin-left: .2rem; }
.top nav a.navchar { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card.tile { display: flex; flex-direction: column; gap: .3rem; }
.card.tile.on { border-color: var(--noxium); box-shadow: inset 0 0 0 1px var(--noxium); background: var(--panel2); }
.tilestepper { margin-top: auto; padding-top: .4rem; justify-content: flex-end; }
.tipped { border-bottom: 1px dotted var(--muted); cursor: help; }
.tipwrap { cursor: help; }
.tipicon { display: inline-grid; place-items: center; width: 1.05rem; height: 1.05rem; margin-left: .3rem; border: 1px solid var(--muted); border-radius: 50%; color: var(--muted); font-size: .7rem; font-style: italic; font-weight: 700; vertical-align: middle; }
.tipwrap:hover .tipicon, .tipwrap:focus .tipicon { color: var(--noxium); border-color: var(--noxium); }
.tippop { position: fixed; z-index: 1000; max-width: 340px; padding: .55rem .75rem; background: var(--panel2); color: var(--text); border: 1px solid var(--diesium); border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.35); font-size: .85rem; line-height: 1.4; pointer-events: none; }
.tippop div + div { margin-top: .35rem; }
/* lobbies */
a.button { display: inline-block; font-weight: 600; text-decoration: none; background: var(--noxium); color: #12150f; border: 1px solid var(--noxium); padding: .5rem 1rem; border-radius: var(--radius); }
a.button:hover { filter: brightness(1.1); }
.wrapform { flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
.wrapform > input, .wrapform > select { flex: 1 1 12rem; width: auto; }
.lobbycard { display: grid; gap: .45rem; }
.lobbycard.me { border-color: var(--noxium); }
.lobbycard p { margin: 0; }
.joincode { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1.5rem; letter-spacing: .15em; }
span.chip { border: 1px solid var(--line); font-size: .85rem; padding: .1rem .6rem; }
.lobbycard .chips { gap: .4rem; }
h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
/* lobby page: tabs, chat, sheet */
.lobbypage { max-width: 980px; }
.tabs { display: flex; flex-wrap: wrap; gap: .3rem; border-bottom: 1px solid var(--line); margin: .8rem 0 1rem; }
.tabs button { background: transparent; color: var(--text); border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; font-weight: 500; padding: .45rem .9rem; margin-bottom: -1px; }
.tabs button.on { background: var(--panel); border-color: var(--line); border-bottom: 1px solid var(--panel); color: var(--text); font-weight: 700; }
.chatwrap { display: grid; gap: .6rem; }
.chatbox { height: min(55vh, 520px); overflow-y: auto; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: .6rem .8rem; display: flex; flex-direction: column; gap: .25rem; }
.msg { overflow-wrap: anywhere; line-height: 1.35; }
.msg .time { color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
.msg.event { color: var(--muted); font-size: .85rem; font-style: italic; }
.msg.mine strong { color: var(--noxium); }
.msg.whisper { background: var(--panel2); border-left: 3px solid #8a63c7; padding: .15rem .5rem; border-radius: 4px; }
.msg.whisper em { color: #8a63c7; }
.chatform { flex-wrap: nowrap; gap: .5rem; }
.chatform select { width: 12rem; flex: 0 0 auto; }
.chatform select.whisper { border-color: #8a63c7; }
.chatform input { flex: 1 1 auto; width: auto; min-width: 0; }
.sheetcols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; }
.sheetview h4 { margin: 1rem 0 .3rem; }
.sheetview table { width: 100%; }
@media (max-width: 760px) { .sheetcols { grid-template-columns: 1fr; } .chatform { flex-wrap: wrap; } .chatform select { width: 100%; } }
/* lobby desktop layout: main area left, chat column fixed at the right edge of the window, dice bar bottom centre of the main area */
:root { --chatw: 380px; --hdr: 3.7rem; }
body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(--chatw) + 1.5rem); }
.lobbypage { max-width: none; margin: 0; padding: 0; }
.lobbychat { position: fixed; right: 0; top: var(--hdr); bottom: 0; width: var(--chatw); z-index: 10; border-radius: 0; border-width: 0 0 0 1px; padding: .8rem; display: flex; flex-direction: column; overflow: hidden; }
.lobbychat .chatwrap { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr) auto auto auto auto; }
.lobbychat .chatbox { height: auto; min-height: 0; }
.lobbychat .chatform { flex-wrap: wrap; }
.lobbychat .chatform select { width: 100%; flex-basis: 100%; }
.lobbychat .chatform input { flex: 1 1 8rem; }
.partygrid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.msg.rollmsg .rollres b { font-size: 1.05rem; color: var(--noxium); }
.whispertag { color: #8a63c7; }
.dicecount { display: flex; align-items: center; gap: .3rem; margin-right: .3rem; }
.dicecount select { width: auto; padding: .25rem .4rem; }
.diebtn { display: flex; flex-direction: column; align-items: center; gap: 0; padding: .2rem .5rem; border-radius: 12px; min-width: 3.1rem; font-size: .75rem; line-height: 1.1; }
.diebtn:hover:not(:disabled) { border-color: var(--noxium); color: var(--noxium); }
.dicedest { margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
.dicedest.priv { color: #8a63c7; font-weight: 600; }
.trayroll { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: .5rem .9rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .25); animation: trayin .25s ease-out; }
.trayrow { display: flex; align-items: center; gap: .5rem; margin-top: .3rem; flex-wrap: wrap; justify-content: center; max-width: 30rem; }
.traytotal { font-weight: 700; font-size: 1.2rem; margin-left: .3rem; }
.die { display: inline-flex; align-items: center; justify-content: center; width: 2.9rem; height: 2.9rem; background: var(--dc, var(--noxium)); color: #12150f; font-weight: 800; font-variant-numeric: tabular-nums; }
.die.d4 { clip-path: polygon(50% 4%, 96% 92%, 4% 92%); padding-top: .55rem; }
.die.d6 { border-radius: 12px; }
.die.d8 { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
.die.d10 { clip-path: polygon(50% 0, 98% 40%, 50% 100%, 2% 40%); }
.die.d12 { clip-path: polygon(50% 0, 98% 38%, 80% 96%, 20% 96%, 2% 38%); }
.die.d20 { clip-path: polygon(25% 4%, 75% 4%, 100% 50%, 75% 96%, 25% 96%, 0 50%); }
.die.d100 { border-radius: 50%; }
.die.rolling { animation: tumble .32s linear infinite; filter: brightness(.9); }
.die.landed { animation: land .25s ease-out; }
@keyframes tumble { 0% { transform: rotate(0) translateY(0); } 25% { transform: rotate(90deg) translateY(-8px); } 50% { transform: rotate(180deg) translateY(0); } 75% { transform: rotate(270deg) translateY(-5px); } 100% { transform: rotate(360deg) translateY(0); } }
@keyframes land { 0% { transform: scale(1.35); } 100% { transform: scale(1); } }
@keyframes trayin { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .die.rolling, .die.landed, .trayroll { animation: none; } }
.lobbycard { align-content: start; }
.lobbycard .chips { align-items: flex-start; align-content: flex-start; }
.lobbycard .chip { align-self: flex-start; }
/* ---- A4 character sheet (fixed colours on purpose: it is printed on white paper) ---- */
.papertools { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: 0 0 .5rem; }
.papertools .toolright { margin-left: auto; display: flex; gap: .5rem; align-items: center; }
.papertools button { padding: .3rem .8rem; }
.seg { display: inline-flex; }
.seg button { border-radius: 0; padding: .4rem .9rem; }
.seg button:first-child { border-radius: var(--radius) 0 0 var(--radius); }
.seg button:last-child { border-radius: 0 var(--radius) var(--radius) 0; margin-left: -1px; }
.seg button.on { background: var(--noxium); color: #12150f; border-color: var(--noxium); }
.paperfit { width: 100%; }
.papers { display: grid; gap: 14px; justify-items: start; width: 210mm;
--pi: #1a1f14; --pg: #2f6a0d; --pbar: #2f6a0d; --pm: #6a7260; --pl: #cfd5c3; --pd: #c3cab6; --pt: #eef3e6; --pb: #fff; --p2: #4d5645; --p3: #3b4433; --p4: #5c6553; --pn: #b9c1ab; }
.papers.dark { --pi: #e4e9d8; --pg: #8fd44c; --pbar: #3c7a16; --pm: #98a28a; --pl: #343b2d; --pd: #353c2e; --pt: #252b1f; --pb: #161a12; --p2: #b4bda6; --p3: #c5cdb7; --p4: #a1aa93; --pn: #454d3a; }
.papers.dark .paper { box-shadow: 0 2px 12px rgba(0, 0, 0, .5); }
.paper { box-sizing: border-box; width: 210mm; min-height: 297mm; padding: 11mm 12mm 8mm; background: var(--pb); color: var(--pi); border: 1px solid var(--pl); box-shadow: 0 2px 12px rgba(0, 0, 0, .14);
display: flex; flex-direction: column; gap: 3.5mm; font-size: 8.6pt; line-height: 1.25; break-after: page; }
.paper * { color: inherit; }
.paper .tipped { border-bottom: 0; text-decoration: none; cursor: default; }
.paper p { margin: 0; }
.phead { display: flex; justify-content: space-between; align-items: flex-end; gap: 8mm; border-bottom: 2px solid var(--pg); padding-bottom: 2mm; }
.pname { font-size: 24pt; font-weight: 800; line-height: 1.05; letter-spacing: .01em; overflow-wrap: anywhere; }
.phead.small .pname { font-size: 15pt; }
.pbrand { text-align: right; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; font-size: 8.5pt; color: var(--pg) !important; white-space: nowrap; }
.pbrand span { display: block; font-weight: 500; letter-spacing: .08em; font-size: 7pt; color: var(--pm) !important; }
.pfields { display: grid; grid-template-columns: 1fr 1.6fr 1.3fr 1fr .8fr; gap: 3mm; }
.pfield { border-bottom: 1px solid var(--pi); padding-bottom: .6mm; min-width: 0; }
.pfield span { display: block; font-size: 6.2pt; text-transform: uppercase; letter-spacing: .1em; color: var(--pm) !important; }
.pfield b { font-weight: 600; font-size: 9pt; overflow-wrap: anywhere; }
.pattrs { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2.5mm; }
.pattr { border: 1.4px solid var(--pi); border-radius: 3mm; text-align: center; padding: 1.6mm 1mm 1.4mm; display: grid; gap: .2mm; }
.pattr-n { font-size: 6.6pt; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
.pattr-v { font-size: 20pt; line-height: 1; }
.pattr-m { font-size: 7pt; color: var(--p2) !important; border-top: 1px solid var(--pl); padding-top: .8mm; }
.pderived { display: grid; grid-template-columns: repeat(auto-fit, minmax(28mm, 1fr)); gap: 2.5mm; }
.pstat { background: var(--pt); border-radius: 2mm; padding: 1.4mm 2.4mm; display: flex; justify-content: space-between; align-items: baseline; font-weight: 600; font-size: 8pt; }
.pstat b { font-size: 13pt; }
.psec h3 { margin: 0 0 1.6mm; font-size: 9pt; text-transform: uppercase; letter-spacing: .12em; color: #fff !important; background: var(--pbar); padding: .8mm 2.4mm; border-radius: 1.4mm; display: flex; justify-content: space-between; align-items: center; gap: 3mm; }
.psec h3 * { color: #fff !important; }
.psec h3 small { font-size: 6.6pt; font-weight: 500; letter-spacing: .04em; text-transform: none; }
.pskills { column-count: 2; column-gap: 6mm; }
.pgroup { break-inside: avoid; margin-bottom: 3mm; }
.pgroup h4 { margin: 0 0 .6mm; font-size: 7.4pt; text-transform: uppercase; letter-spacing: .1em; color: var(--pg) !important; border-bottom: 1px solid var(--pg); }
.prow { display: grid; grid-template-columns: 6.5mm 1fr 11mm 6mm 8mm; align-items: center; gap: 1mm; padding: .35mm 0; border-bottom: 1px dotted var(--pd); }
.prow .pn { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.prow .pn em { font-size: 6pt; color: var(--pm) !important; font-style: normal; text-transform: uppercase; letter-spacing: .05em; margin-left: .8mm; }
.prow .pa { font-size: 6.4pt; color: var(--pm) !important; text-align: center; }
.prow .pp { font-size: 7pt; color: var(--p2) !important; text-align: right; }
.prow .pt { text-align: center; border: 1px solid var(--pi); border-radius: 1.4mm; font-size: 8.6pt; padding: .1mm 0; background: var(--pb); }
.prow.locked { opacity: .45; }
.prow.locked .pn { text-decoration: line-through; }
.ppips { display: inline-flex; gap: .7mm; }
.ppips i { width: 2.1mm; height: 2.1mm; border: 1px solid var(--pi); border-radius: 50%; background: var(--pb); display: inline-block; }
.ppips i.on { background: var(--pg); border-color: var(--pg); }
.pcols { column-count: 2; column-gap: 6mm; }
.pitem { break-inside: avoid; margin-bottom: 2.2mm; }
.pitem b { font-size: 9pt; }
.pitem p { font-size: 7.8pt; color: var(--p3) !important; }
.ptag { font-size: 6.6pt; color: var(--pm) !important; text-transform: uppercase; letter-spacing: .05em; }
.pnone { color: var(--pm) !important; }
.pgear { width: 100%; border-collapse: collapse; font-size: 8pt; }
.pgear th { text-align: left; font-size: 6.4pt; text-transform: uppercase; letter-spacing: .08em; color: var(--pm) !important; border-bottom: 1px solid var(--pi); padding: .6mm 1mm; }
.pgear td { padding: .45mm 1mm; border-bottom: 1px dotted var(--pd); vertical-align: top; }
.pgear td:nth-child(n+4), .pgear th:nth-child(n+4) { text-align: right; white-space: nowrap; }
.pnote { font-size: 6.6pt; color: var(--p4) !important; }
.pother { margin-top: 1.6mm; font-size: 8pt; }
.pbg { font-size: 8pt; white-space: pre-wrap; }
.pnotes { flex: 1; display: flex; flex-direction: column; min-height: 18mm; }
.plines { flex: 1; background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 6.4mm, var(--pn) 6.4mm, var(--pn) calc(6.4mm + 1px)); border-radius: 1mm; }
.pfoot { margin-top: auto; display: flex; justify-content: space-between; font-size: 6.4pt; color: var(--pm) !important; text-transform: uppercase; letter-spacing: .08em; border-top: 1px solid var(--pl); padding-top: 1.2mm; }
@media print { .paper { box-shadow: none; } }
/* lobby header: one slim line instead of three */
.lobbyhead { display: flex; align-items: baseline; gap: .8rem; flex-wrap: wrap; margin: 0 0 .3rem; }
.lobbyhead h1 { font-size: 1.3rem; margin: 0; }
.lobbypage .tabs { margin: .4rem 0 .7rem; }
.lobbypage .tabs button { padding: .3rem .8rem; }
.tabs { align-items: center; }
.tabs .tabright { margin-left: auto; align-self: center; margin-bottom: .3rem; }
.tabs .tabright button { padding: .25rem .75rem; margin-bottom: 0; border-radius: 0; border-bottom: 1px solid var(--line); }
.tabs .tabright button.on { border-color: var(--noxium); }
.tabs .tabright button:first-child { border-radius: var(--radius) 0 0 var(--radius); }
.tabs .tabright button:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
/* lobby: three columns - sheet | player area | chat (fixed at the right edge) */
body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
.lobbyhead { margin: 0 0 .4rem; } .lobbyhead .tabright { margin-left: auto; }
.lobbygrid { display: grid; grid-template-columns: clamp(480px, 42vw, 800px) minmax(0, 1fr); gap: .8rem; align-items: start; }
.lobbygrid.nosheet { grid-template-columns: minmax(0, 1fr); }
.sheetcol { position: sticky; top: calc(var(--hdr) + .4rem); max-height: calc(100vh - var(--hdr) - 3.2rem); overflow-y: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); padding: .5rem; }
.sheetbar { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; } .sheetbar .tabright { margin-left: auto; display: flex; gap: .4rem; align-items: center; }
.sheetbar .seg button { padding: .2rem .6rem; }
.playarea { display: flex; flex-direction: column; gap: .6rem; min-width: 0; height: calc(100vh - var(--hdr) - 3.2rem); position: sticky; top: calc(var(--hdr) + .4rem); }
.showsheet { align-self: flex-start; }
.charbar { display: flex; gap: .5rem; flex-wrap: wrap; }
.pcard { display: flex; align-items: center; gap: .55rem; padding: .35rem .6rem; border: 2px solid var(--line); border-left: 6px solid var(--pc); border-radius: var(--radius); background: var(--panel); min-width: 11rem; }
.pcard.can { cursor: pointer; } .pcard.can:hover { background: var(--panel2); }
.pcard.sel { border-color: var(--pc); background: var(--panel2); box-shadow: 0 0 0 1px var(--pc); }
.pavatar { width: 2.2rem; height: 2.2rem; border-radius: 50%; background: var(--pc); color: #12150f; font-weight: 800; display: grid; place-items: center; flex: none; }
.pinfo { display: grid; line-height: 1.2; min-width: 0; } .pinfo b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.modin { width: 4rem; padding: .2rem .3rem; margin-left: auto; text-align: center; }
.modchip { margin-left: auto; padding: .05rem .5rem; border-radius: 999px; font-weight: 700; border: 1px solid var(--line); } .modchip.up { color: var(--noxium); } .modchip.down { color: var(--danger); }
.diffbar { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; padding: .3rem .6rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
.diffbar label { display: flex; align-items: center; gap: .4rem; } .diffbar select { width: auto; padding: .25rem .4rem; }
.table { position: relative; flex: 1; min-height: 14rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto;
background: radial-gradient(ellipse at center, color-mix(in srgb, var(--noxium) 10%, var(--panel)) 0%, var(--panel) 75%); }
.tablehint { position: absolute; top: .6rem; left: 0; right: 0; text-align: center; margin: 0; }
.tableerr { position: absolute; bottom: .5rem; left: 0; right: 0; text-align: center; }
.dicetray { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 3; display: grid; gap: .6rem; justify-items: center; pointer-events: none; max-width: 95%; }
.trayroll { border-top: 3px solid var(--dc, var(--noxium)); }
.trayout { display: grid; line-height: 1.2; margin-left: .5rem; } .trayout .out { font-weight: 800; }
.die.mystery { filter: grayscale(.4); }
.dicebar { display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; justify-content: center; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .4rem .8rem; align-self: center; max-width: 100%; }
.ownswitch { display: flex; align-items: center; gap: .3rem; margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
.dicebar .seg button { padding: .2rem .6rem; }
.manual { position: absolute; left: 50%; top: 30%; transform: translateX(-50%); z-index: 4; display: grid; gap: .4rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .35); }
.settings { position: absolute; inset: .6rem; z-index: 6; overflow: auto; align-content: start; display: grid; gap: .5rem; max-width: 34rem; margin: 0 auto; height: fit-content; max-height: calc(100% - 1.2rem); }
.swatches { display: flex; gap: .4rem; flex-wrap: wrap; } .swatchbtn { width: 2rem; height: 2rem; border-radius: 50%; padding: 0; border: 2px solid var(--line); } .swatchbtn.on { border-color: var(--text); box-shadow: 0 0 0 2px var(--panel), 0 0 0 4px var(--text); } .swatchbtn:disabled { opacity: .25; cursor: not-allowed; }
.swatch { display: inline-block; width: .8rem; height: .8rem; border-radius: 50%; vertical-align: middle; }
.out.crit_success { color: var(--noxium); font-weight: 800; } .out.success { color: var(--noxium); } .out.failure { color: var(--danger); } .out.crit_failure { color: var(--danger); font-weight: 800; }
.manualtag { color: var(--diesium); } .rollres.mystery { letter-spacing: .15em; font-weight: 700; color: var(--muted); }
.papers .rollable { cursor: pointer; } .papers .rollable:hover { outline: 1.5px solid var(--pg); outline-offset: 1px; background: color-mix(in srgb, var(--pg) 12%, transparent); }
@media (max-width: 1100px) {
body:has(.lobbychat) main { padding: .5rem; }
.lobbychat { position: static; width: auto; height: 28rem; border-width: 1px; border-radius: var(--radius); margin-top: 1rem; }
.lobbygrid { grid-template-columns: minmax(0, 1fr); } .playarea { order: -1; height: 32rem; position: static; } .sheetcol { position: static; max-height: none; }
}
/* the table: dice fly in from a random side and rest at a random spot */
.table { overflow: hidden; }
.dicetray { inset: 0; top: 0; left: 0; transform: none; display: block; max-width: none; }
.thrown { position: absolute; width: 2.9rem; height: 2.9rem; margin: -1.45rem 0 0 -1.45rem; transform: rotate(var(--er)); animation: throwin .8s cubic-bezier(.15, .7, .3, 1) both; filter: drop-shadow(0 4px 4px rgba(0, 0, 0, .35)); }
@keyframes throwin { from { transform: translate(var(--sx), var(--sy)) rotate(var(--sr)) scale(1.3); opacity: .2; } to { transform: rotate(var(--er)); opacity: 1; } }
.tablelog { position: absolute; left: .6rem; bottom: .5rem; display: grid; gap: .25rem; max-width: 70%; }
.tablelog .trayroll { padding: .2rem .6rem; border-radius: 8px; font-size: .85rem; background: color-mix(in srgb, var(--panel) 88%, transparent); border: 1px solid var(--line); border-top: 3px solid var(--line); box-shadow: none; }
@media (prefers-reduced-motion: reduce) { .thrown { animation: none; } }
.stepper .step5 { font-size: .75rem; padding: .25rem .4rem; min-width: 2.2rem; }
/* rules editor */
.editor .edform { margin: .8rem 0; display: grid; gap: .6rem; }
.edgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: .6rem .9rem; }
.edfield { display: grid; gap: .2rem; }
.edfield .multi { display: flex; flex-wrap: wrap; gap: .3rem .9rem; padding: .3rem 0; max-height: 11rem; overflow: auto; }
.edtable td.actions { text-align: right; white-space: nowrap; }
.edtable tr.sel { background: var(--panel2); }
.editor .tabs { margin-top: .6rem; }
.settings.inline { position: static; max-width: none; margin: 0; border: 0; background: transparent; padding: 0; }
/* ---- maps */
.table { background: color-mix(in srgb, var(--panel) 70%, #000); }
.mapbox { position: absolute; inset: 0; overflow: hidden; touch-action: none; cursor: grab; background: #111; z-index: 1; }
.mapbox:active { cursor: grabbing; }
.mapworld { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
.mapworld img { display: block; max-width: none; user-select: none; -webkit-user-drag: none; }
.mapgrid { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(to right, rgba(255,255,255,.28) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,.28) 1px, transparent 1px); }
.maptoken { position: absolute; border-radius: 50%; background: var(--tc); border: 3px solid #fff; box-shadow: 0 0 0 2px rgba(0,0,0,.6), 0 2px 6px rgba(0,0,0,.6); display: grid; place-items: center; color: #fff; font-weight: 700; text-transform: uppercase; user-select: none; touch-action: none; cursor: default; }
.maptoken.mov { cursor: move; }
.maptoken.player { border-color: var(--tc); background: color-mix(in srgb, var(--tc) 55%, #000); box-shadow: 0 0 0 2px #fff, 0 2px 6px rgba(0,0,0,.6); }
.maptoken.hid { opacity: .55; border-style: dashed; }
.maptoken.sel { outline: 3px solid var(--accent, gold); outline-offset: 3px; }
.maptoken span { pointer-events: none; text-shadow: 0 1px 2px #000; }
.maptoken em { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); margin-top: 2px; font-style: normal; font-weight: 600; text-transform: none; white-space: nowrap; color: #fff; text-shadow: 0 0 3px #000, 0 0 3px #000; pointer-events: none; }
.maptray { position: absolute; top: .5rem; left: .5rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; padding: .3rem .5rem; border-radius: 8px; background: color-mix(in srgb, var(--panel) 90%, transparent); border: 1px solid var(--line); z-index: 4; max-width: 70%; }
.traytoken { border-radius: 999px; border: 2px solid var(--tc); background: color-mix(in srgb, var(--tc) 40%, var(--panel)); color: var(--text); padding: .15rem .7rem; cursor: grab; touch-action: none; }
.mapzoom { position: absolute; right: .5rem; bottom: .5rem; display: grid; gap: .25rem; z-index: 4; }
.mapzoom button, .maptools button { background: color-mix(in srgb, var(--panel) 90%, transparent); }
.maptools { position: absolute; top: .5rem; right: .5rem; display: flex; gap: .3rem; z-index: 4; }
.tokenpanel { position: absolute; right: 3rem; bottom: .5rem; z-index: 5; width: 17rem; max-width: 60%; padding: .6rem; border-radius: 10px; background: var(--panel); border: 1px solid var(--line); cursor: default; }
.tokenform { display: grid; gap: .45rem; }
.tokenform input[type=number] { width: 4.5rem; }
.swatches { display: flex; gap: .3rem; flex-wrap: wrap; }
.swatch { width: 1.4rem; height: 1.4rem; padding: 0; border-radius: 50%; border: 2px solid transparent; }
.swatch.on { border-color: var(--text); }
.maplib { display: grid; gap: .5rem; margin-bottom: 1rem; }
.uploadbtn { display: inline-block; padding: .4rem .8rem; border: 1px dashed var(--line); border-radius: 8px; cursor: pointer; text-align: center; }
.maplist { list-style: none; padding: 0; margin: 0; display: grid; gap: .4rem; }
.maplist li { padding: .5rem; border: 1px solid var(--line); border-radius: 8px; display: grid; gap: .3rem; }
.maplist li.active { border-color: var(--accent, gold); }
.mapeditor { position: fixed; inset: 0; z-index: 100; display: grid; grid-template-columns: 20rem 1fr; background: var(--bg); }
.mapeditor aside { overflow-y: auto; border-radius: 0; display: grid; gap: .6rem; align-content: start; }
.mapstage { position: relative; }
.tokenlist { list-style: none; padding: 0; margin: 0; display: grid; gap: .25rem; }
.tokenlist button { width: 100%; text-align: left; }
.tokenlist i { display: inline-block; width: .8rem; height: .8rem; border-radius: 50%; vertical-align: middle; }
@media (max-width: 800px) { .mapeditor { grid-template-columns: 1fr; grid-template-rows: 45% 1fr; } }
.mapeditor, .mapeditor * { box-sizing: border-box; }
.mapeditor { z-index: 1000; }
.mapeditor .mapstage { min-width: 0; }
label.chk { display: flex; align-items: center; gap: .45rem; }
label.chk input[type=checkbox] { width: auto; margin: 0; }
.mapeditor label.small:not(.chk) { display: grid; gap: .2rem; }
.editactions { position: sticky; bottom: 0; background: var(--panel); padding: .5rem 0; }
.mapbox.drawing { cursor: crosshair; }
.mapworld { background: #000; }
.wl { stroke: #ff4d4d; stroke-width: 3; stroke-linecap: round; }
.wl.door { stroke: #ff9f1a; stroke-width: 5; }
.wl.door.open { stroke: #4cd964; stroke-dasharray: 6 6; stroke-width: 4; }
.wl.draft { stroke: #fff; stroke-dasharray: 5 5; stroke-width: 2; }
.wldot { fill: #fff; }
.toolrow { display: flex; flex-wrap: wrap; gap: .3rem; }
.mapeditor input[type=number] { width: 100%; }
.mapempty { position: absolute; inset: 0; display: grid; place-items: center; color: #ddd; z-index: 2; pointer-events: none; text-align: center; padding: 1rem; }
.mapcards { list-style: none; padding: 0; margin: 1rem 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr)); gap: 1rem; }
.mapcard { display: grid; grid-template-columns: 8rem 1fr; gap: .8rem; padding: .8rem; align-items: start; }
.mapcard img { width: 8rem; height: 6rem; object-fit: cover; border-radius: 6px; background: #111; }
.mapcardbody { display: grid; gap: .4rem; min-width: 0; }
.mapcardbody .row { flex-wrap: wrap; }
.maptools { flex-wrap: wrap; justify-content: flex-end; max-width: 60%; }
.maptools button, .maptools select { padding: .2rem .55rem; font-size: .8rem; min-height: 0; white-space: nowrap; width: auto; }
.maptray { max-width: 38%; }
.traytoken { font-size: .85rem; }
.soundctl { display: inline-flex; align-items: center; gap: .3rem; }
.soundctl input[type=range] { width: 5rem; padding: 0; }
.soundsettings { display: grid; gap: .5rem; margin-bottom: 1rem; }
.soundrow { display: grid; gap: .25rem; padding: .4rem; border: 1px solid var(--line); border-radius: 8px; }
.soundrow .row { justify-content: space-between; align-items: center; flex-wrap: wrap; }
.uploadbtn.small { padding: .15rem .6rem; font-size: .85rem; }
/* ---- portraits */
.portraitbox { display: block; }
.portraitcur { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.portraitcur img, .portraitnone { width: 6.5rem; height: 6.5rem; border-radius: 10px; object-fit: cover; border: 1px solid var(--line); background: var(--bg); display: grid; place-items: center; }
.portraitbtns { display: grid; gap: .4rem; justify-items: start; }
.portraitcrop { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
.cropwin { position: relative; overflow: hidden; border-radius: 10px; border: 2px solid var(--noxium, #7aa63c); background: #000; cursor: move; touch-action: none; flex: none; }
.cropwin img { position: absolute; max-width: none; user-select: none; }
.cropctl { display: grid; gap: .5rem; }
.pavatar { overflow: hidden; border: 2px solid var(--pc); }
.pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; }
.adminusers { margin: .8rem 0; }
.userlist { list-style: none; padding: 0; margin: .5rem 0 0; display: grid; gap: .4rem; }
.userlist li { justify-content: space-between; align-items: center; }
.chip.on { display: inline-block; }
.npchead { display: flex; align-items: center; gap: .7rem; }
.npcthumb { width: 44px; height: 44px; border-radius: 8px; object-fit: cover; border: 1px solid var(--line, #444); flex: none; }
.npcthumb.none { display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,.05); color: var(--muted, #888); }
.npchead .badge { margin-left: .5rem; }
.npcpicker { display: grid; gap: .5rem; max-width: 320px; }
.npcpick { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow: auto; display: grid; gap: .25rem; }
.npcpick button { display: flex; align-items: center; gap: .6rem; width: 100%; text-align: left; }
.npcpickwrap { position: absolute; top: 3rem; left: .5rem; z-index: 5; }
.pnpc .pre { white-space: pre-wrap; margin: .1rem 0 .6rem; font-size: .85rem; }
.pavatar { position: relative; }
.presence { position: absolute; right: -3px; bottom: -3px; width: 11px; height: 11px; border-radius: 50%; background: #777; border: 2px solid var(--panel, #222); }
.presence.on { background: #3fb950; }
.presence.inline { position: static; display: inline-block; width: 9px; height: 9px; border: 0; margin-right: .2rem; }
.pcard.off { opacity: .6; }
/* lobby header removed: DM chip and settings button live in the player bar */
.playarea { height: calc(100vh - var(--hdr) - 1.2rem); }
.sheetcol { max-height: calc(100vh - var(--hdr) - 1.2rem); }
.dmcard { min-width: 0; border-left-color: var(--accent, gold); }
.pavatar.dm { font-size: .7rem; font-weight: 700; }
.lobbycfg { margin-left: auto; align-self: center; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fightrow { display: flex; justify-content: flex-end; margin-top: -.3rem; }
.initbar { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; padding: .35rem .5rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
.initround { padding-right: .4rem; border-right: 1px solid var(--line); }
.initcard { display: flex; align-items: center; gap: .35rem; padding: .2rem .5rem; border: 2px solid var(--line); border-radius: var(--radius); cursor: pointer; background: var(--panel2); }
.initcard.player { border-left: 5px solid #4f93e6; } .initcard.npc { border-left: 5px solid #c0392b; }
.initcard.cur { border-color: var(--accent, gold); box-shadow: 0 0 0 1px var(--accent, gold); background: color-mix(in srgb, var(--accent, gold) 18%, var(--panel2)); }
.initnum { font-weight: 700; min-width: 1.6rem; text-align: center; }
.initcard .modin { width: 3.2rem; }
.initctl { margin-left: auto; display: flex; gap: .3rem; }
.maptoken.turn { animation: turnpulse 1.4s ease-in-out infinite; }
@keyframes turnpulse { 0%, 100% { box-shadow: 0 0 0 3px var(--accent, gold), 0 0 6px 3px rgba(255,215,0,.5); } 50% { box-shadow: 0 0 0 3px var(--accent, gold), 0 0 16px 8px rgba(255,215,0,.8); } }
.radialback { position: fixed; inset: 0; z-index: 60; }
.radial { position: fixed; width: 0; height: 0; }
.radialtitle { position: absolute; transform: translate(-50%, -50%); background: rgba(0,0,0,.75); color: #fff; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; white-space: nowrap; max-width: 9rem; overflow: hidden; text-overflow: ellipsis; }
.radialbtn { position: absolute; left: -26px; top: -26px; width: 52px; height: 52px; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0; gap: 0; line-height: 1; box-shadow: 0 2px 8px rgba(0,0,0,.6); background: var(--panel); border: 2px solid var(--line); }
.radialbtn:hover:not(:disabled) { border-color: var(--accent, gold); background: var(--panel2); }
.radialbtn .ri { font-size: 1.2rem; } .radialbtn .rl { font-size: .55rem; margin-top: 2px; }
.initcard .modin { width: 2.8rem; padding: .1rem .25rem; min-height: 0; }
.initcard { padding: .1rem .4rem; }
/* compact top area of the lobby */
.topbar { display: flex; align-items: stretch; gap: .4rem; }
.topctl { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; align-content: center; }
.topctl button.mini { padding: 0; min-width: 28px; height: 26px; font-size: .8rem; line-height: 1; }
.topctl button.mini.wide { grid-column: span 1; }
.topscroll { flex: 1; min-width: 0; overflow-x: auto; overflow-y: hidden; padding-bottom: 4px; scrollbar-width: thin; }
.topscroll .charbar, .topscroll .initcards { flex-wrap: nowrap; width: max-content; }
.topbar .lobbycfg { margin-left: 0; align-self: center; flex: none; max-width: 11rem; }
.topmore { display: grid; gap: .5rem; padding: .4rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
.topmore .charbar { flex-wrap: wrap; }
.topbar + .topmore, .topbar + .diffbar { margin-top: -.2rem; }
.initcards { display: flex; gap: .35rem; align-items: center; }
.initcard { padding: .15rem .45rem .15rem .15rem; border-radius: 999px; border-left-width: 2px; flex: none; }
.initcard.player { border-left: 2px solid #4f93e6; border-color: #4f93e6; } .initcard.npc { border-left: 2px solid #c0392b; border-color: #c0392b; }
.initcard.cur { border-color: var(--accent, gold); }
.initpic { width: 30px; height: 30px; border-radius: 50%; overflow: hidden; display: grid; place-items: center; background: var(--panel); font-weight: 700; flex: none; }
.initpic img { width: 100%; height: 100%; object-fit: cover; }
.initname { white-space: nowrap; }
.initround { padding: 0 .4rem; flex: none; }
.pcard { flex: none; }
.playarea { gap: .4rem; }
.maptoken em { display: none; }
.maptoken:hover em, .maptoken.sel em, .maptoken.turn em { display: block; }
/* resizing */
.splitter { width: 10px; cursor: col-resize; align-self: stretch; position: relative; touch-action: none; }
.splitter::after { content: ''; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; border-radius: 2px; background: var(--line); transition: background .15s; }
.splitter:hover::after, body.resizing .splitter::after { background: var(--accent, gold); }
body.resizing { cursor: col-resize; user-select: none; }
.lobbygrid:not(.nosheet) { grid-template-columns: var(--sheetw) 10px minmax(0, 1fr); gap: .3rem; }
.chatsplit { position: absolute; left: -5px; top: 0; bottom: 0; z-index: 12; }
.chatsplit::after { left: 4px; }
@media (max-width: 1100px) {
.lobbygrid, .lobbygrid:not(.nosheet) { grid-template-columns: minmax(0, 1fr); }
.sheetsplit, .chatsplit { display: none; }
}
/* portrait next to name and identity fields */
.pidentity { display: flex; gap: 5mm; align-items: stretch; }
.pidright { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3mm; justify-content: flex-end; }
.pportrait { width: 36mm; height: 42mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; align-self: flex-start; }
.pidright .pfields { grid-template-columns: 1fr 1fr 1fr; }
.pidright .pfield:nth-child(2) { grid-column: span 2; }
.pwrite { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; line-height: calc(6.4mm + 1px); padding: 0; color: var(--pi); border: 0; resize: none; width: 100%; box-sizing: border-box; background-attachment: local; min-height: 18mm; }
textarea.pwrite { background-color: transparent; outline: none; field-sizing: content; }
textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
.pwrite::placeholder { color: var(--pm); opacity: .6; }
/* token status, fog */
.mapfog { position: absolute; left: 0; top: 0; filter: brightness(.42) saturate(.55); pointer-events: none; max-width: none; }
.tokhp { position: absolute; left: 8%; right: 8%; bottom: -7px; height: 6px; background: rgba(0,0,0,.75); border-radius: 3px; border: 1px solid rgba(255,255,255,.7); overflow: hidden; pointer-events: none; font-style: normal; }
.tokhp b { display: block; height: 100%; }
.tokconds { position: absolute; left: 50%; top: -9px; transform: translateX(-50%); display: flex; gap: 2px; pointer-events: none; }
.tokconds u { text-decoration: none; font-size: 9px; font-weight: 700; text-transform: none; background: #6a1b9a; border: 1px solid #fff; border-radius: 4px; padding: 0 3px; line-height: 12px; white-space: nowrap; color: #fff; }
.statusform { display: grid; gap: .4rem; margin-top: .5rem; padding-top: .5rem; border-top: 1px solid var(--line); }
.hprow { gap: .3rem; flex-wrap: wrap; align-items: center; }
.hprow input { width: 3.6rem; }
.condrow { display: flex; flex-wrap: wrap; gap: .25rem; }
.sightrange input { width: 3.4rem; }
.tokenpanel { max-height: calc(100% - 4rem); overflow-y: auto; }
/* reveal areas, sight groups */
.rz { fill: rgba(255, 215, 0, .14); stroke: rgba(255, 215, 0, .9); stroke-width: 2; stroke-dasharray: 6 4; }
.rz.draft { fill: rgba(255, 215, 0, .25); }
.rzo { fill: gold; stroke: #000; stroke-width: 1; }
.revealpanel { right: auto; left: .5rem; bottom: .5rem; top: auto; max-width: 18rem; display: grid; gap: .4rem; }
.visiongroups { display: grid; gap: .4rem; }
.vgrow { justify-content: space-between; }
/* radial menu: always a dark disc with light text, readable on both themes */
.radialbtn { background: #1b2116 !important; border: 2px solid #8fd44c !important; color: #fff !important; }
.radialbtn:hover:not(:disabled) { background: #2b3a1c !important; border-color: gold !important; }
.radialbtn:disabled { opacity: .55; }
.radialbtn .ri { font-size: 1.35rem; color: #fff; }
.radialbtn .rl { font-size: .62rem; font-weight: 700; color: #fff; text-shadow: 0 1px 1px #000; }
.radialbtn { width: 58px; height: 58px; left: -29px; top: -29px; }
.radialtitle { background: #000; color: #fff; font-weight: 700; font-size: .8rem; border: 1px solid #8fd44c; }
.radialbtn:disabled { opacity: .78; background: #262b22 !important; border-color: #5a634f !important; cursor: not-allowed; }
/* decor tokens (props) */
.maptoken.prop { border: 0; border-radius: 0; background: none; box-shadow: none; display: block; transform-origin: center; }
.maptoken.prop:not(.mov) { pointer-events: none; }
.maptoken.prop.hid { opacity: .5; outline: 2px dashed rgba(255,255,255,.5); border-style: none; }
.maptoken.prop.sel { outline: 3px solid var(--accent, gold); outline-offset: 2px; }
.maptoken.prop em { display: none !important; }
.proppicker { max-width: 360px; }
.propgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: .3rem; max-height: 300px; overflow: auto; }
.propbtn { display: grid; justify-items: center; gap: .15rem; padding: .3rem; font-size: .72rem; position: relative; }
.propbtn svg, .propbtn img { width: 46px; height: 46px; object-fit: contain; }
.propbtn.own { padding: 0; }
.propbtn.own > .ghost:first-child { display: grid; justify-items: center; gap: .15rem; width: 100%; }
.propbtn.own .mini { position: absolute; top: 0; right: 0; padding: 0 .3rem; min-height: 0; }
.tokrow { display: flex; gap: .25rem; }
.tokrow > button:first-child { flex: 1; min-width: 0; }
.propdot { display: inline-block; width: .8rem; height: .8rem; border-radius: 2px; background: #b98545; vertical-align: middle; }
.tokenform input[type=range] { width: 100%; }
.tokenform .row { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-start; align-items: center; }
.tokenform label.small { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; }
.tokenform label.small input[type=number] { width: 4rem; }
.tokenform .mini { min-height: 0; padding: .15rem .5rem; }
.tokenlist .tokrow > button:first-child { width: auto; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.tokenlist .tokrow > button.mini { width: auto; flex: none; }
/* table tool buttons: low and readable on the dark map */
.maptools button, .maptools select { padding: .1rem .5rem; font-size: .76rem; line-height: 1.2; height: 1.6rem; min-height: 0; }
.maptools button.ghost, .maptools select { background: rgba(20,22,26,.85); color: #f1f1f1; border: 1px solid rgba(255,255,255,.35); }
.maptools button.on { background: var(--accent, #2f7d32); color: #fff; border-color: transparent; }
.maptools button:disabled { opacity: .45; }
.maptools .sightrange input { height: 1.6rem; padding: 0 .3rem; }
.maptools .sightrange { color: #f1f1f1; }
.notefield { display: grid; gap: .2rem; }
.notefield textarea { width: 100%; resize: vertical; font: inherit; font-size: .85rem; }
.toknote { position: absolute; top: -.5em; right: -.5em; font-size: .75rem; line-height: 1; pointer-events: none; transform: none; filter: drop-shadow(0 1px 1px #000); }
.maptoken.prop .toknote { transform: none; }
.propmgr { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: .5rem; list-style: none; padding: 0; }
.propmgr li { display: grid; justify-items: center; gap: .3rem; padding: .5rem; border: 1px solid var(--line); border-radius: 8px; }
.propmgr svg, .propmgr img { width: 64px; height: 64px; object-fit: contain; }
.propmgr .row { display: flex; gap: .25rem; }
/* map pings */
.mapping { position: absolute; width: 0; height: 0; pointer-events: none; z-index: 6; }
.mapping i { position: absolute; left: calc(var(--ps) / -2); top: calc(var(--ps) / -2); width: var(--ps); height: var(--ps); border-radius: 50%; border: 4px solid var(--pc); box-shadow: 0 0 10px var(--pc), inset 0 0 10px var(--pc); opacity: 0; animation: pingring 1.6s ease-out 2; }
.mapping i + i { animation-delay: .35s; }
.mapping b { position: absolute; left: 0; top: calc(var(--ps) / 2 + 2px); transform: translateX(-50%); white-space: nowrap; color: #fff; text-shadow: 0 0 3px #000, 0 0 3px #000; animation: pingname 3.4s forwards; }
@keyframes pingring { 0% { transform: scale(.1); opacity: .95; } 100% { transform: scale(1.4); opacity: 0; } }
@keyframes pingname { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .mapping i { animation-duration: 3s; animation-iteration-count: 1; } }
/* table tools: measure, areas, markers */
.toollayer { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; }
.toolabel { fill: #fff; stroke: #000; stroke-width: 3px; paint-order: stroke; font-weight: 700; }
.mappin { position: absolute; transform: translate(-50%, -92%); padding: 0; margin: 0; min-height: 0; background: none; border: 0; line-height: 1; cursor: pointer; filter: drop-shadow(0 2px 2px rgba(0,0,0,.8)); z-index: 4; }
.mappin.shared { filter: drop-shadow(0 0 4px #fff) drop-shadow(0 2px 2px rgba(0,0,0,.8)); }
.mappin.sel { outline: 2px solid var(--accent, gold); border-radius: 6px; }
.mappin.draft { opacity: .6; pointer-events: none; }
.toolspanel { right: auto; left: .5rem; top: .5rem; bottom: auto; display: grid; gap: .4rem; max-width: 18rem; }
.toolspanel.tpllist { top: .5rem; left: .5rem; }
.toolspanel.tpllist.low { left: 15.8rem; }
.toolspanel textarea, .toolspanel input:not([type=range]):not([type=checkbox]) { width: 100%; font: inherit; }
.toolspanel .toolrow, .toolspanel .iconrow { display: flex; flex-wrap: wrap; gap: .25rem; }
.toolspanel .dot { display: inline-block; width: .7rem; height: .7rem; border-radius: 50%; }
.toolspanel .notetext { white-space: pre-wrap; margin: 0; }
.toolspanel { padding: .45rem .55rem; max-width: 15rem; gap: .3rem; font-size: .8rem; }
.toolspanel button { padding: .15rem .5rem; font-size: .78rem; min-height: 0; width: auto; }
.toolspanel p { margin: 0; }
.toolspanel input[type=range] { width: 100%; }
.toolspanel input:not([type=range]):not([type=checkbox]), .toolspanel textarea { padding: .25rem .4rem; font-size: .82rem; }