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
This commit is contained in:
@@ -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
|
||||||
@@ -5,3 +5,5 @@ backend/__pycache__/
|
|||||||
backend/app/__pycache__/
|
backend/app/__pycache__/
|
||||||
backend/aw.db
|
backend/aw.db
|
||||||
db/
|
db/
|
||||||
|
.env
|
||||||
|
_to_delete/
|
||||||
|
|||||||
@@ -12,7 +12,29 @@ Website for creating characters for the Arcane Wasteland TTRPG, with a searchabl
|
|||||||
docker compose up -d --build
|
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://<server>:8080`. 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
|
## Edit the rules
|
||||||
|
|
||||||
@@ -36,7 +58,7 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are
|
|||||||
|
|
||||||
### Equipment
|
### 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
|
## Development
|
||||||
|
|
||||||
@@ -51,9 +73,7 @@ cd frontend && npm install && npm run dev
|
|||||||
|
|
||||||
## Saving and sharing
|
## 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.
|
|
||||||
|
|
||||||
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.
|
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 +81,17 @@ Each wizard page has a **Reset this page** button that clears only that page's d
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/api/rules` | Rule data |
|
| GET | `/api/auth/me` | Current user (or `null`) |
|
||||||
| POST | `/api/characters` | Create a character, returns `id` and `token` |
|
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
|
||||||
| GET | `/api/characters/{id}` | Read a character |
|
| GET | `/api/rules` | Rule data including presets |
|
||||||
| PUT / DELETE | `/api/characters/{id}` | Change / delete, header `X-Edit-Token` |
|
| GET / POST | `/api/characters` | Own and shared-with-me characters / create one |
|
||||||
| POST | `/api/presets` | Create a preset, returns `id` and `token` |
|
| GET | `/api/characters/{id}` | Read (owner or shared users) |
|
||||||
| PUT / DELETE | `/api/presets/{id}` | Change / delete, header `X-Edit-Token` |
|
| 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 |
|
| 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.
@@ -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:]))
|
||||||
@@ -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", "")
|
||||||
+9
-12
@@ -2,20 +2,17 @@
|
|||||||
|
|
||||||
|
|
||||||
def kit_items(ch: dict, rules: dict) -> list:
|
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", [])}
|
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 {}
|
kit = ch.get("kit") or {}
|
||||||
out = []
|
qty: dict = {}
|
||||||
for label, key in (("Weapon", "weapon"), ("Armor", "armor"), ("Tool", "tool")):
|
for iid in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or [])]:
|
||||||
if kit.get(key) in items:
|
if iid:
|
||||||
out.append((label, items[kit[key]], 1))
|
qty[iid] = qty.get(iid, 0) + 1
|
||||||
for rid in kit.get("resources", []):
|
for iid, n in (kit.get("extras") or {}).items():
|
||||||
if rid in items:
|
qty[iid] = qty.get(iid, 0) + int(n)
|
||||||
out.append(("Resource", items[rid], 1))
|
return [(cats.get(items[i]["cat"], ""), items[i], n) for i, n in qty.items() if i in items and n > 0]
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def compute(ch: dict, rules: dict) -> dict:
|
def compute(ch: dict, rules: dict) -> dict:
|
||||||
|
|||||||
@@ -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"]],
|
"derived_rows": [[d["name"], str(c["derived"][d["id"]])] for d in rules["derived"]],
|
||||||
"skill_rows": [
|
"skill_rows": [
|
||||||
[s["name"], "/".join(a["short"] for a in rules["attributes"] if a["id"] in s["attrs"]),
|
[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"])]
|
str(s["points"]), {1: "Proficient", 2: "Expertise"}.get(s["pips"]) or ("" if s["usable"] else "Locked"), str(s["total"])]
|
||||||
for s in c["skills"] if s["points"] or s["prof"] or (s["unlocked"] and s["type"] == "advanced")
|
for s in c["skills"]
|
||||||
],
|
],
|
||||||
"augment_rows": [[a["name"], KIND.get(a["kind"], a["kind"]), SOURCE.get(a["source"]) or "-", str(a["cost"])]
|
"augment_rows": [[a["name"], KIND.get(a["kind"], a["kind"]), SOURCE.get(a["source"]) or "-", str(a["cost"])]
|
||||||
for a in c["augments"]],
|
for a in c["augments"]],
|
||||||
@@ -92,7 +92,7 @@ def to_docx(ch: dict, rules: dict) -> bytes:
|
|||||||
doc.add_paragraph("-")
|
doc.add_paragraph("-")
|
||||||
doc.add_heading(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", 1)
|
doc.add_heading(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", 1)
|
||||||
if s["gear_rows"]:
|
if s["gear_rows"]:
|
||||||
table(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
|
table(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
|
||||||
else:
|
else:
|
||||||
doc.add_paragraph("-")
|
doc.add_paragraph("-")
|
||||||
if s["other"]:
|
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))
|
el.append(Paragraph(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", h2))
|
||||||
if s["gear_rows"]:
|
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:
|
else:
|
||||||
el.append(Paragraph("-", body))
|
el.append(Paragraph("-", body))
|
||||||
if s["other"]:
|
if s["other"]:
|
||||||
|
|||||||
+301
-58
@@ -7,10 +7,11 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import Body, FastAPI, Header, HTTPException, Response
|
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from . import auth
|
||||||
from .export import to_docx, to_pdf
|
from .export import to_docx, to_pdf
|
||||||
|
|
||||||
BASE = Path(__file__).resolve().parent.parent
|
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"))
|
STATIC = Path(os.environ.get("AW_STATIC", BASE / "static"))
|
||||||
MAX_BODY = 200_000 # bytes per character
|
MAX_BODY = 200_000 # bytes per character
|
||||||
MAX_PRESETS = 200
|
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:
|
def load_rules() -> dict:
|
||||||
@@ -38,19 +45,28 @@ def db() -> sqlite3.Connection:
|
|||||||
con.execute("""CREATE TABLE IF NOT EXISTS presets (
|
con.execute("""CREATE TABLE IF NOT EXISTS presets (
|
||||||
id TEXT PRIMARY KEY, token TEXT NOT NULL, data TEXT NOT NULL,
|
id TEXT PRIMARY KEY, token TEXT NOT NULL, data TEXT NOT NULL,
|
||||||
created REAL NOT NULL, updated REAL 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
|
return con
|
||||||
|
|
||||||
|
|
||||||
def custom_presets() -> list:
|
def custom_presets(con, uid=None) -> list:
|
||||||
with db() as con:
|
rows = con.execute("""SELECT p.id, p.data, p.owner_id, u.username FROM presets p
|
||||||
rows = con.execute("SELECT id, data FROM presets ORDER BY created").fetchall()
|
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} for r in rows]
|
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."""
|
"""Rules from rules.json plus the presets created in the app."""
|
||||||
r = load_rules()
|
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
|
return r
|
||||||
|
|
||||||
|
|
||||||
@@ -76,99 +92,321 @@ def check_size(ch: dict):
|
|||||||
raise HTTPException(413, "Character too large")
|
raise HTTPException(413, "Character too large")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rules")
|
# ---------------------------------------------------------------- security
|
||||||
def get_rules():
|
|
||||||
return all_rules()
|
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 is_https(request: Request) -> bool:
|
||||||
def create_preset(body: dict = Body(...)):
|
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())
|
data = clean_preset(body, load_rules())
|
||||||
with db() as con:
|
with db() as con:
|
||||||
if con.execute("SELECT COUNT(*) FROM presets").fetchone()[0] >= MAX_PRESETS:
|
if con.execute("SELECT COUNT(*) FROM presets").fetchone()[0] >= MAX_PRESETS:
|
||||||
raise HTTPException(409, "Too many presets on this server")
|
raise HTTPException(409, "Too many presets on this server")
|
||||||
pid, token, now = "u-" + uuid.uuid4().hex[:8], secrets.token_urlsafe(16), time.time()
|
pid, now = "u-" + uuid.uuid4().hex[:8], time.time()
|
||||||
con.execute("INSERT INTO presets VALUES (?,?,?,?,?)", (pid, token, json.dumps(data), now, now))
|
con.execute("INSERT INTO presets (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)",
|
||||||
return {"id": pid, "token": token}
|
(pid, "", json.dumps(data), now, now, user["id"]))
|
||||||
|
return {"id": pid}
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/presets/{pid}")
|
@api.put("/presets/{pid}")
|
||||||
def update_preset(pid: str, body: dict = Body(...), x_edit_token: str = Header(default="")):
|
def update_preset(pid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
data = clean_preset(body, load_rules())
|
data = clean_preset(body, load_rules())
|
||||||
with db() as con:
|
with db() as con:
|
||||||
row = con.execute("SELECT token FROM presets WHERE id=?", (pid,)).fetchone()
|
_owned(con, "presets", pid, user)
|
||||||
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")
|
|
||||||
con.execute("UPDATE presets SET data=?, updated=? WHERE id=?", (json.dumps(data), time.time(), pid))
|
con.execute("UPDATE presets SET data=?, updated=? WHERE id=?", (json.dumps(data), time.time(), pid))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/presets/{pid}")
|
@api.delete("/presets/{pid}")
|
||||||
def delete_preset(pid: str, x_edit_token: str = Header(default="")):
|
def delete_preset(pid: str, user: dict = Depends(current_user)):
|
||||||
with db() as con:
|
with db() as con:
|
||||||
row = con.execute("SELECT token FROM presets WHERE id=?", (pid,)).fetchone()
|
_owned(con, "presets", pid, user)
|
||||||
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")
|
|
||||||
con.execute("DELETE FROM presets WHERE id=?", (pid,))
|
con.execute("DELETE FROM presets WHERE id=?", (pid,))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/characters")
|
# ---- characters (any logged-in user can open a shared link, only the owner can change or delete)
|
||||||
def create_character(ch: dict = Body(...)):
|
|
||||||
|
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)
|
check_size(ch)
|
||||||
cid, token, now = uuid.uuid4().hex[:12], secrets.token_urlsafe(16), time.time()
|
|
||||||
with db() as con:
|
with db() as con:
|
||||||
con.execute("INSERT INTO characters VALUES (?,?,?,?,?)", (cid, token, json.dumps(ch), now, now))
|
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
|
||||||
return {"id": cid, "token": token}
|
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}")
|
@api.get("/characters/{cid}")
|
||||||
def read_character(cid: str):
|
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:
|
with db() as con:
|
||||||
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
|
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
||||||
if not row:
|
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")
|
raise HTTPException(404, "Character not found")
|
||||||
return json.loads(row["data"])
|
return json.loads(row["data"])
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/characters/{cid}")
|
@api.post("/characters/{cid}/shares")
|
||||||
def update_character(cid: str, ch: dict = Body(...), x_edit_token: str = Header(default="")):
|
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)
|
check_size(ch)
|
||||||
with db() as con:
|
with db() as con:
|
||||||
row = con.execute("SELECT token FROM characters WHERE id=?", (cid,)).fetchone()
|
_owned(con, "characters", cid, user)
|
||||||
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")
|
|
||||||
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
|
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/characters/{cid}")
|
@api.delete("/characters/{cid}")
|
||||||
def delete_character(cid: str, x_edit_token: str = Header(default="")):
|
def delete_character(cid: str, user: dict = Depends(current_user)):
|
||||||
with db() as con:
|
with db() as con:
|
||||||
row = con.execute("SELECT token FROM characters WHERE id=?", (cid,)).fetchone()
|
_owned(con, "characters", cid, user)
|
||||||
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")
|
|
||||||
con.execute("DELETE FROM characters WHERE id=?", (cid,))
|
con.execute("DELETE FROM characters WHERE id=?", (cid,))
|
||||||
|
con.execute("DELETE FROM shares WHERE character_id=?", (cid,))
|
||||||
return {"ok": True}
|
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:
|
def _filename(ch: dict, ext: str) -> str:
|
||||||
base = re.sub(r"[^A-Za-z0-9_-]+", "_", ch.get("name") or "Character").strip("_") or "Character"
|
base = re.sub(r"[^A-Za-z0-9_-]+", "_", ch.get("name") or "Character").strip("_") or "Character"
|
||||||
return f"{base}.{ext}"
|
return f"{base}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/export/{fmt}")
|
@api.post("/export/{fmt}")
|
||||||
def export(fmt: str, ch: dict = Body(...)):
|
def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
check_size(ch)
|
check_size(ch)
|
||||||
rules = all_rules()
|
rules = all_rules(user["id"])
|
||||||
if fmt == "docx":
|
if fmt == "docx":
|
||||||
data = to_docx(ch, rules)
|
data = to_docx(ch, rules)
|
||||||
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
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)}"'})
|
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():
|
if STATIC.is_dir():
|
||||||
app.mount("/assets", StaticFiles(directory=STATIC / "assets"), name="assets")
|
app.mount("/assets", StaticFiles(directory=STATIC / "assets"), name="assets")
|
||||||
|
|
||||||
@app.get("/{path:path}")
|
@app.get("/{path:path}")
|
||||||
def spa(path: str):
|
def spa(path: str):
|
||||||
|
if path.startswith("api/"):
|
||||||
|
raise HTTPException(404, "Not found")
|
||||||
f = (STATIC / path).resolve()
|
f = (STATIC / path).resolve()
|
||||||
if path and f.is_file() and STATIC in f.parents:
|
if path and f.is_file() and STATIC in f.parents:
|
||||||
return FileResponse(f)
|
return FileResponse(f)
|
||||||
|
|||||||
@@ -21,18 +21,6 @@
|
|||||||
"talents_max": 2,
|
"talents_max": 2,
|
||||||
"proficiency_bonus": 10,
|
"proficiency_bonus": 10,
|
||||||
"expertise_bonus": 20,
|
"expertise_bonus": 20,
|
||||||
"kit": {
|
|
||||||
"weapon_cats": [
|
|
||||||
"melee",
|
|
||||||
"firearm"
|
|
||||||
],
|
|
||||||
"armor_tiers": [
|
|
||||||
"light"
|
|
||||||
],
|
|
||||||
"resources_min": 1,
|
|
||||||
"resources_max": 2,
|
|
||||||
"tools": 1
|
|
||||||
},
|
|
||||||
"carry_per_strength_mod": 10
|
"carry_per_strength_mod": 10
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -3,7 +3,13 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8000" # Host-Port bei Bedarf ändern, oder hinter einen Reverse Proxy hängen
|
- "8080:8000" # change the host port, or put the container behind a reverse proxy
|
||||||
|
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:
|
volumes:
|
||||||
- ./db:/db # SQLite-Datenbank (Charaktere)
|
- ./db:/db # SQLite database (users, characters, presets)
|
||||||
- ./backend/data:/app/data:ro # rules.json: bearbeiten, Seite neu laden, fertig
|
- ./backend/data:/app/data:ro # rules.json: edit, reload the page, done
|
||||||
|
|||||||
@@ -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
@@ -3,6 +3,9 @@ import Creator from './Creator.jsx'
|
|||||||
import Rules from './Rules.jsx'
|
import Rules from './Rules.jsx'
|
||||||
import Characters from './Characters.jsx'
|
import Characters from './Characters.jsx'
|
||||||
import Presets from './Presets.jsx'
|
import Presets from './Presets.jsx'
|
||||||
|
import Login from './Login.jsx'
|
||||||
|
import Account from './Account.jsx'
|
||||||
|
import { claimLegacy } from './legacy.js'
|
||||||
|
|
||||||
const useHash = () => {
|
const useHash = () => {
|
||||||
const [h, setH] = useState(window.location.hash.slice(1) || 'creator')
|
const [h, setH] = useState(window.location.hash.slice(1) || 'creator')
|
||||||
@@ -16,16 +19,40 @@ const useHash = () => {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const hash = useHash()
|
const hash = useHash()
|
||||||
|
const [auth, setAuth] = useState(null) // null = checking, {user, registration_open}
|
||||||
const [rules, setRules] = useState(null)
|
const [rules, setRules] = useState(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
// Used to load a saved character into the creator
|
// Used to load a saved character into the creator
|
||||||
const [loadRequest, setLoadRequest] = useState(null)
|
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.'))
|
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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -36,19 +63,21 @@ export default function App() {
|
|||||||
</a>
|
</a>
|
||||||
<nav>
|
<nav>
|
||||||
{nav.map(([id, label]) => (
|
{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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{error && <p className="warn">{error}</p>}
|
{!rules && <p className="muted">Loading rules …</p>}
|
||||||
{!rules && !error && <p className="muted">Loading rules …</p>}
|
{rules && page === 'creator' && <Creator rules={rules} user={username} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} onName={setCharName} />}
|
||||||
{rules && page === 'creator' && <Creator rules={rules} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} />}
|
|
||||||
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
|
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
|
||||||
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
|
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
|
||||||
{rules && page === 'characters' && (
|
{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>
|
</main>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
+89
-34
@@ -1,50 +1,105 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
const KEY = 'aw_chars'
|
function SharePanel({ c, reload }) {
|
||||||
export const loadList = () => {
|
const [name, setName] = useState('')
|
||||||
try { return JSON.parse(localStorage.getItem(KEY)) || [] } catch { return [] }
|
const [err, setErr] = useState('')
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Characters({ onOpen }) {
|
const add = async (e) => {
|
||||||
const [list, setList] = useState(loadList)
|
e.preventDefault(); setErr('')
|
||||||
|
if (!name.trim()) return
|
||||||
const remove = async (c) => {
|
const r = await fetch(`/api/characters/${c.id}/shares`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: name }) })
|
||||||
if (!window.confirm(`Really delete "${c.name}"?`)) return
|
const data = await r.json().catch(() => ({}))
|
||||||
await fetch(`/api/characters/${c.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': c.token } }).catch(() => {})
|
if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not share.')
|
||||||
const l = list.filter((x) => x.id !== c.id)
|
setName(''); reload()
|
||||||
saveList(l); setList(l)
|
}
|
||||||
|
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 (
|
return (
|
||||||
<section className="page narrow">
|
<section className="page narrow">
|
||||||
<h1>My characters</h1>
|
<div className="row">
|
||||||
|
<h1>My characters</h1>
|
||||||
|
<button onClick={onNew}>+ New character</button>
|
||||||
|
</div>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
This list is stored in this browser. The characters themselves live on the server and can be shared by link
|
These characters are saved in your account. Only you and the users you share a character with can see it.
|
||||||
(<code>#creator/<ID></code>), but only this browser holds the key needed to change them.
|
Shared users can look at it but not change it.
|
||||||
</p>
|
</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">
|
<ul className="charlist">
|
||||||
{list.map((c) => (
|
{(data?.mine || []).map((c) => (
|
||||||
<li key={c.id} className="card row">
|
<li key={c.id} className="card">
|
||||||
<div>
|
<div className="row">
|
||||||
<strong>{c.name}</strong>
|
<div>
|
||||||
<div className="muted small">ID {c.id} · {new Date(c.updated).toLocaleString('en-GB')}</div>
|
<strong>{c.name}</strong>
|
||||||
</div>
|
<div className="muted small">
|
||||||
<div className="actions">
|
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
|
||||||
<button onClick={() => onOpen(c)}>Open</button>
|
</div>
|
||||||
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
|
</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>
|
</div>
|
||||||
|
{open === c.id && <SharePanel c={c} reload={load} />}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-74
@@ -1,26 +1,25 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
|
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
|
||||||
import { rememberCharacter } from './Characters.jsx'
|
|
||||||
import SheetPanel from './SheetPanel.jsx'
|
import SheetPanel from './SheetPanel.jsx'
|
||||||
|
|
||||||
const DRAFT = 'aw_draft'
|
|
||||||
const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary']
|
const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary']
|
||||||
const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' }
|
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>
|
const Badge = () => <span className="badge" title="Not yet worked out in the brainstorm">Placeholder</span>
|
||||||
|
|
||||||
function readDraft() {
|
function readDraft(key) {
|
||||||
try { return JSON.parse(localStorage.getItem(DRAFT)) } catch { return null }
|
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 freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
|
||||||
const [ch, setCh] = useState(() => {
|
const [ch, setCh] = useState(() => {
|
||||||
const d = readDraft()
|
const d = readDraft(DRAFT)
|
||||||
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
|
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
|
// `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 [committed, setCommitted] = useState(() => {
|
||||||
const d = readDraft()
|
const d = readDraft(DRAFT)
|
||||||
const src = d?.committed || d?.ch
|
const src = d?.committed || d?.ch
|
||||||
return src ? normalize(src, baseRules) : freshCharacter()
|
return src ? normalize(src, baseRules) : freshCharacter()
|
||||||
})
|
})
|
||||||
@@ -28,7 +27,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
|||||||
const pending = JSON.stringify(ch) !== JSON.stringify(committed)
|
const pending = JSON.stringify(ch) !== JSON.stringify(committed)
|
||||||
const apply = () => setCommitted(ch)
|
const apply = () => setCommitted(ch)
|
||||||
const discard = () => setCh(committed)
|
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 [step, setStep] = useState(0)
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
@@ -42,6 +41,8 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
|||||||
const set = (patch) => setCh((x) => ({ ...x, ...patch }))
|
const set = (patch) => setCh((x) => ({ ...x, ...patch }))
|
||||||
const cr = rules.creation
|
const cr = rules.creation
|
||||||
|
|
||||||
|
useEffect(() => { onName?.(ch.name) }, [ch.name]) // eslint-disable-line
|
||||||
|
|
||||||
// Keep the draft in this browser
|
// Keep the draft in this browser
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try { localStorage.setItem(DRAFT, JSON.stringify({ ch, committed, saved })) } catch { /* ignore */ }
|
try { localStorage.setItem(DRAFT, JSON.stringify({ ch, committed, saved })) } catch { /* ignore */ }
|
||||||
@@ -50,8 +51,17 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
|||||||
// Load a character picked in "My characters"
|
// Load a character picked in "My characters"
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadRequest) return
|
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() })
|
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.'))
|
.catch(() => setMsg('Could not load the character.'))
|
||||||
.finally(clearLoad)
|
.finally(clearLoad)
|
||||||
}, [loadRequest]) // eslint-disable-line
|
}, [loadRequest]) // eslint-disable-line
|
||||||
@@ -62,7 +72,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
|||||||
if (!id || loadRequest) return
|
if (!id || loadRequest) return
|
||||||
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
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.') })
|
.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
|
}, []) // eslint-disable-line
|
||||||
|
|
||||||
// Moving between pages: pending edits are applied first (Next applies silently, other jumps ask).
|
// Moving between pages: pending edits are applied first (Next applies silently, other jumps ask).
|
||||||
@@ -89,15 +99,14 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
|||||||
const save = async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
if (saved) {
|
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()
|
if (!r.ok) throw new Error()
|
||||||
rememberCharacter(saved.id, saved.token, ch.name)
|
|
||||||
flash('Saved.')
|
flash('Saved.')
|
||||||
} else {
|
} else {
|
||||||
const r = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
|
const r = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
|
||||||
if (!r.ok) throw new Error()
|
if (!r.ok) throw new Error()
|
||||||
const { id, token } = await r.json()
|
const { id } = await r.json()
|
||||||
setSaved({ id, token }); rememberCharacter(id, token, ch.name)
|
setSaved({ id })
|
||||||
flash('Saved. The share link is shown below.')
|
flash('Saved. The share link is shown below.')
|
||||||
}
|
}
|
||||||
} catch { flash('Saving failed.') }
|
} catch { flash('Saving failed.') }
|
||||||
@@ -357,69 +366,44 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// --- Equipment ---
|
// --- Equipment ---
|
||||||
const kitRule = cr.kit
|
|
||||||
const items = rules.equipment
|
const items = rules.equipment
|
||||||
const catName = (id) => rules.equipment_categories.find((x) => x.id === id).name
|
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 setExtra = (id, qty) => {
|
||||||
const extras = { ...ch.kit.extras }
|
const extras = { ...ch.kit.extras }
|
||||||
if (qty <= 0) delete extras[id]; else extras[id] = qty
|
if (qty <= 0) delete extras[id]; else extras[id] = qty
|
||||||
setKit({ extras })
|
set({ kit: { ...ch.kit, extras } })
|
||||||
}
|
}
|
||||||
const overWeight = c.weight > c.carryMax
|
const overWeight = c.weight > c.carryMax
|
||||||
|
|
||||||
const stepGear = (
|
const stepGear = (
|
||||||
<>
|
<>
|
||||||
<h2>Equipment</h2>
|
<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>
|
<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' : ''}`}>Carried: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
|
<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" />
|
{rules.equipment_categories.map((cat) => {
|
||||||
<SingleSlot title="Armor or clothing" list={armorItems} slot="armor" />
|
const list = items.filter((e) => e.cat === cat.id)
|
||||||
<h3>Resources ({ch.kit.resources.length}/{kitRule.resources_max})</h3>
|
const n = list.reduce((sum, e) => sum + (ch.kit.extras[e.id] || 0), 0)
|
||||||
<div className="cards">{resourceItems.map((e) => {
|
return (
|
||||||
const on = ch.kit.resources.includes(e.id)
|
<div key={cat.id}>
|
||||||
return <ItemCard key={e.id} e={e} on={on} disabled={!on && ch.kit.resources.length >= kitRule.resources_max}
|
<h4>{cat.name} {n > 0 && <span className="muted small">({n} chosen)</span>}</h4>
|
||||||
onClick={() => setKit({ resources: on ? ch.kit.resources.filter((x) => x !== e.id) : [...ch.kit.resources, e.id] })} />
|
<div className="cards">{list.map((e) => {
|
||||||
})}</div>
|
const qty = ch.kit.extras[e.id] || 0
|
||||||
<SingleSlot title="Tool or utility item" list={toolItems} slot="tool" />
|
return (
|
||||||
|
<div key={e.id} className={`card tile ${qty > 0 ? 'on' : ''}`}>
|
||||||
<h3>Extra items</h3>
|
<strong>{e.name}</strong>
|
||||||
<p className="small muted">Anything else from the document's item lists. Ask your GM what you may take along.</p>
|
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
|
||||||
{rules.equipment_categories.map((cat) => (
|
{e.note && <span className="small">{e.note}</span>}
|
||||||
<details key={cat.id}>
|
<div className="stepper tilestepper">
|
||||||
<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>
|
<button className="ghost" onClick={() => setExtra(e.id, qty - 1)} disabled={qty === 0} aria-label={`${e.name} minus`}>−</button>
|
||||||
<div className="extras">{items.filter((e) => e.cat === cat.id).map((e) => (
|
<span className="val">{qty}</span>
|
||||||
<div key={e.id} className="extra">
|
<button onClick={() => setExtra(e.id, qty + 1)} aria-label={`${e.name} plus`}>+</button>
|
||||||
<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>
|
||||||
<div className="stepper">
|
</div>
|
||||||
<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>
|
})}</div>
|
||||||
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) + 1)} aria-label={`${e.name} plus`}>+</button>
|
</div>
|
||||||
</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>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>
|
<label>Notes<textarea rows="4" value={ch.notes} onChange={(e) => set({ notes: e.target.value })} /></label>
|
||||||
</>
|
</>
|
||||||
@@ -438,10 +422,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 (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 (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
|
||||||
if (over) warnings.push('Augmentation Capacity exceeded.')
|
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.')
|
if (overWeight) warnings.push('Carrying more than the carry capacity.')
|
||||||
|
|
||||||
const gearLines = kitItems(ch, rules)
|
const gearLines = kitItems(ch, rules)
|
||||||
@@ -460,11 +440,6 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
|||||||
<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>{d.name}</td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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>
|
<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>}
|
{!ch.talents.length && <p>–</p>}
|
||||||
@@ -477,6 +452,23 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
|||||||
{ch.gear.other && <p className="small"><strong>Other:</strong> {ch.gear.other}</p>}
|
{ch.gear.other && <p className="small"><strong>Other:</strong> {ch.gear.other}</p>}
|
||||||
</div>
|
</div>
|
||||||
</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>{s.name}{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>
|
||||||
<div className="actions wrap">
|
<div className="actions wrap">
|
||||||
<button onClick={() => download('pdf')}>Download as PDF</button>
|
<button onClick={() => download('pdf')}>Download as PDF</button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import { useState } from 'react'
|
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: {} }
|
const BLANK = { id: null, name: '', desc: '', creation: {} }
|
||||||
|
|
||||||
export default function Presets({ rules, reload }) {
|
export default function Presets({ rules, reload }) {
|
||||||
const [tokens, setTokens] = useState(loadTokens)
|
|
||||||
const [sel, setSel] = useState(rules.default_preset || 'standard')
|
const [sel, setSel] = useState(rules.default_preset || 'standard')
|
||||||
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
|
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
const presets = rules.presets || []
|
const presets = rules.presets || []
|
||||||
const current = draft || presets.find((p) => p.id === sel) || presets[0]
|
const current = draft || presets.find((p) => p.id === sel) || presets[0]
|
||||||
const editable = !!draft
|
const editable = !!draft
|
||||||
const own = !!current?.id && !!tokens[current.id]
|
const own = !!current?.mine
|
||||||
const base = rules.creation
|
const base = rules.creation
|
||||||
const groups = [...new Set((rules.tunables || []).map((x) => x.group))]
|
const groups = [...new Set((rules.tunables || []).map((x) => x.group))]
|
||||||
const value = (k) => (current.creation?.[k] ?? base[k])
|
const value = (k) => (current.creation?.[k] ?? base[k])
|
||||||
@@ -36,17 +31,13 @@ export default function Presets({ rules, reload }) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(draft.id ? `/api/presets/${draft.id}` : '/api/presets', {
|
const res = await fetch(draft.id ? `/api/presets/${draft.id}` : '/api/presets', {
|
||||||
method: draft.id ? 'PUT' : 'POST',
|
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),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Saving failed')
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Saving failed')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
let id = draft.id
|
let id = draft.id
|
||||||
if (!id) {
|
if (!id) id = data.id
|
||||||
id = data.id
|
|
||||||
const t = { ...tokens, [id]: data.token }
|
|
||||||
saveTokens(t); setTokens(t)
|
|
||||||
}
|
|
||||||
await reload()
|
await reload()
|
||||||
setSel(id); setDraft(null); flash('Preset saved.')
|
setSel(id); setDraft(null); flash('Preset saved.')
|
||||||
} catch (e) { flash(String(e.message || e)) }
|
} catch (e) { flash(String(e.message || e)) }
|
||||||
@@ -55,22 +46,20 @@ export default function Presets({ rules, reload }) {
|
|||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!window.confirm(`Delete the preset "${current.name}"? Characters that already use it keep their numbers.`)) return
|
if (!window.confirm(`Delete the preset "${current.name}"? Characters that already use it keep their numbers.`)) return
|
||||||
try {
|
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')
|
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')
|
await reload(); setSel(rules.default_preset || 'standard')
|
||||||
} catch (e) { flash(String(e.message || e)) }
|
} 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 (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<h1>Rules presets</h1>
|
<h1>Rules presets</h1>
|
||||||
<p className="muted narrow">
|
<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.
|
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.
|
Built-in presets come from <code>rules.json</code>. Duplicate one to make your own version.
|
||||||
</p>
|
</p>
|
||||||
<div className="presets-layout">
|
<div className="presets-layout">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Rows({ now, before, valueLabel }) {
|
|||||||
return (
|
return (
|
||||||
<table className="difftable"><tbody>
|
<table className="difftable"><tbody>
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<tr key={r.k} className={`d-${r.status}`}>
|
<tr key={r.k} className={`d-${r.status} ${r.dim ? 'dim' : ''}`}>
|
||||||
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
|
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
|
||||||
<td>
|
<td>
|
||||||
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && <span className="was">{r.old.val} → </span>}
|
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && <span className="was">{r.old.val} → </span>}
|
||||||
|
|||||||
+13
-8
@@ -12,7 +12,7 @@ export function emptyCharacter(rules) {
|
|||||||
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
|
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
|
||||||
skill_points: {}, proficiencies: {}, proficiency_picks: {}, unlocked: [],
|
skill_points: {}, proficiencies: {}, proficiency_picks: {}, unlocked: [],
|
||||||
talents: [], augments: [],
|
talents: [], augments: [],
|
||||||
kit: { weapon: '', armor: '', tool: '', resources: [], extras: {} },
|
kit: { extras: {} }, // item id -> quantity
|
||||||
gear: { other: '' },
|
gear: { other: '' },
|
||||||
notes: '',
|
notes: '',
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ export function normalize(ch, rules) {
|
|||||||
const preset = ch.preset || rules.default_preset || 'standard'
|
const preset = ch.preset || rules.default_preset || 'standard'
|
||||||
const creation = ch.creation ?? presetOverrides(rules, preset)
|
const creation = ch.creation ?? presetOverrides(rules, preset)
|
||||||
const base = emptyCharacter(effectiveRules(rules, { creation }))
|
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 (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) {
|
if (out.skill_pool !== effectiveRules(rules, out).creation.skill_points_default) {
|
||||||
out.creation = { ...out.creation, skill_points_default: out.skill_pool }
|
out.creation = { ...out.creation, skill_points_default: out.skill_pool }
|
||||||
@@ -73,6 +73,13 @@ export function normalize(ch, rules) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete out.skill_pool
|
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
|
// 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)
|
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 count as unlocked
|
||||||
@@ -92,12 +99,10 @@ export function pruneLocked(ch, rules) {
|
|||||||
|
|
||||||
export function kitItems(ch, rules) {
|
export function kitItems(ch, rules) {
|
||||||
const items = Object.fromEntries(rules.equipment.map((e) => [e.id, e]))
|
const items = Object.fromEntries(rules.equipment.map((e) => [e.id, e]))
|
||||||
const k = ch.kit
|
const cats = Object.fromEntries((rules.equipment_categories || []).map((c) => [c.id, c.name]))
|
||||||
const out = []
|
return Object.entries(ch.kit.extras || {})
|
||||||
;[['Weapon', k.weapon], ['Armor', k.armor], ['Tool', k.tool]].forEach(([label, id]) => { if (items[id]) out.push({ label, item: items[id], qty: 1 }) })
|
.filter(([id, qty]) => items[id] && qty > 0)
|
||||||
k.resources.forEach((id) => { if (items[id]) out.push({ label: 'Resource', item: items[id], qty: 1 }) })
|
.map(([id, qty]) => ({ label: cats[items[id].cat] || '', item: items[id], qty }))
|
||||||
Object.entries(k.extras).forEach(([id, qty]) => { if (items[id] && qty > 0) out.push({ label: 'Extra', item: items[id], qty }) })
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compute(ch, rules) {
|
export function compute(ch, rules) {
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
|
}
|
||||||
@@ -3,4 +3,13 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import './styles.css'
|
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 />)
|
createRoot(document.getElementById('root')).render(<App />)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function sheetModel(ch, baseRules) {
|
|||||||
const cls = rules.classes.find((k) => k.id === ch.class_id)
|
const cls = rules.classes.find((k) => k.id === ch.class_id)
|
||||||
const sub = cls?.subclasses.find((x) => x.id === ch.subclass_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 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 {
|
return {
|
||||||
c,
|
c,
|
||||||
head: [
|
head: [
|
||||||
@@ -19,8 +19,7 @@ export function sheetModel(ch, baseRules) {
|
|||||||
],
|
],
|
||||||
attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}` })),
|
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] })),
|
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'))
|
skills: c.skills.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s), dim: !s.usable })),
|
||||||
.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s) })),
|
|
||||||
talents: rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => ({ k: t.id, label: t.name, desc: t.desc })),
|
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}` : ''}` })),
|
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(' · ') })),
|
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(' · ') })),
|
||||||
|
|||||||
@@ -147,3 +147,18 @@ ul.desclist { list-style: none; padding: 0; margin: 0 0 .5rem; display: grid; ga
|
|||||||
main { max-width: 1280px; }
|
main { max-width: 1280px; }
|
||||||
@media (max-width: 1100px) { .creator-body { grid-template-columns: minmax(0, 1fr) 360px; } }
|
@media (max-width: 1100px) { .creator-body { grid-template-columns: minmax(0, 1fr) 360px; } }
|
||||||
@media (max-width: 800px) { .creator-body { grid-template-columns: 1fr; } }
|
@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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user