diff --git a/.env.example b/.env.example index 84d197c..9ddd69f 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,5 @@ AW_REGISTRATION_CODE= AW_TRUST_PROXY=0 # Port on the host (bound to 127.0.0.1 only). nginx proxies to it. Pick a free one (check: ss -tlnp | grep :8090): AW_PORT=8090 +# Who may use the rules editor (comma separated usernames). Empty = only the first account that was ever created: +AW_ADMINS= diff --git a/DEPLOY.md b/DEPLOY.md index 21a1d41..5433c1d 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -51,7 +51,7 @@ docker compose up -d --build curl -sI http://127.0.0.1:8090 | head -1 # should print HTTP/1.1 200 OK ``` -The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`. +The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`; changes from the rules editor are in `./db/rules_overrides.json` (back up the whole `db` folder). Set `AW_ADMINS` in `.env` to choose who may use the editor (default: the first account). ## 4. nginx + HTTPS @@ -118,3 +118,13 @@ docker compose down # stop ``` If `docker` says it cannot connect to the daemon, check the two `export` lines from step 1 and `systemctl --user status docker`. + +## Updating + +```bash +cd ~/AWCharacterCreator +git pull +docker compose up -d --build +``` + +The `db` folder (database and rules editor changes) is kept. New settings from `.env.example` have to be added to your `.env` by hand. diff --git a/README.md b/README.md index 706260f..c92844d 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,15 @@ Each wizard page has a **Reset this page** button that clears only that page's d The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout. +### Rules editor + +Admins get an **Editor** tab to add, change and delete **items, item categories, skills, skill groups and professions** with forms. Admins are the usernames in `AW_ADMINS` (comma separated, set in `.env`); without that setting only the first account ever created is admin. + +- Changes are saved in `db/rules_overrides.json` (next to the database, so it is part of your backup). For each edited list it **replaces** the list from `rules.json`; the shipped file is not touched. A previous copy is kept as `rules_overrides.json.bak`. "Reset to shipped" removes the override of one list. +- New entries get their id from the name (lowercase, `_`). Ids of existing entries cannot be changed, because characters refer to them. +- The server checks every reference: an item needs an existing category, a skill a group and attributes, a profession existing skills. Removing a category, group or skill that is still used is refused. Removing an item, skill or profession that saved characters use asks for confirmation first (their data stays, the entry disappears from the sheets). +- Talents, mutations, implants, classes and the rules text are still edited in `rules.json`. Players see changes after reloading the page. + ### Lobbies The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave). diff --git a/backend/app/main.py b/backend/app/main.py index 35f48dd..4bc8ee7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from fastapi.staticfiles import StaticFiles from . import auth from .derive import compute +from . import rules_edit from .export import to_docx, to_pdf BASE = Path(__file__).resolve().parent.parent @@ -41,11 +42,30 @@ TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse pr -def load_rules() -> dict: - # Re-read on every request so rules.json can be edited without a restart. +# Changes made in the rules editor live next to the database (writable), the shipped rules.json stays untouched. +OVERRIDES_PATH = Path(DB_PATH).parent / "rules_overrides.json" + + +def base_rules() -> dict: return json.loads(RULES_PATH.read_text(encoding="utf-8")) +def read_overrides() -> dict: + try: + return json.loads(OVERRIDES_PATH.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def load_rules() -> dict: + # Re-read on every request so rules.json can be edited without a restart; editor changes replace whole sections. + r = base_rules() + for sec, items in read_overrides().items(): + if sec in rules_edit.SECTIONS: + r[sec] = items + return r + + def db() -> sqlite3.Connection: Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) con = sqlite3.connect(DB_PATH) @@ -154,6 +174,16 @@ async def security(request: Request, call_next): return resp +def is_admin(user: dict) -> bool: + """Admins (rules editor): the names in AW_ADMINS, or without that setting the very first account.""" + names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()} + if names: + return user["username"].lower() in names + with db() as con: + first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone() + return bool(first) and first["id"] == user["id"] + + def current_user(request: Request) -> dict: with db() as con: user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE)) @@ -187,6 +217,8 @@ def check_password_rules(pw: str): def me(request: Request): with db() as con: user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE)) + if user: + user = {**user, "admin": is_admin(user)} return {"user": user, "registration_open": bool(auth.registration_code())} @@ -213,7 +245,8 @@ def register(request: Request, response: Response, body: dict = Body(...)): (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}} + u = {"id": cur.lastrowid, "username": username} + return {"user": {**u, "admin": is_admin(u)}} @app.post("/api/auth/login") @@ -235,7 +268,8 @@ def login(request: Request, response: Response, body: dict = Body(...)): token = auth.create_session(con, row["id"]) limiter.reset(keys[1]) set_cookie(request, response, token) - return {"user": {"id": row["id"], "username": row["username"]}} + u = {"id": row["id"], "username": row["username"]} + return {"user": {**u, "admin": is_admin(u)}} @app.post("/api/auth/logout") @@ -883,6 +917,75 @@ def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)): headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'}) +# ---- rules editor (admins only) + +def admin_user(user: dict = Depends(current_user)) -> dict: + if not is_admin(user): + raise HTTPException(403, "Only admins can edit the rules") + return user + + +@api.get("/admin/rules") +def editor_state(user: dict = Depends(admin_user)): + """The editable sections as they are now, and which of them differ from the shipped rules.json.""" + r = load_rules() + return {"sections": {k: r[k] for k in rules_edit.SECTIONS}, "customized": [k for k in read_overrides() if k in rules_edit.SECTIONS]} + + +def _all_characters(con) -> list: + return [json.loads(x["data"]) for x in con.execute("SELECT data FROM characters")] + + +def _save_overrides(data: dict): + OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True) + if OVERRIDES_PATH.exists(): + OVERRIDES_PATH.with_suffix(".json.bak").write_bytes(OVERRIDES_PATH.read_bytes()) + tmp = OVERRIDES_PATH.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") + os.replace(tmp, OVERRIDES_PATH) + + +def _apply(section: str, new: list, confirm: bool): + r = load_rules() + old_ids = {x["id"] for x in r[section]} + removed = old_ids - {x["id"] for x in new} + bad = rules_edit.dangling(section, new, r) + if bad: + raise HTTPException(422, "Cannot save: " + "; ".join(bad[:5]) + (" ..." if len(bad) > 5 else "")) + with db() as con: + used = rules_edit.usage(_all_characters(con), section, removed) + if used and not confirm: + raise HTTPException(409, f"{used} saved character{'s' if used != 1 else ''} use{'' if used != 1 else 's'} entries you are removing. " + "Their data stays in the characters but the removed entries disappear from the sheets.") + return used + + +@api.put("/admin/rules/{section}") +def editor_save(section: str, body: dict = Body(...), user: dict = Depends(admin_user)): + try: + new = rules_edit.clean(section, body.get("items"), load_rules()) + except rules_edit.RuleError as e: + raise HTTPException(422, str(e)) + _apply(section, new, bool(body.get("confirm"))) + data = read_overrides() + data[section] = new + _save_overrides(data) + return {"ok": True, "count": len(new)} + + +@api.delete("/admin/rules/{section}") +def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin_user)): + """Back to the shipped rules.json for this section.""" + if section not in rules_edit.SECTIONS: + raise HTTPException(404, "Unknown section") + data = read_overrides() + if section in data: + _apply(section, base_rules()[section], confirm) + del data[section] + _save_overrides(data) + return {"ok": True} + + app.include_router(api) diff --git a/backend/app/rules_edit.py b/backend/app/rules_edit.py new file mode 100644 index 0000000..8e5bc4c --- /dev/null +++ b/backend/app/rules_edit.py @@ -0,0 +1,125 @@ +"""Validation for the rules editor (items, skills, professions and their categories). +Only whitelisted fields are kept and every reference (category, group, attribute, skill) has to exist.""" +import re + +SECTIONS = ("equipment", "equipment_categories", "skills", "skill_groups", "professions") +MAX_ENTRIES = 400 +ID_RE = re.compile(r"^[a-z0-9_]{1,40}$") + + +class RuleError(ValueError): + pass + + +def _text(v, limit, label, required=False): + v = str(v if v is not None else "").strip() + if required and not v: + raise RuleError(f"{label} must not be empty") + if len(v) > limit: + raise RuleError(f"{label} is too long (max {limit} characters)") + return v + + +def _num(v, lo, hi, label, integer=False): + try: + n = float(v) + except (TypeError, ValueError): + raise RuleError(f"{label} must be a number") + if not lo <= n <= hi: + raise RuleError(f"{label} must be between {lo} and {hi}") + return int(n) if integer else round(n, 2) + + +def _ids(v, allowed, label, lo=0, hi=50): + if not isinstance(v, list) or not lo <= len(v) <= hi: + raise RuleError(f"{label}: choose {lo} to {hi}" if hi < 50 else f"{label} must be a list") + out = [] + for x in v: + if x not in allowed: + raise RuleError(f"{label}: unknown entry '{x}'") + if x not in out: + out.append(x) + return out + + +def clean(section: str, items, rules: dict) -> list: + """Returns the cleaned list for one section. `rules` is the effective rules (for the other sections' ids).""" + if section not in SECTIONS: + raise RuleError("Unknown section") + if not isinstance(items, list) or len(items) > MAX_ENTRIES: + raise RuleError(f"A list of at most {MAX_ENTRIES} entries is expected") + attrs = {a["id"] for a in rules["attributes"]} + out, seen = [], set() + cats = {c["id"] for c in (items if section == "equipment_categories" else rules["equipment_categories"])} + groups = {g["id"] for g in (items if section == "skill_groups" else rules["skill_groups"])} + skills = {s["id"] for s in (items if section == "skills" else rules["skills"])} + for it in items: + if not isinstance(it, dict): + raise RuleError("Every entry has to be an object") + iid = str(it.get("id", "")) + if not ID_RE.match(iid): + raise RuleError(f"Invalid id '{iid}' (lowercase letters, digits and _ only, up to 40)") + if iid in seen: + raise RuleError(f"The id '{iid}' is used twice") + seen.add(iid) + name = _text(it.get("name"), 60, f"Name of '{iid}'", True) + e = {"id": iid, "name": name} + label = f"'{name}'" + if section == "equipment": + if it.get("cat") not in cats: + raise RuleError(f"{label}: unknown category") + e.update(cat=it["cat"], stat=_text(it.get("stat"), 40, f"{label} stat"), kg=_num(it.get("kg", 0), 0, 1000, f"{label} weight"), + note=_text(it.get("note"), 200, f"{label} note")) + if it.get("tier"): + e["tier"] = _text(it["tier"], 20, f"{label} tier") + elif section == "equipment_categories": + e["stat"] = _text(it.get("stat"), 40, f"{label} stat column") + elif section == "skills": + if it.get("group") not in groups: + raise RuleError(f"{label}: unknown group") + if it.get("type") not in ("basic", "advanced"): + raise RuleError(f"{label}: type must be basic or advanced") + a = _ids(it.get("attrs"), attrs, f"{label} attributes", 1, 3) + e.update(group=it["group"], attrs=a, type=it["type"], desc=_text(it.get("desc"), 300, f"{label} description")) + elif section == "professions": + e.update(desc=_text(it.get("desc"), 500, f"{label} description"), bonus_attrs=_ids(it.get("bonus_attrs"), attrs, f"{label} bonus attributes", 0, 7), + proficiencies=_ids(it.get("proficiencies"), skills, f"{label} proficiencies", 0, 12), + picks=_num(it.get("picks", 0), 0, 10, f"{label} free picks", True), note=_text(it.get("note"), 200, f"{label} note")) + if not e["note"]: + del e["note"] + if it.get("placeholder"): + e["placeholder"] = True + out.append(e) + return out + + +def dangling(section: str, new: list, rules: dict) -> list: + """Things in the other sections that would point to an entry that no longer exists.""" + ids = {x["id"] for x in new} + msgs = [] + if section == "equipment_categories": + msgs += [f"Item '{e['name']}' uses the removed category" for e in rules["equipment"] if e["cat"] not in ids] + if section == "skill_groups": + msgs += [f"Skill '{s['name']}' uses the removed group" for s in rules["skills"] if s["group"] not in ids] + if section == "skills": + for p in rules["professions"]: + gone = [i for i in p["proficiencies"] if i not in ids] + if gone: + msgs.append(f"Profession '{p['name']}' grants proficiency in a removed skill ({', '.join(gone)})") + return msgs + + +def character_refs(ch: dict) -> dict: + """Ids a saved character points to, per section.""" + kit = ch.get("kit") or {} + items = {i for i in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or []), *(kit.get("extras") or {})] if i} + sk = set((ch.get("skill_points") or {}).keys()) | set((ch.get("proficiencies") or {}).keys()) | set((ch.get("proficiency_picks") or {}).keys()) \ + | set(ch.get("unlocked") or []) | set(ch.get("profession_picks") or []) + return {"equipment": items, "skills": sk, "professions": {ch["profession"]} if ch.get("profession") else set()} + + +def usage(chars: list, section: str, removed: set) -> int: + """How many saved characters use one of the removed ids.""" + if section not in ("equipment", "skills", "professions") or not removed: + return 0 + return sum(1 for ch in chars if character_refs(ch)[section] & removed) diff --git a/docker-compose.yml b/docker-compose.yml index ead62ac..a12e7e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: # Code that new users must enter to create an account (set it in the .env file next to this file). # Empty = registration is disabled. AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-} + # Who may use the rules editor: comma separated usernames. Empty = only the first account that was ever created. + AW_ADMINS: ${AW_ADMINS:-} # Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers. AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0} volumes: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index eff8a7b..631d95a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import Presets from './Presets.jsx' import Lobbies from './Lobbies.jsx' import Login from './Login.jsx' import Account from './Account.jsx' +import Editor from './Editor.jsx' import { claimLegacy } from './legacy.js' const useHash = () => { @@ -53,7 +54,7 @@ export default function App() { if (!auth.user) return
setAuth({ ...auth, user: u })} />
const page = hash.split('/')[0] - const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies']] + const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])] return ( <> @@ -81,6 +82,7 @@ export default function App() { {rules && page === 'lobbies' && ( { setLoadRequest(entry); window.location.hash = 'creator' }} /> )} + {rules && page === 'editor' && auth.user.admin && } {page === 'account' && } diff --git a/frontend/src/Editor.jsx b/frontend/src/Editor.jsx new file mode 100644 index 0000000..b7aa0bb --- /dev/null +++ b/frontend/src/Editor.jsx @@ -0,0 +1,185 @@ +import { useEffect, useMemo, useState } from 'react' + +const J = { 'Content-Type': 'application/json' } +const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) + +// What can be edited: fields per entry and the columns of the list +const defs = (saved, attrs) => { + const cats = (saved.equipment_categories || []).map((c) => [c.id, c.name]) + const groups = (saved.skill_groups || []).map((g) => [g.id, g.name]) + const skills = (saved.skills || []).map((s) => [s.id, s.name]) + const name = (list, id) => (list.find(([k]) => k === id) || [])[1] || id + return { + equipment: { + label: 'Items', noun: 'item', + fields: [['name', 'Name', 'text'], ['cat', 'Category', 'select', cats], ['stat', 'Stat (damage, armor ...)', 'text'], ['tier', 'Tier (optional, e.g. light)', 'text'], + ['kg', 'Weight (kg)', 'number'], ['note', 'Note', 'text'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']], + blank: { cat: cats[0]?.[0] || '', stat: '', kg: 1, note: '' }, + cols: [['Name', (e) => e.name], ['Category', (e) => name(cats, e.cat)], ['Stat', (e) => `${e.stat}${e.tier ? ` (${e.tier})` : ''}`], ['kg', (e) => e.kg]], + }, + equipment_categories: { + label: 'Item categories', noun: 'category', + fields: [['name', 'Name', 'text'], ['stat', 'Name of the stat column', 'text']], + blank: { stat: '' }, cols: [['Name', (e) => e.name], ['Stat column', (e) => e.stat]], + }, + skills: { + label: 'Skills', noun: 'skill', + fields: [['name', 'Name', 'text'], ['group', 'Group', 'select', groups], ['type', 'Type', 'select', [['basic', 'Basic'], ['advanced', 'Advanced']]], + ['attrs', 'Attributes (the higher one counts)', 'multi', attrs], ['desc', 'Description', 'textarea'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']], + blank: { group: groups[0]?.[0] || '', type: 'basic', attrs: [], desc: '' }, + cols: [['Name', (e) => e.name], ['Group', (e) => name(groups, e.group)], ['Type', (e) => e.type], ['Attributes', (e) => e.attrs.map((a) => name(attrs, a)).join(' / ')]], + }, + skill_groups: { + label: 'Skill groups', noun: 'group', fields: [['name', 'Name', 'text']], blank: {}, cols: [['Name', (e) => e.name]], + }, + professions: { + label: 'Professions', noun: 'profession', + fields: [['name', 'Name', 'text'], ['desc', 'Description', 'textarea'], ['bonus_attrs', 'Attributes that can get the profession bonus', 'multi', attrs], + ['proficiencies', 'Skills with fixed proficiency', 'multi', skills], ['picks', 'Free proficiency picks', 'number'], ['note', 'Note', 'text'], + ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']], + blank: { desc: '', bonus_attrs: [], proficiencies: [], picks: 0 }, + cols: [['Name', (e) => e.name], ['Bonus', (e) => e.bonus_attrs.map((a) => name(attrs, a)).join(', ')], ['Proficiencies', (e) => e.proficiencies.map((s) => name(skills, s)).join(', ')], ['Picks', (e) => e.picks]], + }, + } +} + +function Field({ f, value, onChange }) { + const [key, label, type, opts] = f + if (type === 'bool') return + return ( +