Compare commits

...
4 Commits
Author SHA1 Message Date
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
30 changed files with 1145 additions and 280 deletions
+5
View File
@@ -0,0 +1,5 @@
# 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
+2
View File
@@ -5,3 +5,5 @@ backend/__pycache__/
backend/app/__pycache__/
backend/aw.db
db/
.env
_to_delete/
+120
View File
@@ -0,0 +1,120 @@
# 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:8080 | head -1 # should print HTTP/1.1 200 OK
```
The container only listens on `127.0.0.1:8080`, so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`.
## 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 1m;
location / {
proxy_pass http://127.0.0.1:8080;
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;
}
}
```
```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. Port 8080 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`.
+38 -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:8080` (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,13 @@ 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_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,9 +73,9 @@ 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.
@@ -61,12 +83,17 @@ Each wizard page has a **Reset this page** button that clears only that page's d
## 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` | Own and shared-with-me characters / create one |
| GET | `/api/characters/{id}` | Read (owner or shared users) |
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner 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.
+45
View File
@@ -0,0 +1,45 @@
"""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 users WHERE id=?", (row["id"],))
print("User deleted. 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", "")
+10 -13
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,7 +37,7 @@ 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
skills.append({**s, "points": pts, "prof": prof, "pips": pips, "usable": usable, "unlocked": unlocked,
"total": best + pts + prof})
+4 -4
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,7 +92,7 @@ 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"]:
@@ -166,7 +166,7 @@ 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"]:
+301 -58
View File
@@ -7,10 +7,11 @@ import time
import uuid
from pathlib import Path
from fastapi import Body, FastAPI, Header, HTTPException, Response
from fastapi.responses import FileResponse
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import auth
from .export import to_docx, to_pdf
BASE = Path(__file__).resolve().parent.parent
@@ -19,8 +20,14 @@ DB_PATH = os.environ.get("AW_DB", str(BASE / "aw.db"))
STATIC = Path(os.environ.get("AW_STATIC", BASE / "static"))
MAX_BODY = 200_000 # bytes per character
MAX_PRESETS = 200
MAX_CHARACTERS = 300 # per user
MAX_SHARES = 30 # per character
# No public API docs: everything about the API stays behind the login
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
limiter = auth.Limiter()
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
app = FastAPI(title="Arcane Wasteland Character Creator")
def load_rules() -> dict:
@@ -38,19 +45,28 @@ def db() -> sqlite3.Connection:
con.execute("""CREATE TABLE IF NOT EXISTS presets (
id TEXT PRIMARY KEY, token TEXT NOT NULL, data TEXT NOT NULL,
created REAL NOT NULL, updated REAL NOT NULL)""")
auth.init_tables(con)
con.execute("""CREATE TABLE IF NOT EXISTS shares (
character_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (character_id, user_id))""")
for table in ("characters", "presets"): # older databases: add the owner column
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
return con
def custom_presets() -> list:
with db() as con:
rows = con.execute("SELECT id, data FROM presets ORDER BY created").fetchall()
return [{"id": r["id"], **json.loads(r["data"]), "custom": True} for r in rows]
def custom_presets(con, uid=None) -> list:
rows = con.execute("""SELECT p.id, p.data, p.owner_id, u.username FROM presets p
LEFT JOIN users u ON u.id = p.owner_id ORDER BY p.created""").fetchall()
return [{"id": r["id"], **json.loads(r["data"]), "custom": True, "author": r["username"] or "",
"mine": uid is not None and r["owner_id"] == uid} for r in rows]
def all_rules() -> dict:
def all_rules(uid=None) -> dict:
"""Rules from rules.json plus the presets created in the app."""
r = load_rules()
r["presets"] = [{**p, "builtin": True} for p in r.get("presets", [])] + custom_presets()
with db() as con:
custom = custom_presets(con, uid)
r["presets"] = [{**p, "builtin": True} for p in r.get("presets", [])] + custom
return r
@@ -76,99 +92,321 @@ def check_size(ch: dict):
raise HTTPException(413, "Character too large")
@app.get("/api/rules")
def get_rules():
return all_rules()
# ---------------------------------------------------------------- security
def client_ip(request: Request) -> str:
if TRUST_PROXY:
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[-1].strip() # the address the reverse proxy saw
return request.client.host if request.client else "?"
@app.post("/api/presets")
def create_preset(body: dict = Body(...)):
def is_https(request: Request) -> bool:
if TRUST_PROXY:
return request.headers.get("x-forwarded-proto", "") == "https"
return request.url.scheme == "https"
@app.middleware("http")
async def security(request: Request, call_next):
# Block cross-site writes (cookies are SameSite=Lax as well)
if request.method not in ("GET", "HEAD", "OPTIONS"):
origin = request.headers.get("origin")
hosts = {request.headers.get("host", "")}
if TRUST_PROXY:
hosts.add(request.headers.get("x-forwarded-host", ""))
if origin and origin.split("://", 1)[-1] not in hosts:
return JSONResponse({"detail": "Cross-site request refused"}, status_code=403)
resp = await call_next(request)
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "same-origin"
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; frame-ancestors 'none'")
if request.url.path.startswith("/api/"):
resp.headers["Cache-Control"] = "no-store"
return resp
def current_user(request: Request) -> dict:
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
if not user:
raise HTTPException(401, "Not logged in")
return user
def set_cookie(request: Request, resp: Response, token: str):
resp.set_cookie(auth.SESSION_COOKIE, token, max_age=auth.SESSION_DAYS * 86400, httponly=True,
samesite="lax", secure=is_https(request), path="/")
def throttle(key: str, limit: int, window: int):
wait = limiter.blocked(key, limit, window)
if wait:
raise HTTPException(429, f"Too many attempts. Try again in {wait // 60 + 1} minute(s).",
headers={"Retry-After": str(wait)})
def check_password_rules(pw: str):
if not isinstance(pw, str) or len(pw) < auth.MIN_PASSWORD:
raise HTTPException(422, f"The password needs at least {auth.MIN_PASSWORD} characters")
if len(pw) > auth.MAX_PASSWORD:
raise HTTPException(422, "The password is too long")
# ---------------------------------------------------------------- auth routes (public)
@app.get("/api/auth/me")
def me(request: Request):
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
return {"user": user, "registration_open": bool(auth.registration_code())}
@app.post("/api/auth/register")
def register(request: Request, response: Response, body: dict = Body(...)):
code_cfg = auth.registration_code()
if not code_cfg:
raise HTTPException(403, "Registration is disabled on this server")
ip = client_ip(request)
# The shared code may be weak, so wrong guesses are throttled per address and for the whole server.
throttle(f"reg:{ip}", 5, 900)
throttle("reg:all", 30, 3600)
username, password, code = str(body.get("username", "")).strip(), body.get("password", ""), str(body.get("code", ""))
if not secrets.compare_digest(code.encode(), code_cfg.encode()):
limiter.fail(f"reg:{ip}"); limiter.fail("reg:all")
raise HTTPException(403, "Wrong registration code")
if not auth.USERNAME_RE.match(username):
raise HTTPException(422, "Username: 3-32 characters, letters, digits and . _ - only")
check_password_rules(password)
with db() as con:
if con.execute("SELECT 1 FROM users WHERE username=?", (username,)).fetchone():
raise HTTPException(409, "This username is taken")
cur = con.execute("INSERT INTO users (username, pw_hash, created) VALUES (?,?,?)",
(username, auth.hash_password(password), time.time()))
token = auth.create_session(con, cur.lastrowid)
set_cookie(request, response, token)
return {"user": {"id": cur.lastrowid, "username": username}}
@app.post("/api/auth/login")
def login(request: Request, response: Response, body: dict = Body(...)):
username, password = str(body.get("username", "")).strip(), str(body.get("password", ""))[:auth.MAX_PASSWORD]
ip = client_ip(request)
keys = (f"login:{ip}", f"login:u:{username.lower()}")
throttle(keys[0], 10, 600)
throttle(keys[1], 8, 600)
with db() as con:
row = con.execute("SELECT id, username, pw_hash FROM users WHERE username=?", (username,)).fetchone()
ok = bool(row) and auth.verify_password(password, row["pw_hash"])
if not row:
auth.timing_dummy(password)
if not ok:
for k in keys:
limiter.fail(k)
raise HTTPException(401, "Wrong username or password")
token = auth.create_session(con, row["id"])
limiter.reset(keys[1])
set_cookie(request, response, token)
return {"user": {"id": row["id"], "username": row["username"]}}
@app.post("/api/auth/logout")
def logout(request: Request, response: Response):
with db() as con:
auth.end_session(con, request.cookies.get(auth.SESSION_COOKIE))
response.delete_cookie(auth.SESSION_COOKIE, path="/")
return {"ok": True}
# ---------------------------------------------------------------- everything below needs a login
api = APIRouter(prefix="/api", dependencies=[Depends(current_user)])
@api.post("/auth/password")
def change_password(request: Request, response: Response, body: dict = Body(...), user: dict = Depends(current_user)):
throttle(f"pw:{user['id']}", 8, 600)
old, new = str(body.get("old", "")), body.get("new", "")
check_password_rules(new)
with db() as con:
row = con.execute("SELECT pw_hash FROM users WHERE id=?", (user["id"],)).fetchone()
if not auth.verify_password(old, row["pw_hash"]):
limiter.fail(f"pw:{user['id']}")
raise HTTPException(403, "The current password is wrong")
con.execute("UPDATE users SET pw_hash=? WHERE id=?", (auth.hash_password(new), user["id"]))
con.execute("DELETE FROM sessions WHERE user_id=?", (user["id"],)) # log out all other devices
token = auth.create_session(con, user["id"])
set_cookie(request, response, token)
return {"ok": True}
@api.get("/rules")
def get_rules(user: dict = Depends(current_user)):
return all_rules(user["id"])
# ---- presets
def _owned(con, table: str, rid: str, user: dict):
row = con.execute(f"SELECT owner_id FROM {table} WHERE id=?", (rid,)).fetchone()
if not row:
raise HTTPException(404, "Not found")
if row["owner_id"] != user["id"]:
raise HTTPException(403, "This belongs to another user")
@api.post("/presets")
def create_preset(body: dict = Body(...), user: dict = Depends(current_user)):
data = clean_preset(body, load_rules())
with db() as con:
if con.execute("SELECT COUNT(*) FROM presets").fetchone()[0] >= MAX_PRESETS:
raise HTTPException(409, "Too many presets on this server")
pid, token, now = "u-" + uuid.uuid4().hex[:8], secrets.token_urlsafe(16), time.time()
con.execute("INSERT INTO presets VALUES (?,?,?,?,?)", (pid, token, json.dumps(data), now, now))
return {"id": pid, "token": token}
pid, now = "u-" + uuid.uuid4().hex[:8], time.time()
con.execute("INSERT INTO presets (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)",
(pid, "", json.dumps(data), now, now, user["id"]))
return {"id": pid}
@app.put("/api/presets/{pid}")
def update_preset(pid: str, body: dict = Body(...), x_edit_token: str = Header(default="")):
@api.put("/presets/{pid}")
def update_preset(pid: str, body: dict = Body(...), user: dict = Depends(current_user)):
data = clean_preset(body, load_rules())
with db() as con:
row = con.execute("SELECT token FROM presets WHERE id=?", (pid,)).fetchone()
if not row:
raise HTTPException(404, "Preset not found")
if not secrets.compare_digest(row["token"], x_edit_token):
raise HTTPException(403, "No write access to this preset")
_owned(con, "presets", pid, user)
con.execute("UPDATE presets SET data=?, updated=? WHERE id=?", (json.dumps(data), time.time(), pid))
return {"ok": True}
@app.delete("/api/presets/{pid}")
def delete_preset(pid: str, x_edit_token: str = Header(default="")):
@api.delete("/presets/{pid}")
def delete_preset(pid: str, user: dict = Depends(current_user)):
with db() as con:
row = con.execute("SELECT token FROM presets WHERE id=?", (pid,)).fetchone()
if not row:
raise HTTPException(404, "Preset not found")
if not secrets.compare_digest(row["token"], x_edit_token):
raise HTTPException(403, "No write access to this preset")
_owned(con, "presets", pid, user)
con.execute("DELETE FROM presets WHERE id=?", (pid,))
return {"ok": True}
@app.post("/api/characters")
def create_character(ch: dict = Body(...)):
# ---- characters (any logged-in user can open a shared link, only the owner can change or delete)
def _name(data: str) -> str:
return json.loads(data).get("name") or "Nameless"
@api.get("/characters")
def my_characters(user: dict = Depends(current_user)):
with db() as con:
rows = con.execute("SELECT id, data, updated FROM characters WHERE owner_id=? ORDER BY updated DESC",
(user["id"],)).fetchall()
shares: dict = {}
for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? ORDER BY u.username""",
(user["id"],)):
shares.setdefault(r["character_id"], []).append(r["username"])
shared = con.execute("""SELECT c.id, c.data, c.updated, o.username AS owner FROM shares s
JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id
WHERE s.user_id=? ORDER BY c.updated DESC""", (user["id"],)).fetchall()
return {"mine": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "shared_with": shares.get(r["id"], [])}
for r in rows],
"shared": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "owner": r["owner"] or "?"}
for r in shared]}
@api.post("/characters")
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
check_size(ch)
cid, token, now = uuid.uuid4().hex[:12], secrets.token_urlsafe(16), time.time()
with db() as con:
con.execute("INSERT INTO characters VALUES (?,?,?,?,?)", (cid, token, json.dumps(ch), now, now))
return {"id": cid, "token": token}
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
raise HTTPException(409, "Character limit reached")
cid, now = uuid.uuid4().hex[:12], time.time()
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)",
(cid, "", json.dumps(ch), now, now, user["id"]))
return {"id": cid}
@app.get("/api/characters/{cid}")
def read_character(cid: str):
@api.get("/characters/{cid}")
def read_character(cid: str, user: dict = Depends(current_user)):
"""Only the owner and the users the character is shared with may read it."""
with db() as con:
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
if not row:
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
allowed = bool(row) and (row["owner_id"] == user["id"] or con.execute(
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone())
if not allowed:
raise HTTPException(404, "Character not found")
return json.loads(row["data"])
@app.put("/api/characters/{cid}")
def update_character(cid: str, ch: dict = Body(...), x_edit_token: str = Header(default="")):
@api.post("/characters/{cid}/shares")
def add_share(cid: str, body: dict = Body(...), user: dict = Depends(current_user)):
name = str(body.get("username", "")).strip()
with db() as con:
_owned(con, "characters", cid, user)
target = con.execute("SELECT id, username FROM users WHERE username=?", (name,)).fetchone()
if not target:
raise HTTPException(404, "No user with this name")
if target["id"] == user["id"]:
raise HTTPException(422, "This is your own character")
if con.execute("SELECT COUNT(*) FROM shares WHERE character_id=?", (cid,)).fetchone()[0] >= MAX_SHARES:
raise HTTPException(409, "Shared with too many users")
con.execute("INSERT OR IGNORE INTO shares VALUES (?,?)", (cid, target["id"]))
return {"username": target["username"]}
@api.delete("/characters/{cid}/shares/{username}")
def remove_share(cid: str, username: str, user: dict = Depends(current_user)):
with db() as con:
_owned(con, "characters", cid, user)
con.execute("DELETE FROM shares WHERE character_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
(cid, username))
return {"ok": True}
@api.put("/characters/{cid}")
def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(current_user)):
check_size(ch)
with db() as con:
row = con.execute("SELECT token FROM characters WHERE id=?", (cid,)).fetchone()
if not row:
raise HTTPException(404, "Character not found")
if not secrets.compare_digest(row["token"], x_edit_token):
raise HTTPException(403, "No write access to this character")
_owned(con, "characters", cid, user)
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
return {"ok": True}
@app.delete("/api/characters/{cid}")
def delete_character(cid: str, x_edit_token: str = Header(default="")):
@api.delete("/characters/{cid}")
def delete_character(cid: str, user: dict = Depends(current_user)):
with db() as con:
row = con.execute("SELECT token FROM characters WHERE id=?", (cid,)).fetchone()
if not row:
raise HTTPException(404, "Character not found")
if not secrets.compare_digest(row["token"], x_edit_token):
raise HTTPException(403, "No write access to this character")
_owned(con, "characters", cid, user)
con.execute("DELETE FROM characters WHERE id=?", (cid,))
con.execute("DELETE FROM shares WHERE character_id=?", (cid,))
return {"ok": True}
@api.post("/claim/{kind}/{rid}")
def claim(kind: str, rid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Takes over something created before accounts existed, using its old secret key."""
table = {"characters": "characters", "presets": "presets"}.get(kind)
if not table:
raise HTTPException(404, "Unknown kind")
throttle(f"claim:{user['id']}", 60, 600)
with db() as con:
row = con.execute(f"SELECT token, owner_id FROM {table} WHERE id=?", (rid,)).fetchone()
key = str(body.get("token", ""))
if not row or row["owner_id"] is not None or not row["token"] or not secrets.compare_digest(row["token"], key):
limiter.fail(f"claim:{user['id']}")
raise HTTPException(404, "Nothing to claim")
con.execute(f"UPDATE {table} SET owner_id=?, token='' WHERE id=?", (user["id"], rid))
return {"ok": True}
# ---- export
def _filename(ch: dict, ext: str) -> str:
base = re.sub(r"[^A-Za-z0-9_-]+", "_", ch.get("name") or "Character").strip("_") or "Character"
return f"{base}.{ext}"
@app.post("/api/export/{fmt}")
def export(fmt: str, ch: dict = Body(...)):
@api.post("/export/{fmt}")
def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
check_size(ch)
rules = all_rules()
rules = all_rules(user["id"])
if fmt == "docx":
data = to_docx(ch, rules)
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
@@ -181,12 +419,17 @@ def export(fmt: str, ch: dict = Body(...)):
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
# Serve the built frontend with an SPA fallback
app.include_router(api)
# Serve the built frontend with an SPA fallback (the app shell is public, all data is behind the API login)
if STATIC.is_dir():
app.mount("/assets", StaticFiles(directory=STATIC / "assets"), name="assets")
@app.get("/{path:path}")
def spa(path: str):
if path.startswith("api/"):
raise HTTPException(404, "Not found")
f = (STATIC / path).resolve()
if path and f.is_file() and STATIC in f.parents:
return FileResponse(f)
+1 -13
View File
@@ -21,18 +21,6 @@
"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
},
@@ -433,7 +421,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."
]
},
+13 -3
View File
@@ -3,7 +3,17 @@ 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:8080: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:-}
# 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
+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>
)
}
+36 -7
View File
@@ -3,6 +3,9 @@ import Creator from './Creator.jsx'
import Rules from './Rules.jsx'
import Characters from './Characters.jsx'
import Presets from './Presets.jsx'
import Login from './Login.jsx'
import Account from './Account.jsx'
import { claimLegacy } from './legacy.js'
const useHash = () => {
const [h, setH] = useState(window.location.hash.slice(1) || 'creator')
@@ -16,16 +19,40 @@ 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 [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'], ['characters', 'My characters']]
return (
<>
@@ -36,19 +63,21 @@ 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} />}
{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' }} />
)}
{page === 'account' && <Account user={username} onLogout={logout} />}
</main>
</>
)
+82 -27
View File
@@ -1,50 +1,105 @@
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 character (read-only), for example your 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 }) {
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('/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your characters.'))
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 when = (t) => new Date(t * 1000).toLocaleString('en-GB')
return (
<section className="page narrow">
<div className="row">
<h1>My characters</h1>
<button onClick={onNew}>+ 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.
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 "New character", then click "Save" on the Summary page.</p>}
<ul className="charlist">
{list.map((c) => (
<li key={c.id} className="card row">
{(data?.mine || []).map((c) => (
<li key={c.id} className="card">
<div className="row">
<div>
<strong>{c.name}</strong>
<div className="muted small">ID {c.id} · {new Date(c.updated).toLocaleString('en-GB')}</div>
<div className="muted small">
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
</div>
</div>
<div className="actions">
<button onClick={() => onOpen(c)}>Open</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>
<strong>{c.name}</strong>
<div className="muted small">from {c.owner} · {when(c.updated)}</div>
</div>
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
</li>
))}
</ul>
</>
)}
</section>
)
}
+88 -92
View File
@@ -1,26 +1,27 @@
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 { 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 }) {
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) {
const DRAFT = `aw_draft:${user}` // drafts are kept per account
const freshCharacter = () => 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 +29,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 +42,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 +54,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 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
@@ -62,7 +75,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
if (!id || loadRequest) 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).
@@ -89,15 +102,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 +134,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.')
@@ -181,7 +194,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 +202,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 +264,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 +287,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 +296,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 +310,13 @@ 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>
<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>
<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,69 +370,44 @@ 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] })} />
<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>
<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>
))}
)
})}
<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>
</>
@@ -438,10 +426,6 @@ 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)
@@ -455,28 +439,40 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
<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>
<table><tbody>{rules.attributes.map((a) => <tr key={a.id}><td><Tip text={explainAttr(a, ctx)}>{a.name}</Tip></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>
<table><tbody>{rules.derived.map((d) => <tr key={d.id}><td><Tip text={explainDerived(d, ctx)}>{d.name}</Tip></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>
<h4><Tip text={explainCapacity(ctx)}>Mutations / implants ({c.capacityUsed}/{c.capacityMax})</Tip></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>
<h4><Tip text={explainCarry(ctx)}>Equipment ({c.weight}/{c.carryMax} kg)</Tip></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>
<h4>Skills</h4>
<div className="skillcols">
{rules.skill_groups.map((g) => (
<div key={g.id} className="skillgroup">
<h5>{g.name}</h5>
<table><tbody>
{c.skills.filter((s) => s.group === g.id).map((s) => (
<tr key={s.id} className={s.usable ? '' : 'dim'}>
<td><Tip text={explainSkill(s, ctx)}>{s.name}</Tip>{s.type === 'advanced' && <span className="muted small"> (adv.)</span>}</td>
<td>{s.total}</td>
<td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.points ? `+${s.points}` : ''}</td>
</tr>
))}
</tbody></table>
</div>
))}
</div>
</div>
<div className="actions wrap">
<button onClick={() => download('pdf')}>Download as PDF</button>
+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>
)
}
+6 -17
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 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)}
</>
)
}
+22 -16
View File
@@ -12,7 +12,7 @@ export function emptyCharacter(rules) {
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: '',
}
@@ -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) {
@@ -120,15 +126,15 @@ export function compute(ch, rules) {
const best = Math.max(...s.attrs.map((a) => attrs[a])) // with several attributes the higher one counts
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
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))
+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 = 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 />)
+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(' · ') })),
+22
View File
@@ -147,3 +147,25 @@ 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; }