commit afbdc4b13997afd354df78cd3ee037d9c7dedf41 Author: DuOtto Date: Mon Oct 19 22:27:05 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d96cac --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +frontend/dist/ +backend/static/ +backend/__pycache__/ +backend/app/__pycache__/ +backend/aw.db +db/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bb3dc99 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Stufe 1: React-Frontend bauen +FROM node:20-alpine AS frontend +WORKDIR /fe +COPY frontend/package.json ./ +RUN npm install --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +# Stufe 2: FastAPI liefert API und gebautes Frontend aus +FROM python:3.12-slim +WORKDIR /app +COPY backend/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY backend/app ./app +COPY backend/data ./data +COPY --from=frontend /fe/dist ./static +ENV AW_DB=/db/aw.db +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..08cae7a --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Arcane Wasteland Character Creator + +Website for creating characters for the Arcane Wasteland TTRPG, with a searchable rulebook and export to PDF or Word. + +- **Frontend:** React (Vite), 8-step wizard, searchable rules page +- **Backend:** FastAPI, SQLite, `python-docx` and `reportlab` for the export +- Everything runs in **one** container + +## Run it on the server + +```bash +docker compose up -d --build +``` + +Then open `http://:8080`. Characters are stored in `./db/aw.db`. For HTTPS and a domain, put the container behind a reverse proxy (nginx, Caddy, Traefik). + +## Edit the rules + +All rule data lives in `backend/data/rules.json`: attributes, derived-stat formulas, skills, professions, classes, talents, mutations, implants, conditions, equipment and the texts of the rules page. Edit the file and reload the page; no rebuild is needed because the folder is mounted into the container. + +Entries with `"placeholder": true` are placeholders and show a badge in the app. To remove the badge, fill in the entry and delete that line. + +**Note:** Derived-stat formulas are in `rules.json` (`derived[].formula` = list of attributes whose mods are summed). Special rules that cannot be expressed there (profession attribute bonus, skill maximum, higher-of-two-attributes for skills, carry capacity) are mirrored in `frontend/src/calc.js` and `backend/app/derive.py`. When changing them, change both files. + +### Rules presets + +Presets are named sets of creation numbers (attribute points, skill pool, talents, proficiency picks, unlock cost, caps ...). They are defined in `rules.json` under `presets`: each has an `id`, `name`, `desc` and a `creation` object that overrides keys of the global `creation` block (an empty object = the standard values). `default_preset` is the one new characters start with. The list of values that can be edited per character is `tunables`. + +In the creator, the Concept page only has a "Rules preset" selector. Presets are created, edited, duplicated and deleted on the **Presets** tab. Built-in presets (from `rules.json`) are read-only; user presets are stored in the database (`/api/presets`) and visible to everyone, but only the browser that created one holds the key to change or delete it. The chosen numbers are copied into the character, so shared links and exports use the same numbers even if the preset is changed or deleted later. "Start over" keeps the chosen preset. + +### Skills, proficiency and expertise + +- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if a profession or proficiency covers it). +- `creation.free_proficiencies`: free proficiency picks every character gets. Talents can add more with `"proficiency_picks": N` (see "Versatile Training"). +- Proficiency is +`proficiency_bonus`; two picks on one skill (a profession proficiency counts as one) give Expertise, +`expertise_bonus`. + +### Equipment + +`equipment` lists every item with `id`, `name`, `cat` (category), `stat` (damage / armor / type), `kg` and `note`. The starting-kit rules are in `creation.kit` (allowed weapon categories, armor tiers, number of resources). Weights are approximate; for ranges in the source document the upper value is used. + +## Development + +```bash +# Backend +cd backend && pip install -r requirements.txt +uvicorn app.main:app --reload --port 8000 + +# Frontend (second terminal, proxies /api to port 8000) +cd frontend && npm install && npm run dev +``` + +## Saving and sharing + +There are no user accounts. When saved, a character gets an ID and a secret write key that stays in the browser ("My characters" page). The link `/#creator/` 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"). + +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. + +Each wizard page has a **Reset this page** button that clears only that page's data, plus **Start over** for the whole character. + +## API + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/rules` | Rule data | +| POST | `/api/characters` | Create a character, returns `id` and `token` | +| GET | `/api/characters/{id}` | Read a character | +| PUT / DELETE | `/api/characters/{id}` | Change / delete, header `X-Edit-Token` | +| POST | `/api/presets` | Create a preset, returns `id` and `token` | +| PUT / DELETE | `/api/presets/{id}` | Change / delete, header `X-Edit-Token` | +| POST | `/api/export/pdf` or `/docx` | Character JSON in the body, file in the response | diff --git a/_to_delete/awcc5.tgz b/_to_delete/awcc5.tgz new file mode 100644 index 0000000..bcb076d Binary files /dev/null and b/_to_delete/awcc5.tgz differ diff --git a/_to_delete/awcc6.tgz b/_to_delete/awcc6.tgz new file mode 100644 index 0000000..d4e3af6 Binary files /dev/null and b/_to_delete/awcc6.tgz differ diff --git a/_to_delete/awcc7.tgz b/_to_delete/awcc7.tgz new file mode 100644 index 0000000..c7539fb Binary files /dev/null and b/_to_delete/awcc7.tgz differ diff --git a/_to_delete/awcc8.tgz b/_to_delete/awcc8.tgz new file mode 100644 index 0000000..38e71e9 Binary files /dev/null and b/_to_delete/awcc8.tgz differ diff --git a/_to_delete/awcc9.tgz b/_to_delete/awcc9.tgz new file mode 100644 index 0000000..eeb6132 Binary files /dev/null and b/_to_delete/awcc9.tgz differ diff --git a/backend/app/derive.py b/backend/app/derive.py new file mode 100644 index 0000000..f7527c3 --- /dev/null +++ b/backend/app/derive.py @@ -0,0 +1,57 @@ +"""Computes modifiers, derived stats, skill totals and carried weight (mirror of frontend/src/calc.js).""" + + +def kit_items(ch: dict, rules: dict) -> list: + """Returns [(label, item, qty)] for the starting kit and extra items.""" + items = {e["id"]: e for e in rules.get("equipment", [])} + kit = ch.get("kit") or {} + out = [] + for label, key in (("Weapon", "weapon"), ("Armor", "armor"), ("Tool", "tool")): + if kit.get(key) in items: + out.append((label, items[kit[key]], 1)) + for rid in kit.get("resources", []): + if rid in items: + out.append(("Resource", items[rid], 1)) + for iid, qty in (kit.get("extras") or {}).items(): + if iid in items and int(qty) > 0: + out.append(("Extra", items[iid], int(qty))) + return out + + +def compute(ch: dict, rules: dict) -> dict: + cr = {**rules["creation"], **(ch.get("creation") or {})} # per-character rules preset overrides + prof_id = ch.get("profession") + bonus_attr = ch.get("profession_bonus_attr") if prof_id else None + + attrs = {} + for a in rules["attributes"]: + base = int((ch.get("attributes") or {}).get(a["id"], cr["attr_start"])) + attrs[a["id"]] = base + (cr["profession_attr_bonus"] if a["id"] == bonus_attr else 0) + mods = {k: v // 10 for k, v in attrs.items()} + + derived = {d["id"]: sum(mods[x] for x in d["formula"]) for d in rules["derived"]} + + skills = [] + for s in rules["skills"]: + pts = int((ch.get("skill_points") or {}).get(s["id"], 0)) + # Proficiency "pips": 1 from the profession plus free picks, at most 2 (2 = Expertise) + prof_pip = 1 if (ch.get("proficiencies") or {}).get(s["id"]) else 0 + free_pips = min(int((ch.get("proficiency_picks") or {}).get(s["id"], 0)), cr.get("max_proficiency_pips", 2) - prof_pip) + pips = prof_pip + max(0, free_pips) + prof = [0, cr["proficiency_bonus"], cr["expertise_bonus"]][pips] + unlocked = s["id"] in (ch.get("unlocked") or []) + usable = s["type"] == "basic" or unlocked or prof > 0 # advanced skills must be unlocked + best = max(attrs[a] for a in s["attrs"]) # with several attributes the higher one counts + skills.append({**s, "points": pts, "prof": prof, "pips": pips, "usable": usable, "unlocked": unlocked, + "total": best + pts + prof}) + + aug_by_id = {a["id"]: a for a in rules["augments"]} + chosen = [aug_by_id[i] for i in ch.get("augments", []) if i in aug_by_id] + used = sum(a["cost"] for a in chosen) + + weight = round(sum(item["kg"] * qty for _, item, qty in kit_items(ch, rules)), 1) + + return {"attrs": attrs, "mods": mods, "derived": derived, "skills": skills, + "capacity_used": used, "capacity_max": derived.get("augmentation", 0), + "augments": chosen, "weight": weight, + "carry_max": max(0, mods["strength"]) * cr.get("carry_per_strength_mod", 10)} diff --git a/backend/app/export.py b/backend/app/export.py new file mode 100644 index 0000000..de9dd98 --- /dev/null +++ b/backend/app/export.py @@ -0,0 +1,179 @@ +"""Character sheet as DOCX and PDF.""" +import io + +from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.shared import Pt +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.platypus import Paragraph, SimpleDocTemplate, Table, TableStyle + +from .derive import compute, kit_items + +SOURCE = {"noxium": "Noxium", "diesium": "Diesium", "hybrid": "Hybrid", "precyber": "Pre-meteor cybernetics"} +KIND = {"mutation": "Mutation", "implant": "Implant"} + + +def _sheet(ch: dict, rules: dict) -> dict: + """Prepares all data once for both formats.""" + c = compute(ch, rules) + prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None) + talents = [t for t in rules["talents"] if t["id"] in ch.get("talents", [])] + cls = next((k for k in rules.get("classes", []) if k["id"] == ch.get("class_id")), None) + sub = next((x for x in (cls or {}).get("subclasses", []) if x["id"] == ch.get("subclass_id")), None) + gear_rows = [] + for label, item, qty in kit_items(ch, rules): + name = item["name"] + (f" x{qty}" if qty > 1 else "") + gear_rows.append([label, name, item["stat"], f"{round(item['kg'] * qty, 1)} kg"]) + preset = next((x["name"] for x in rules.get("presets", []) if x["id"] == ch.get("preset")), "Custom") + return { + "c": c, + "preset": preset, + "class": " / ".join(x["name"] for x in (cls, sub) if x) or "-", + "profession": prof["name"] if prof else "-", + "talents": talents, + "gear_rows": gear_rows, + "other": (ch.get("gear") or {}).get("other", ""), + "attr_rows": [[a["name"], str(c["attrs"][a["id"]]), str(c["mods"][a["id"]])] for a in rules["attributes"]], + "derived_rows": [[d["name"], str(c["derived"][d["id"]])] for d in rules["derived"]], + "skill_rows": [ + [s["name"], "/".join(a["short"] for a in rules["attributes"] if a["id"] in s["attrs"]), + str(s["points"]), {0: "", 1: "Proficient", 2: "Expertise"}.get(s["pips"], ""), str(s["total"])] + for s in c["skills"] if s["points"] or s["prof"] or (s["unlocked"] and s["type"] == "advanced") + ], + "augment_rows": [[a["name"], KIND.get(a["kind"], a["kind"]), SOURCE.get(a["source"]) or "-", str(a["cost"])] + for a in c["augments"]], + } + + +def to_docx(ch: dict, rules: dict) -> bytes: + s = _sheet(ch, rules) + c = s["c"] + doc = Document() + doc.styles["Normal"].font.name = "Calibri" + doc.styles["Normal"].font.size = Pt(10) + + title = doc.add_heading(ch.get("name") or "Nameless", 0) + title.alignment = WD_ALIGN_PARAGRAPH.LEFT + doc.add_paragraph(f"Arcane Wasteland | Player: {ch.get('player') or '-'} | Profession: {s['profession']} | Class: {s['class']} | Rules: {s['preset']}") + if ch.get("concept"): + doc.add_paragraph().add_run(ch["concept"]).italic = True + + def table(head, rows): + t = doc.add_table(rows=1, cols=len(head)) + t.style = "Light Grid Accent 1" + for i, h in enumerate(head): + t.rows[0].cells[i].text = h + for r in rows: + cells = t.add_row().cells + for i, v in enumerate(r): + cells[i].text = v + + doc.add_heading("Attributes", 1) + table(["Attribute", "Value", "Mod"], s["attr_rows"]) + doc.add_heading("Derived stats", 1) + table(["Stat", ""], s["derived_rows"]) + doc.add_heading("Skills", 1) + if s["skill_rows"]: + table(["Skill", "Attr.", "Points", "Training", "Target"], s["skill_rows"]) + else: + doc.add_paragraph("No skill points spent.") + doc.add_heading("Talents", 1) + for t in s["talents"]: + doc.add_paragraph(f"{t['name']}: {t['desc']}", style="List Bullet") + if not s["talents"]: + doc.add_paragraph("-") + doc.add_heading(f"Mutations and implants ({c['capacity_used']} / {c['capacity_max']} capacity)", 1) + if s["augment_rows"]: + table(["Name", "Type", "Source", "Cost"], s["augment_rows"]) + else: + doc.add_paragraph("-") + doc.add_heading(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", 1) + if s["gear_rows"]: + table(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"]) + else: + doc.add_paragraph("-") + if s["other"]: + p = doc.add_paragraph() + p.add_run("Other: ").bold = True + p.add_run(s["other"]) + for key, head in (("background", "Background"), ("notes", "Notes")): + if ch.get(key): + doc.add_heading(head, 1) + doc.add_paragraph(ch[key]) + + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() + + +def to_pdf(ch: dict, rules: dict) -> bytes: + s = _sheet(ch, rules) + c = s["c"] + buf = io.BytesIO() + doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm, + topMargin=16 * mm, bottomMargin=16 * mm, + title=ch.get("name") or "Character", author=ch.get("player") or "") + st = getSampleStyleSheet() + h1 = ParagraphStyle("h1", parent=st["Heading1"], fontSize=22, spaceAfter=2) + h2 = ParagraphStyle("h2", parent=st["Heading2"], fontSize=12, spaceBefore=10, spaceAfter=4, + textColor=colors.HexColor("#2f5d3a")) + body = ParagraphStyle("b", parent=st["BodyText"], fontSize=9.5, leading=12.5) + + def esc(t): + return (t or "").replace("&", "&").replace("<", "<").replace(">", ">").replace("\n", "
") + + def tbl(head, rows, widths=None): + t = Table([head] + rows, colWidths=widths, repeatRows=1) + t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2f5d3a")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#b9c4bb")), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f1f5f2")]), + ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ])) + return t + + el = [Paragraph(esc(ch.get("name") or "Nameless"), h1), + Paragraph(f"Arcane Wasteland  |  Player: {esc(ch.get('player') or '-')}  |  " + f"Profession: {esc(s['profession'])}  |  Class: {esc(s['class'])}  |  Rules: {esc(s['preset'])}", body)] + if ch.get("concept"): + el.append(Paragraph(f"{esc(ch['concept'])}", body)) + + el += [Paragraph("Attributes", h2), tbl(["Attribute", "Value", "Mod"], s["attr_rows"], [80 * mm, 30 * mm, 30 * mm]), + Paragraph("Derived stats", h2), tbl(["Stat", ""], s["derived_rows"], [80 * mm, 30 * mm]), + Paragraph("Skills", h2)] + if s["skill_rows"]: + el.append(tbl(["Skill", "Attr.", "Points", "Training", "Target"], s["skill_rows"], + [60 * mm, 22 * mm, 22 * mm, 28 * mm, 25 * mm])) + else: + el.append(Paragraph("No skill points spent.", body)) + + el.append(Paragraph("Talents", h2)) + for t in s["talents"]: + el.append(Paragraph(f"{esc(t['name'])}: {esc(t['desc'])}", body)) + if not s["talents"]: + el.append(Paragraph("-", body)) + + el.append(Paragraph(f"Mutations and implants ({c['capacity_used']} / {c['capacity_max']} capacity)", h2)) + if s["augment_rows"]: + el.append(tbl(["Name", "Type", "Source", "Cost"], s["augment_rows"], [60 * mm, 30 * mm, 50 * mm, 22 * mm])) + else: + el.append(Paragraph("-", body)) + + el.append(Paragraph(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", h2)) + if s["gear_rows"]: + el.append(tbl(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [22 * mm, 65 * mm, 55 * mm, 22 * mm])) + else: + el.append(Paragraph("-", body)) + if s["other"]: + el.append(Paragraph(f"Other: {esc(s['other'])}", body)) + for key, head in (("background", "Background"), ("notes", "Notes")): + if ch.get(key): + el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)] + + doc.build(el) + return buf.getvalue() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..514d069 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,193 @@ +import json +import os +import re +import secrets +import sqlite3 +import time +import uuid +from pathlib import Path + +from fastapi import Body, FastAPI, Header, HTTPException, Response +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from .export import to_docx, to_pdf + +BASE = Path(__file__).resolve().parent.parent +RULES_PATH = Path(os.environ.get("AW_RULES", BASE / "data" / "rules.json")) +DB_PATH = os.environ.get("AW_DB", str(BASE / "aw.db")) +STATIC = Path(os.environ.get("AW_STATIC", BASE / "static")) +MAX_BODY = 200_000 # bytes per character +MAX_PRESETS = 200 + +app = FastAPI(title="Arcane Wasteland Character Creator") + + +def load_rules() -> dict: + # Re-read on every request so rules.json can be edited without a restart. + return json.loads(RULES_PATH.read_text(encoding="utf-8")) + + +def db() -> sqlite3.Connection: + Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + con.execute("""CREATE TABLE IF NOT EXISTS characters ( + id TEXT PRIMARY KEY, token TEXT NOT NULL, data TEXT NOT NULL, + created REAL NOT NULL, updated REAL NOT NULL)""") + con.execute("""CREATE TABLE IF NOT EXISTS presets ( + id TEXT PRIMARY KEY, token TEXT NOT NULL, data TEXT NOT NULL, + created REAL NOT NULL, updated REAL NOT NULL)""") + return con + + +def custom_presets() -> list: + with db() as con: + rows = con.execute("SELECT id, data FROM presets ORDER BY created").fetchall() + return [{"id": r["id"], **json.loads(r["data"]), "custom": True} for r in rows] + + +def all_rules() -> dict: + """Rules from rules.json plus the presets created in the app.""" + r = load_rules() + r["presets"] = [{**p, "builtin": True} for p in r.get("presets", [])] + custom_presets() + return r + + +def clean_preset(body: dict, rules: dict) -> dict: + """Validates a preset: name, description and only known creation numbers within their limits.""" + name = str(body.get("name", "")).strip()[:60] + if not name: + raise HTTPException(422, "A preset needs a name") + tun = {t["key"]: t for t in rules.get("tunables", [])} + creation = {} + for k, v in (body.get("creation") or {}).items(): + if k not in tun: + raise HTTPException(422, f"Unknown value: {k}") + if isinstance(v, bool) or not isinstance(v, int) or not tun[k]["min"] <= v <= tun[k]["max"]: + raise HTTPException(422, f"Value out of range: {tun[k]['label']}") + if v != rules["creation"].get(k): + creation[k] = v + return {"name": name, "desc": str(body.get("desc", "")).strip()[:300], "creation": creation} + + +def check_size(ch: dict): + if len(json.dumps(ch)) > MAX_BODY: + raise HTTPException(413, "Character too large") + + +@app.get("/api/rules") +def get_rules(): + return all_rules() + + +@app.post("/api/presets") +def create_preset(body: dict = Body(...)): + data = clean_preset(body, load_rules()) + with db() as con: + if con.execute("SELECT COUNT(*) FROM presets").fetchone()[0] >= MAX_PRESETS: + raise HTTPException(409, "Too many presets on this server") + pid, token, now = "u-" + uuid.uuid4().hex[:8], secrets.token_urlsafe(16), time.time() + con.execute("INSERT INTO presets VALUES (?,?,?,?,?)", (pid, token, json.dumps(data), now, now)) + return {"id": pid, "token": token} + + +@app.put("/api/presets/{pid}") +def update_preset(pid: str, body: dict = Body(...), x_edit_token: str = Header(default="")): + data = clean_preset(body, load_rules()) + with db() as con: + row = con.execute("SELECT token FROM presets WHERE id=?", (pid,)).fetchone() + if not row: + raise HTTPException(404, "Preset not found") + if not secrets.compare_digest(row["token"], x_edit_token): + raise HTTPException(403, "No write access to this preset") + con.execute("UPDATE presets SET data=?, updated=? WHERE id=?", (json.dumps(data), time.time(), pid)) + return {"ok": True} + + +@app.delete("/api/presets/{pid}") +def delete_preset(pid: str, x_edit_token: str = Header(default="")): + with db() as con: + row = con.execute("SELECT token FROM presets WHERE id=?", (pid,)).fetchone() + if not row: + raise HTTPException(404, "Preset not found") + if not secrets.compare_digest(row["token"], x_edit_token): + raise HTTPException(403, "No write access to this preset") + con.execute("DELETE FROM presets WHERE id=?", (pid,)) + return {"ok": True} + + +@app.post("/api/characters") +def create_character(ch: dict = Body(...)): + check_size(ch) + cid, token, now = uuid.uuid4().hex[:12], secrets.token_urlsafe(16), time.time() + with db() as con: + con.execute("INSERT INTO characters VALUES (?,?,?,?,?)", (cid, token, json.dumps(ch), now, now)) + return {"id": cid, "token": token} + + +@app.get("/api/characters/{cid}") +def read_character(cid: str): + with db() as con: + row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone() + if not row: + raise HTTPException(404, "Character not found") + return json.loads(row["data"]) + + +@app.put("/api/characters/{cid}") +def update_character(cid: str, ch: dict = Body(...), x_edit_token: str = Header(default="")): + check_size(ch) + with db() as con: + row = con.execute("SELECT token FROM characters WHERE id=?", (cid,)).fetchone() + if not row: + raise HTTPException(404, "Character not found") + if not secrets.compare_digest(row["token"], x_edit_token): + raise HTTPException(403, "No write access to this character") + con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid)) + return {"ok": True} + + +@app.delete("/api/characters/{cid}") +def delete_character(cid: str, x_edit_token: str = Header(default="")): + with db() as con: + row = con.execute("SELECT token FROM characters WHERE id=?", (cid,)).fetchone() + if not row: + raise HTTPException(404, "Character not found") + if not secrets.compare_digest(row["token"], x_edit_token): + raise HTTPException(403, "No write access to this character") + con.execute("DELETE FROM characters WHERE id=?", (cid,)) + return {"ok": True} + + +def _filename(ch: dict, ext: str) -> str: + base = re.sub(r"[^A-Za-z0-9_-]+", "_", ch.get("name") or "Character").strip("_") or "Character" + return f"{base}.{ext}" + + +@app.post("/api/export/{fmt}") +def export(fmt: str, ch: dict = Body(...)): + check_size(ch) + rules = all_rules() + if fmt == "docx": + data = to_docx(ch, rules) + mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + elif fmt == "pdf": + data = to_pdf(ch, rules) + mime = "application/pdf" + else: + raise HTTPException(404, "Unknown format") + return Response(data, media_type=mime, + headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'}) + + +# Serve the built frontend with an SPA fallback +if STATIC.is_dir(): + app.mount("/assets", StaticFiles(directory=STATIC / "assets"), name="assets") + + @app.get("/{path:path}") + def spa(path: str): + f = (STATIC / path).resolve() + if path and f.is_file() and STATIC in f.parents: + return FileResponse(f) + return FileResponse(STATIC / "index.html") diff --git a/backend/data/rules.json b/backend/data/rules.json new file mode 100644 index 0000000..f45cabc --- /dev/null +++ b/backend/data/rules.json @@ -0,0 +1,611 @@ +{ + "meta": { + "name": "Arcane Wasteland", + "subtitle": "Tabletop rules", + "version": "0.3 (lists from the brainstorm document)", + "note": "Skills, professions, classes, talents, mutations, implants, conditions and equipment come from the brainstorm document. Entries with \"placeholder\": true are still open there and are flagged in the app. Edit this file and reload the page - no rebuild needed." + }, + + "creation": { + "attr_start": 25, + "attr_points": 90, + "attr_max": 45, + "attr_max_raise": 20, + "profession_attr_bonus": 5, + "skill_points_default": 150, + "skill_max_divisor": 2, + "advanced_unlock_cost": 10, + "free_proficiencies": 2, + "max_proficiency_pips": 2, + "talents_min": 1, + "talents_max": 2, + "proficiency_bonus": 10, + "expertise_bonus": 20, + "kit": { + "weapon_cats": [ + "melee", + "firearm" + ], + "armor_tiers": [ + "light" + ], + "resources_min": 1, + "resources_max": 2, + "tools": 1 + }, + "carry_per_strength_mod": 10 + }, + + "default_preset": "standard", + + "presets": [ + {"id": "standard", "name": "Standard", "desc": "The numbers from the brainstorm document.", "creation": {}}, + {"id": "gritty", "name": "Gritty survival", "desc": "Example preset: fewer points, one starting talent, harder to learn advanced skills.", "creation": {"attr_points": 70, "skill_points_default": 100, "talents_max": 1, "free_proficiencies": 1, "advanced_unlock_cost": 15}}, + {"id": "heroic", "name": "Heroic", "desc": "Example preset: more points, higher attribute cap, three starting talents.", "creation": {"attr_points": 110, "attr_max_raise": 25, "attr_max": 50, "skill_points_default": 200, "talents_max": 3, "free_proficiencies": 3, "advanced_unlock_cost": 5}}, + {"id": "oneshot", "name": "One-shot", "desc": "Example preset: quick, strong characters for a single session.", "creation": {"attr_points": 100, "skill_points_default": 120, "talents_max": 2, "free_proficiencies": 3, "advanced_unlock_cost": 0}} + ], + + "tunables": [ + {"key": "attr_start", "label": "Starting value of every attribute", "group": "Attributes", "min": 1, "max": 100}, + {"key": "attr_points", "label": "Attribute points to distribute", "group": "Attributes", "min": 0, "max": 500}, + {"key": "attr_max_raise", "label": "Maximum raise per attribute at creation", "group": "Attributes", "min": 0, "max": 100}, + {"key": "attr_max", "label": "Attribute cap at creation", "group": "Attributes", "min": 1, "max": 150}, + {"key": "profession_attr_bonus", "label": "Profession attribute bonus", "group": "Profession", "min": 0, "max": 50}, + {"key": "free_proficiencies", "label": "Free proficiency picks", "group": "Skills", "min": 0, "max": 20}, + {"key": "skill_points_default", "label": "Skill point pool", "group": "Skills", "min": 0, "max": 1000}, + {"key": "advanced_unlock_cost", "label": "Cost to unlock an advanced skill", "group": "Skills", "min": 0, "max": 100}, + {"key": "skill_max_divisor", "label": "Skill maximum = attribute divided by", "group": "Skills", "min": 1, "max": 10}, + {"key": "proficiency_bonus", "label": "Proficiency bonus", "group": "Skills", "min": 0, "max": 50}, + {"key": "expertise_bonus", "label": "Expertise bonus", "group": "Skills", "min": 0, "max": 100}, + {"key": "talents_min", "label": "Starting talents (minimum)", "group": "Talents", "min": 0, "max": 10}, + {"key": "talents_max", "label": "Starting talents (maximum)", "group": "Talents", "min": 0, "max": 10}, + {"key": "carry_per_strength_mod", "label": "Carry capacity in kg per Strength Mod", "group": "Equipment", "min": 1, "max": 100} + ], + + "attributes": [ + {"id": "strength", "name": "Strength", "short": "STR", "desc": "Raw physical power, melee damage, carrying and climbing."}, + {"id": "agility", "name": "Agility", "short": "AGI", "desc": "Reflexes, balance, stealth, dodging, firearms."}, + {"id": "vitality", "name": "Vitality", "short": "VIT", "desc": "Toughness, stamina and resistance to poison and disease."}, + {"id": "intellect", "name": "Intellect", "short": "INT", "desc": "Knowledge, technology, medicine and logical thinking."}, + {"id": "perception", "name": "Perception", "short": "PER", "desc": "Senses, alertness, tracking and aiming at range."}, + {"id": "charisma", "name": "Charisma", "short": "CHA", "desc": "Presence, persuasion and leadership."}, + {"id": "willpower", "name": "Willpower", "short": "WIL", "desc": "Mental strength, resistance to fear and stress."} + ], + + "derived": [ + {"id": "health", "name": "Health", "formula": ["vitality", "willpower"], "text": "Vitality Mod + Willpower Mod", "note": "Still open in the brainstorm: a level and a shield term."}, + {"id": "sanity", "name": "Sanity", "formula": ["willpower", "perception"], "text": "Willpower Mod + Perception Mod"}, + {"id": "augmentation", "name": "Augmentation Capacity", "formula": ["vitality", "intellect"], "text": "Vitality Mod + Intellect Mod", "note": "Shared pool for mutations and implants."}, + {"id": "defense", "name": "Defense", "formula": ["agility"], "text": "Agility Mod + armor/cover"}, + {"id": "initiative", "name": "Initiative", "formula": ["agility", "perception"], "text": "Agility Mod + Perception Mod (+1d20 in combat)"} + ], + + "skill_groups": [ + {"id": "combat", "name": "Combat"}, + {"id": "survival", "name": "Survival"}, + {"id": "social", "name": "Social"}, + {"id": "technical", "name": "Technical"}, + {"id": "knowledge", "name": "Knowledge & Mental"} + ], + + "skills": [ + {"id": "melee", "name": "Melee Weapons", "group": "combat", "attrs": ["strength"], "type": "basic", "desc": "General melee proficiency."}, + {"id": "bladed", "name": "Bladed Weapons", "group": "combat", "attrs": ["strength", "agility"], "type": "basic", "desc": "Knives, swords, machetes."}, + {"id": "blunt", "name": "Blunt Weapons", "group": "combat", "attrs": ["strength"], "type": "basic", "desc": "Clubs, hammers."}, + {"id": "polearms", "name": "Polearms", "group": "combat", "attrs": ["strength", "agility"], "type": "basic", "desc": "Spears, halberds, reach weapons."}, + {"id": "firearms", "name": "Firearms", "group": "combat", "attrs": ["agility", "perception"], "type": "basic", "desc": "Pistols, rifles, shotguns, most small arms."}, + {"id": "heavy", "name": "Heavy Weapons", "group": "combat", "attrs": ["strength", "agility"], "type": "advanced", "desc": "Machine guns, rocket launchers."}, + {"id": "throwing", "name": "Throwing", "group": "combat", "attrs": ["agility"], "type": "basic", "desc": "Grenades, javelins, thrown knives."}, + {"id": "unarmed", "name": "Unarmed Combat", "group": "combat", "attrs": ["strength", "agility"], "type": "basic", "desc": "Fists, grappling."}, + {"id": "scavenging", "name": "Scavenging", "group": "survival", "attrs": ["perception"], "type": "basic", "desc": "Finding useful items and resources."}, + {"id": "medicine", "name": "Medicine", "group": "survival", "attrs": ["intellect"], "type": "advanced", "desc": "Medical knowledge and surgery."}, + {"id": "firstaid", "name": "First Aid", "group": "survival", "attrs": ["intellect", "vitality"], "type": "basic", "desc": "Stabilizing and patching wounds."}, + {"id": "navigation", "name": "Navigation", "group": "survival", "attrs": ["intellect", "perception"], "type": "basic", "desc": "Maps, stars, landmarks."}, + {"id": "tracking", "name": "Tracking", "group": "survival", "attrs": ["perception"], "type": "basic", "desc": "Following trails and signs."}, + {"id": "animals", "name": "Animal Handling", "group": "survival", "attrs": ["charisma", "perception"], "type": "basic", "desc": "Training, calming and riding animals."}, + {"id": "stealth", "name": "Stealth", "group": "survival", "attrs": ["agility"], "type": "basic", "desc": "Moving unseen."}, + {"id": "climbing", "name": "Climbing", "group": "survival", "attrs": ["strength", "agility"], "type": "basic", "desc": "Scaling obstacles."}, + {"id": "swimming", "name": "Swimming", "group": "survival", "attrs": ["vitality", "strength"], "type": "basic", "desc": "Swimming and holding breath."}, + {"id": "foraging", "name": "Foraging", "group": "survival", "attrs": ["perception", "intellect"], "type": "basic", "desc": "Identifying edible plants and resources."}, + {"id": "trapping", "name": "Trapping", "group": "survival", "attrs": ["intellect", "agility"], "type": "advanced", "desc": "Setting and disarming traps."}, + {"id": "camouflage", "name": "Camouflage", "group": "survival", "attrs": ["agility", "perception"], "type": "basic", "desc": "Concealment in natural settings."}, + {"id": "weather", "name": "Weather Sense", "group": "survival", "attrs": ["perception"], "type": "basic", "desc": "Predicting short-term weather."}, + {"id": "persuasion", "name": "Persuasion", "group": "social", "attrs": ["charisma"], "type": "basic", "desc": "Convincing through logic or emotion."}, + {"id": "intimidation", "name": "Intimidation", "group": "social", "attrs": ["strength", "charisma"], "type": "basic", "desc": "Coercion through fear or strength."}, + {"id": "deception", "name": "Deception", "group": "social", "attrs": ["charisma"], "type": "basic", "desc": "Lies and trickery."}, + {"id": "leadership", "name": "Leadership", "group": "social", "attrs": ["charisma", "willpower"], "type": "basic", "desc": "Inspiring and directing allies."}, + {"id": "negotiation", "name": "Negotiation", "group": "social", "attrs": ["charisma", "intellect"], "type": "basic", "desc": "Bartering and diplomacy."}, + {"id": "hacking", "name": "Hacking", "group": "technical", "attrs": ["intellect"], "type": "advanced", "desc": "Breaching digital systems."}, + {"id": "electronics", "name": "Electronics", "group": "technical", "attrs": ["intellect"], "type": "advanced", "desc": "Repairing circuits and devices."}, + {"id": "mechanics", "name": "Mechanics", "group": "technical", "attrs": ["intellect"], "type": "basic", "desc": "Repairing and maintaining machines."}, + {"id": "engineering", "name": "Engineering", "group": "technical", "attrs": ["intellect"], "type": "advanced", "desc": "Complex designs, structures, inventions."}, + {"id": "demolitions", "name": "Demolitions", "group": "technical", "attrs": ["intellect", "perception"], "type": "advanced", "desc": "Setting up and disposing of explosives."}, + {"id": "chemistry", "name": "Chemistry", "group": "technical", "attrs": ["intellect"], "type": "advanced", "desc": "Compounds, fuels, medicines."}, + {"id": "cybernetics", "name": "Cybernetics", "group": "technical", "attrs": ["intellect"], "type": "advanced", "desc": "Understanding and repairing implants."}, + {"id": "programming", "name": "Programming", "group": "technical", "attrs": ["intellect"], "type": "advanced", "desc": "Writing and altering software."}, + {"id": "lockpicking", "name": "Lockpicking", "group": "technical", "attrs": ["agility", "perception"], "type": "basic", "desc": "Mechanical and electronic locks."}, + {"id": "drones", "name": "Drone Operation", "group": "technical", "attrs": ["intellect", "agility"], "type": "advanced", "desc": "Controlling and repairing drones."}, + {"id": "history", "name": "History", "group": "knowledge", "attrs": ["intellect"], "type": "advanced", "desc": "Past events."}, + {"id": "occult", "name": "Occult Knowledge", "group": "knowledge", "attrs": ["intellect", "willpower"], "type": "advanced", "desc": "Strange powers and Noxium lore."}, + {"id": "science", "name": "Science", "group": "knowledge", "attrs": ["intellect"], "type": "advanced", "desc": "Physics, chemistry, biology."}, + {"id": "tactics", "name": "Tactics", "group": "knowledge", "attrs": ["intellect", "perception"], "type": "advanced", "desc": "Battlefield strategy."}, + {"id": "resolve", "name": "Resolve", "group": "knowledge", "attrs": ["willpower"], "type": "basic", "desc": "Mental endurance, resisting fear."}, + {"id": "concentration", "name": "Concentration", "group": "knowledge", "attrs": ["willpower"], "type": "basic", "desc": "Focus under stress."}, + {"id": "linguistics", "name": "Linguistics", "group": "knowledge", "attrs": ["intellect"], "type": "advanced", "desc": "Old languages and codes."}, + {"id": "survivallore", "name": "Survival Lore", "group": "knowledge", "attrs": ["intellect", "perception"], "type": "basic", "desc": "Edible plants and hazards."}, + {"id": "cartography", "name": "Cartography", "group": "knowledge", "attrs": ["intellect"], "type": "advanced", "desc": "Map-making and terrain data."}, + {"id": "jury", "name": "Jury-Rigging", "group": "knowledge", "attrs": ["intellect"], "type": "basic", "desc": "Improvised repairs under pressure."}, + {"id": "psychology", "name": "Psychology", "group": "knowledge", "attrs": ["intellect", "charisma"], "type": "advanced", "desc": "Motives and mental state."} + ], + + "professions": [ + {"id": "civilian", "name": "Civilian", "desc": "+5 to one attribute of your choice and proficiency in two basic skills of your choice.", "bonus_attrs": [], "proficiencies": [], "picks": 2}, + {"id": "scavenger", "name": "Scavenger", "desc": "+5 Perception, proficiency in Scavenging and Stealth.", "bonus_attrs": ["perception"], "proficiencies": ["scavenging", "stealth"], "picks": 0}, + {"id": "soldier", "name": "Soldier", "desc": "+5 Agility or Strength, proficiency in Firearms and Melee Weapons.", "bonus_attrs": ["agility", "strength"], "proficiencies": ["firearms", "melee"], "picks": 0, "note": "The brainstorm says \"Might\" here; taken as Strength."}, + {"id": "technician", "name": "Technician", "desc": "+5 Intellect, proficiency in Mechanics and Electronics.", "bonus_attrs": ["intellect"], "proficiencies": ["mechanics", "electronics"], "picks": 0}, + {"id": "medic", "name": "Medic", "desc": "+5 Intellect or Vitality, proficiency in First Aid and Medicine.", "bonus_attrs": ["intellect", "vitality"], "proficiencies": ["firstaid", "medicine"], "picks": 0}, + {"id": "diplomat", "name": "Diplomat", "desc": "+5 Charisma, proficiency in Persuasion and Negotiation.", "bonus_attrs": ["charisma"], "proficiencies": ["persuasion", "negotiation"], "picks": 0} + ], + + "classes": [ + {"id": "warrior", "name": "Warrior", "subclasses": [{"id": "berserker", "name": "Berserker", "desc": "Melee and raw strength, mutation synergies."}, {"id": "guardian", "name": "Guardian", "desc": "Defense and protecting allies."}, {"id": "slayer", "name": "Slayer", "desc": "Balanced melee and ranged."}]}, + {"id": "shooter", "name": "Shooter", "subclasses": [{"id": "gunslinger", "name": "Gunslinger", "desc": "Fast and mobile, bonus-action attacks."}, {"id": "marksman", "name": "Marksman", "desc": "Precision, high critical chance."}, {"id": "heavygunner", "name": "Heavy Gunner", "desc": "Heavy weapons, suppression fire."}]}, + {"id": "tech", "name": "Tech", "subclasses": [{"id": "engineer", "name": "Engineer", "desc": "Mechanics, electronics, building."}, {"id": "hacker", "name": "Hacker", "desc": "Digital infiltration, Diesium interfaces."}, {"id": "operator", "name": "Operator", "desc": "Combat support, drones and automation."}]}, + {"id": "mutant", "name": "Mutant", "subclasses": [{"id": "adapted", "name": "Adapted", "desc": "Lower Sanity cost for mutations."}, {"id": "symbiote", "name": "Symbiote", "desc": "Noxium/Diesium hybrid, powerful but unstable."}, {"id": "behemoth", "name": "Behemoth", "desc": "Mutation-heavy brute."}]}, + {"id": "leader", "name": "Leader", "subclasses": [{"id": "commander", "name": "Commander", "desc": "Inspires allies, Leadership buffs."}, {"id": "diplomat_c", "name": "Diplomat", "desc": "Negotiation and alliances."}, {"id": "tactician", "name": "Tactician", "desc": "Battlefield awareness, initiative and positioning."}]}, + {"id": "scholar", "name": "Scholar", "subclasses": [{"id": "scientist", "name": "Scientist", "desc": "Science and Medicine."}, {"id": "occultist", "name": "Occultist", "desc": "Noxium phenomena, ritual powers."}, {"id": "archivist", "name": "Archivist", "desc": "Lore and lost secrets."}]} + ], + + "talent_categories": [ + {"id": "combat", "name": "Combat"}, + {"id": "survival", "name": "Survival"}, + {"id": "technical", "name": "Technical"}, + {"id": "social", "name": "Social"}, + {"id": "knowledge", "name": "Knowledge & Mental"}, + {"id": "synergy", "name": "Mutation / Implant Synergy"} + ], + + "talents": [ + {"id": "quick_reflexes", "name": "Quick Reflexes", "category": "combat", "desc": "Adds Agility and Perception modifiers to Initiative."}, + {"id": "deadeye", "name": "Deadeye", "category": "combat", "desc": "Reroll one missed firearm attack per combat."}, + {"id": "brutal_strikes", "name": "Brutal Strikes", "category": "combat", "desc": "Bonus damage with blunt or polearm weapons."}, + {"id": "dual_wielder", "name": "Dual Wielder", "category": "combat", "desc": "Attack with two light weapons as a bonus action."}, + {"id": "protector", "name": "Guardian", "category": "combat", "desc": "Take an attack meant for an adjacent ally."}, + {"id": "opportunist", "name": "Opportunist", "category": "combat", "desc": "One extra reaction per round, capped by Agility modifier."}, + {"id": "surefooted", "name": "Surefooted", "category": "combat", "desc": "Ignore rough-terrain movement penalties."}, + {"id": "field_medic", "name": "Field Medic", "category": "survival", "desc": "Perform First Aid as a bonus action."}, + {"id": "scav_eye", "name": "Scavenger's Eye", "category": "survival", "desc": "Bonus to Scavenging rolls."}, + {"id": "beast_whisperer", "name": "Beast Whisperer", "category": "survival", "desc": "Bonus to Animal Handling; animals less likely to attack."}, + {"id": "stalker", "name": "Stalker", "category": "survival", "desc": "Bonus to Stealth when moving slowly."}, + {"id": "pathfinder", "name": "Pathfinder", "category": "survival", "desc": "Allies traveling with you ignore Navigation penalties."}, + {"id": "tinkerer", "name": "Tinkerer", "category": "technical", "desc": "Attempt Mechanics and Electronics without full tools."}, + {"id": "hackers_instinct", "name": "Hacker's Instinct", "category": "technical", "desc": "Bonus to Hacking; occasional reroll."}, + {"id": "engineers_touch", "name": "Engineer's Touch", "category": "technical", "desc": "Halves time for Engineering tasks."}, + {"id": "demolitionist", "name": "Demolitionist", "category": "technical", "desc": "Explosives you set deal extra damage."}, + {"id": "silver_tongue", "name": "Silver Tongue", "category": "social", "desc": "Bonus to Persuasion."}, + {"id": "intimidating", "name": "Intimidating Presence", "category": "social", "desc": "Nearby enemies must resist or suffer morale loss."}, + {"id": "negotiator", "name": "Negotiator", "category": "social", "desc": "Once per day, reroll a failed Negotiation."}, + {"id": "leaders_voice", "name": "Leader's Voice", "category": "social", "desc": "Nearby allies gain an attack bonus after a successful Leadership check."}, + {"id": "iron_will", "name": "Iron Will", "category": "knowledge", "desc": "Bonus to Resolve; resists fear and Sanity loss."}, + {"id": "battle_planner", "name": "Battle Planner", "category": "knowledge", "desc": "Bonus to Tactics; allies gain initiative bonus."}, + {"id": "scholar", "name": "Scholar", "category": "knowledge", "desc": "Permanent bonus to one Knowledge skill."}, + {"id": "focus_mastery", "name": "Focus Mastery", "category": "knowledge", "desc": "Spend a reaction to reroll a failed Concentration check."}, + {"id": "adaptive_flesh", "name": "Adaptive Flesh", "category": "synergy", "desc": "Reduces Mutation load by 1."}, + {"id": "cyber_integration", "name": "Cyber Integration", "category": "synergy", "desc": "Reduces Implant load by 1."}, + {"id": "hybrid_symbiosis", "name": "Hybrid Symbiosis", "category": "synergy", "desc": "Extra reaction per round with both Noxium and Diesium mutations."}, + {"id": "resilient_mind", "name": "Resilient Mind", "category": "synergy", "desc": "Reduces Sanity damage from mutations by 1."}, + {"id": "versatile_training", "name": "Versatile Training", "category": "knowledge", "desc": "Gain 2 additional proficiency picks (two skills, or Expertise in one).", "proficiency_picks": 2, "placeholder": true} + ], + + "augments": [ + {"id": "claws", "kind": "mutation", "source": "noxium", "name": "Claws", "cost": 1, "desc": "Gain natural melee attack (1d6 damage, counts as bladed)."}, + {"id": "regeneration", "kind": "mutation", "source": "noxium", "name": "Regeneration", "cost": 2, "desc": "Regain 1 HP per round for 3 rounds once per combat (1/day)."}, + {"id": "chitin", "kind": "mutation", "source": "noxium", "name": "Chitinous Armor", "cost": 2, "desc": "+1 Armor Value."}, + {"id": "nightvision", "kind": "mutation", "source": "noxium", "name": "Night Vision", "cost": 1, "desc": "See in total darkness up to 20 m."}, + {"id": "toxicspit", "kind": "mutation", "source": "noxium", "name": "Toxic Spit", "cost": 2, "desc": "Ranged attack, 1d6 acid damage, 5 m range."}, + {"id": "energychannel", "kind": "mutation", "source": "diesium", "name": "Energy Channel", "cost": 2, "desc": "Add +2 damage to Energy Weapons."}, + {"id": "reflexbooster", "kind": "mutation", "source": "diesium", "name": "Reflex Booster", "cost": 1, "desc": "+2 Initiative."}, + {"id": "reinforcedskin", "kind": "mutation", "source": "diesium", "name": "Reinforced Skin", "cost": 2, "desc": "+1 Armor Value."}, + {"id": "cyberclaw", "kind": "mutation", "source": "diesium", "name": "Cyber Claw", "cost": 2, "desc": "Natural melee attack (1d8 damage)."}, + {"id": "symbioticnode", "kind": "mutation", "source": "hybrid", "name": "Symbiotic Node", "cost": 3, "desc": "Once per combat, reroll a failed attack or defense."}, + {"id": "bioenergycannon", "kind": "mutation", "source": "hybrid", "name": "Bio-Energy Cannon", "cost": 4, "desc": "Ranged attack, 2d8 energy damage, 10 m range."}, + {"id": "cyberarm", "kind": "implant", "source": null, "name": "Cyber Arm", "cost": 2, "desc": "+2 damage with melee attacks, can crush objects."}, + {"id": "neurallink", "kind": "implant", "source": null, "name": "Neural Link", "cost": 2, "desc": "+10 to Hacking/Electronics; control drones."}, + {"id": "exospine", "kind": "implant", "source": null, "name": "Exo-Spine", "cost": 2, "desc": "+2 Strength Modifier for carrying/lifting."}, + {"id": "targetinghud", "kind": "implant", "source": null, "name": "Targeting HUD", "cost": 1, "desc": "+10 to Firearms checks."}, + {"id": "respirator", "kind": "implant", "source": null, "name": "Respirator Implant", "cost": 1, "desc": "Immune to toxic environments."}, + {"id": "dermalplating", "kind": "implant", "source": null, "name": "Dermal Plating", "cost": 2, "desc": "+1 Armor Value."}, + {"id": "adrenalbooster", "kind": "implant", "source": null, "name": "Adrenal Booster", "cost": 2, "desc": "Once per combat, gain +2 Initiative and 1 extra Bonus Action."}, + {"id": "energycore", "kind": "implant", "source": null, "name": "Energy Core", "cost": 3, "desc": "Powers other implants; may explode if overloaded."} + ], + + "conditions": [ + {"id": "fatigue", "name": "Fatigue", "levels": ["-5 to rolls, -1 Initiative.", "-10 to rolls, -2 Initiative.", "Incapacitated."], "removal": "Removed by Long Rest in safe shelter."}, + {"id": "bleeding", "name": "Bleeding", "levels": ["Lose 1 HP/round.", "Lose 2 HP/round.", "Unconscious in 1d4 rounds."], "removal": "Stabilize with First Aid or Medical Supplies."}, + {"id": "stress", "name": "Stress", "levels": ["-5 to Concentration/Resolve.", "-10 to Concentration/Resolve.", "Panic (must flee/freeze)."], "removal": "Reduced by Long Rest or Leadership/Persuasion."}, + {"id": "contamination", "name": "Contamination", "levels": ["Minor mutation risk.", "Roll Willpower/Vitality or gain mutation.", "Severe mutation or sanity collapse."], "removal": "Medical aid, Noxium Antidote, GM event."}, + {"id": "infection", "name": "Infection", "levels": ["-5 to physical rolls.", "-10, daily HP loss.", "Deadly, HP halved until cured."], "removal": "Medicine skill or antibiotics."}, + {"id": "exhaustion", "name": "Exhaustion", "levels": ["Half movement.", "Cannot recover HP on Short Rests.", "Collapse, only crawl."], "removal": "Two consecutive Long Rests."} + ], + + "equipment_categories": [ + {"id": "melee", "name": "Melee Weapons", "stat": "Damage"}, + {"id": "firearm", "name": "Firearms", "stat": "Damage"}, + {"id": "heavy", "name": "Heavy Weapons", "stat": "Damage"}, + {"id": "energy", "name": "Energy Weapons", "stat": "Damage"}, + {"id": "thrown", "name": "Thrown Weapons", "stat": "Damage"}, + {"id": "armor", "name": "Armor", "stat": "Armor"}, + {"id": "resource", "name": "Resources & Supplies", "stat": "Type"}, + {"id": "ammo", "name": "Ammunition", "stat": "Used with"}, + {"id": "tool", "name": "Tools & Gear", "stat": "Type"} + ], + + "equipment": [ + {"id": "knife", "name": "Knife", "cat": "melee", "stat": "1d4", "kg": 0.5, "note": ""}, + {"id": "machete", "name": "Machete", "cat": "melee", "stat": "1d6", "kg": 1, "note": ""}, + {"id": "axe", "name": "Axe", "cat": "melee", "stat": "1d8", "kg": 2, "note": ""}, + {"id": "club", "name": "Club", "cat": "melee", "stat": "1d6", "kg": 1.5, "note": ""}, + {"id": "spear", "name": "Spear", "cat": "melee", "stat": "1d8", "kg": 2.5, "note": "Reach, can be thrown."}, + {"id": "improvised", "name": "Improvised Tool (pipe, wrench, etc.)", "cat": "melee", "stat": "1d4-1d6", "kg": 3, "note": "Weighs 1-3 kg."}, + {"id": "shockbaton", "name": "Shock Baton", "cat": "melee", "stat": "1d6 + shock", "kg": 1.5, "note": "Non-lethal option."}, + {"id": "vibroblade", "name": "Vibroblade", "cat": "melee", "stat": "1d8 + bleed", "kg": 1.2, "note": "Extra bleeding on critical hits."}, + {"id": "pistol", "name": "Pistol", "cat": "firearm", "stat": "2d6", "kg": 1, "note": ""}, + {"id": "smg", "name": "SMG", "cat": "firearm", "stat": "2d6 (burst)", "kg": 2.5, "note": ""}, + {"id": "rifle", "name": "Rifle", "cat": "firearm", "stat": "2d8", "kg": 3.5, "note": "Two-handed."}, + {"id": "shotgun", "name": "Shotgun", "cat": "firearm", "stat": "2d10", "kg": 3, "note": ""}, + {"id": "machinegun", "name": "Machine Gun", "cat": "heavy", "stat": "3d8", "kg": 10, "note": ""}, + {"id": "rocketlauncher", "name": "Rocket Launcher", "cat": "heavy", "stat": "4d10 (area)", "kg": 12, "note": ""}, + {"id": "flamethrower", "name": "Flamethrower", "cat": "heavy", "stat": "2d8 per round", "kg": 8, "note": ""}, + {"id": "plasmathrower", "name": "Plasmathrower", "cat": "heavy", "stat": "3d10 per burst", "kg": 9, "note": "Can overheat."}, + {"id": "railpistol", "name": "Rail Pistol", "cat": "energy", "stat": "2d8", "kg": 3.5, "note": ""}, + {"id": "railgun", "name": "Railgun (Sniper)", "cat": "energy", "stat": "3d10", "kg": 9, "note": ""}, + {"id": "laserrifle", "name": "Laser Rifle", "cat": "energy", "stat": "2d8", "kg": 4, "note": "Overheats on sustained use."}, + {"id": "laserpistol", "name": "Laser Pistol", "cat": "energy", "stat": "2d6", "kg": 2.5, "note": ""}, + {"id": "particlepistol", "name": "Particle Pistol", "cat": "energy", "stat": "2d6", "kg": 2, "note": ""}, + {"id": "plasmacutter", "name": "Plasma Cutter", "cat": "energy", "stat": "2d10", "kg": 5, "note": ""}, + {"id": "tesla", "name": "Tesla Projector", "cat": "energy", "stat": "2d8 (cone)", "kg": 6, "note": ""}, + {"id": "grenade", "name": "Grenade", "cat": "thrown", "stat": "3d6 (area)", "kg": 0.5, "note": "Frag, Stun and Flash types are mentioned in the document."}, + {"id": "molotov", "name": "Molotov", "cat": "thrown", "stat": "2d6 + fire", "kg": 0.5, "note": ""}, + {"id": "javelin", "name": "Javelin", "cat": "thrown", "stat": "1d8", "kg": 2, "note": ""}, + {"id": "cloth", "name": "Cloth / Layered Clothing", "cat": "armor", "tier": "light", "stat": "+0", "kg": 1, "note": ""}, + {"id": "leather", "name": "Leather Jacket / Padded", "cat": "armor", "tier": "light", "stat": "+1", "kg": 2, "note": ""}, + {"id": "scraparmor", "name": "Scrap Armor", "cat": "armor", "tier": "light", "stat": "+2", "kg": 3, "note": ""}, + {"id": "kevlar", "name": "Kevlar Vest", "cat": "armor", "tier": "medium", "stat": "+3", "kg": 5, "note": ""}, + {"id": "tactical", "name": "Tactical Armor", "cat": "armor", "tier": "medium", "stat": "+4", "kg": 7, "note": ""}, + {"id": "reactive", "name": "Reactive Plating", "cat": "armor", "tier": "medium", "stat": "+4", "kg": 8, "note": "Medium/Advanced. Chance to absorb one hit."}, + {"id": "nanoweave", "name": "Nano-weave Suit", "cat": "armor", "tier": "medium", "stat": "+4", "kg": 6, "note": ""}, + {"id": "adaptive", "name": "Adaptive Nano-Fabric", "cat": "armor", "tier": "advanced", "stat": "+5", "kg": 6, "note": "Needs power."}, + {"id": "riotgear", "name": "Riot Gear", "cat": "armor", "tier": "heavy", "stat": "+5", "kg": 12, "note": ""}, + {"id": "platecarrier", "name": "Ballistic Plate Carrier", "cat": "armor", "tier": "heavy", "stat": "+6", "kg": 14, "note": ""}, + {"id": "exosuit", "name": "Military Exosuit", "cat": "armor", "tier": "heavy", "stat": "+7", "kg": 25, "note": "Heavy/Powered, needs fuel/power. The equipment table in the document says +6 and about 20 kg; the armor table's values are used here."}, + {"id": "shieldgen", "name": "Personal Shield Generator", "cat": "armor", "tier": "special", "stat": "Variable", "kg": 4, "note": "Consumes Energy Cells."}, + {"id": "rations", "name": "Rations & Water", "cat": "resource", "stat": "Consumable", "kg": 1, "note": "About 1 kg per day."}, + {"id": "fuel", "name": "Fuel", "cat": "resource", "stat": "Resource", "kg": 5, "note": "About 2-5 kg."}, + {"id": "ammo_ballistic", "name": "Ammunition (Ballistic)", "cat": "resource", "stat": "Resource", "kg": 2, "note": "About 0.5-2 kg."}, + {"id": "ammo_energy", "name": "Ammunition (Energy Cells)", "cat": "resource", "stat": "Resource", "kg": 2, "note": "About 0.5-2 kg."}, + {"id": "specialty_rounds", "name": "Specialty Rounds", "cat": "resource", "stat": "Resource", "kg": 1, "note": "About 0.5-1 kg."}, + {"id": "medical", "name": "Medical Supplies", "cat": "resource", "stat": "Consumable", "kg": 2, "note": "Bandages, antibiotics, stimpacks. About 0.5-2 kg."}, + {"id": "noxium_antidote", "name": "Noxium Antidote", "cat": "resource", "stat": "Consumable", "kg": 0.5, "note": ""}, + {"id": "crafting", "name": "Crafting Materials", "cat": "resource", "stat": "Resource", "kg": 5, "note": "Scrap metal, electronics, chemicals. About 1-5 kg."}, + {"id": "noxium_crystals", "name": "Noxium Crystals", "cat": "resource", "stat": "Exotic resource", "kg": 2, "note": "Used for mutations, dangerous to carry. About 0.5-2 kg."}, + {"id": "diesium_components", "name": "Diesium Tech Components", "cat": "resource", "stat": "Resource", "kg": 4, "note": "Implant fuel and advanced crafting. About 1-4 kg."}, + {"id": "am_standard", "name": "Standard Rounds", "cat": "ammo", "stat": "Firearms", "kg": 0.5, "note": "30 shots - Common."}, + {"id": "am_ap", "name": "Armor-Piercing (AP)", "cat": "ammo", "stat": "Firearms, Railgun", "kg": 0.5, "note": "20 shots - Uncommon."}, + {"id": "am_hp", "name": "Hollow-Point", "cat": "ammo", "stat": "Firearms", "kg": 0.5, "note": "20 shots - Uncommon."}, + {"id": "am_incendiary", "name": "Incendiary Rounds", "cat": "ammo", "stat": "Firearms, Shotgun", "kg": 0.5, "note": "15 shots - Rare."}, + {"id": "am_explosive", "name": "Explosive Rounds", "cat": "ammo", "stat": "Firearms, Heavy", "kg": 1, "note": "10 shots - Rare."}, + {"id": "am_tracer", "name": "Tracer Rounds", "cat": "ammo", "stat": "Firearms, Machine Gun", "kg": 0.5, "note": "30 shots - Common."}, + {"id": "am_railslug", "name": "Rail Slugs", "cat": "ammo", "stat": "Rail weapons", "kg": 1, "note": "10 shots - Uncommon."}, + {"id": "am_penetrator", "name": "Penetrator Slugs", "cat": "ammo", "stat": "Rail weapons", "kg": 1, "note": "5 shots - Rare."}, + {"id": "am_plasma", "name": "Plasma Canisters", "cat": "ammo", "stat": "Plasma weapons", "kg": 2, "note": "5 shots - Very Rare."}, + {"id": "am_fuel", "name": "Fuel Tanks", "cat": "ammo", "stat": "Flamethrower", "kg": 5, "note": "5 shots - Uncommon."}, + {"id": "am_tesla", "name": "Tesla Capacitors", "cat": "ammo", "stat": "Tesla Projector", "kg": 1, "note": "5 shots - Rare."}, + {"id": "am_cells", "name": "Energy Cells (Standard)", "cat": "ammo", "stat": "Laser, Particle", "kg": 0.5, "note": "10 shots - Common."}, + {"id": "am_cells_hc", "name": "High-Cap Energy Cells", "cat": "ammo", "stat": "Laser, Particle, Rail", "kg": 1, "note": "15 shots - Uncommon."}, + {"id": "detectors", "name": "Detectors & Scanners", "cat": "tool", "stat": "Tool", "kg": 3, "note": "About 1-3 kg."}, + {"id": "lockpicks", "name": "Lockpicks", "cat": "tool", "stat": "Tool", "kg": 0, "note": "Negligible weight."}, + {"id": "mechkit", "name": "Mechanic Kit", "cat": "tool", "stat": "Tool", "kg": 5, "note": ""}, + {"id": "hackdevice", "name": "Hacking Device", "cat": "tool", "stat": "Tool", "kg": 2, "note": ""} + ], + + "sections": [ + { + "id": "basics", + "title": "Core Principle", + "blocks": [ + { + "p": "Arcane Wasteland is set about 113 years after a meteorite struck and brought two materials: Noxium and Diesium. Both heavily change humans and animals. The game uses a d100." + }, + { + "h": "The check" + }, + { + "p": "Roll a d100. The check succeeds if the result is less than or equal to Attribute + Skill (± situational modifiers)." + }, + { + "list": [ + "01-05: critical success", + "96-100: critical failure", + "Complex actions may require multiple checks." + ] + } + ] + }, + { + "id": "difficulty", + "title": "Difficulty Levels", + "blocks": [ + { + "p": "The GM picks the difficulty; the modifier is added to the target number." + }, + { + "table": { + "head": [ + "Level", + "Modifier" + ], + "rows": [ + [ + "Trivial", + "+60" + ], + [ + "Elementary", + "+50" + ], + [ + "Easy", + "+30" + ], + [ + "Routine", + "+20" + ], + [ + "Ordinary", + "+10" + ], + [ + "Challenging", + "±0" + ], + [ + "Difficult", + "-10" + ], + [ + "Hard", + "-20" + ], + [ + "Very Hard", + "-30" + ], + [ + "Arduous", + "-40" + ], + [ + "Punishing", + "-50" + ], + [ + "Hellish", + "-60" + ] + ] + } + } + ] + }, + { + "id": "attributes", + "title": "Attributes and Modifiers", + "blocks": [ + { + "p": "There are seven attributes. An attribute's modifier (Mod) is its value divided by 10, rounded down. The maximum at character creation is 45. Talents, implants or mutations can raise it." + }, + { + "attributes": true + } + ] + }, + { + "id": "derived", + "title": "Derived Stats", + "blocks": [ + { + "derived": true + } + ] + }, + { + "id": "skills", + "title": "Skills", + "blocks": [ + { + "p": "Skill value + attribute give the target number of a check. At creation a skill may be at most half the attribute (rounded down). As a cap in play, without talents or story rewards, the limit is attribute x 2." + }, + { + "list": [ + "Basic skills can be attempted untrained (only the attribute counts).", + "Advanced skills must be unlocked first by spending 10 points of the skill pool (free if your profession grants proficiency in them). Once unlocked they are raised like any other skill.", + "Proficiency: +10. Expertise: +20.", + "Every character gets 2 free proficiency picks on top of the profession's proficiencies. A talent can grant 2 more.", + "Putting two proficiency picks into the same skill makes it Expertise (a profession proficiency counts as one of the two).", + "Proficiency in an advanced skill unlocks it for free.", + "Groups: Combat, Survival, Social, Technical, Knowledge & Mental." + ] + }, + { + "p": "Skills listing two attributes (e.g. STR/AGI): the higher of the two counts. This is not defined in the brainstorm and is an assumption of the app.", + "placeholder": true + }, + { + "p": "The free proficiency picks, the Versatile Training talent and the way Expertise is earned are house rules of the app, not part of the brainstorm. They can be changed in rules.json (creation.free_proficiencies, talents).", + "placeholder": true + }, + { + "p": "The unlock cost for advanced skills (10 points) is a house rule of the app, not part of the brainstorm. It can be changed in rules.json (creation.advanced_unlock_cost).", + "placeholder": true + }, + { + "skilllist": true + } + ] + }, + { + "id": "creation", + "title": "Character Creation", + "blocks": [ + { + "list": [ + "All attributes start at 25. Distribute 90 points (at most +20 on one attribute, maximum 45).", + "Derive modifiers and derived stats.", + "Spend the skill point pool (the GM sets its size, e.g. 150).", + "Choose a profession: +5 to one attribute and 2-3 proficiencies.", + "Spend your 2 free proficiency picks on any skills (two on one skill = Expertise).", + "Choose 1-2 starting talents.", + "Starting gear: one weapon (melee or firearm), light armor or clothing, 1-2 resources and one tool or utility item." + ] + }, + { + "p": "The numbers above are the Standard preset. In the character creator the GM or group can pick another rules preset (or change single values); it only changes the creation numbers such as point pools, caps and talent counts. Presets are not part of the brainstorm; they are defined in rules.json.", + "placeholder": true + }, + { + "professions": true + }, + { + "h": "Classes and sub-classes" + }, + { + "classes": true + } + ] + }, + { + "id": "combat", + "title": "Combat", + "blocks": [ + { + "list": [ + "Initiative: 1d20 + Agility Mod or Perception Mod.", + "Attacks are rolled against attribute + skill.", + "Dodge reduces the chance to be hit only if the target has a Reaction available.", + "Damage reduction: flat or relative, chosen by the GM. With flat reduction at least 1 damage goes through.", + "Base movement: 6 m per turn.", + "Per turn: 1 Normal Action, 1 Bonus Action, and Reactions up to the Perception Mod, capped by the Agility Mod." + ] + }, + { + "h": "Noise" + }, + { + "p": "Loud actions (especially firearms) can attract enemies, mostly in cities. Melee and stealth are deliberately favored." + } + ] + }, + { + "id": "rest", + "title": "Rest and Recovery", + "blocks": [ + { + "table": { + "head": [ + "Rest", + "Duration", + "Recovery" + ], + "rows": [ + [ + "Short rest", + "about 1 hour", + "1d4 + Vitality Mod HP. A Willpower check may restore 1 Sanity." + ], + [ + "Long rest", + "6-8 hours, safe shelter", + "Full HP, 1d6 + Willpower Mod Sanity, some conditions are removed." + ], + [ + "Unsafe rest", + "any", + "Half recovery, plus a risk of contamination or an encounter." + ] + ] + } + } + ] + }, + { + "id": "conditions", + "title": "Conditions", + "blocks": [ + { + "p": "Most conditions have severity levels I to III that stack. How different conditions combine is not yet settled in the brainstorm.", + "placeholder": true + }, + { + "conditions": true + } + ] + }, + { + "id": "augments", + "title": "Mutations and Implants", + "blocks": [ + { + "p": "Mutations (Noxium = flesh, Diesium = tech, or Hybrid) and implants (Diesium or pre-meteor cybernetics) share one capacity pool, the Augmentation Capacity. Mutations cost 1-4, implants 1-3. Exceeding the capacity risks corruption or rejection." + }, + { + "augmentlist": true + } + ] + }, + { + "id": "talents", + "title": "Talents", + "blocks": [ + { + "p": "Talents are grouped in six categories: Combat, Survival, Technical, Social, Knowledge & Mental, and Mutation/Implant Synergy." + }, + { + "talentlist": true + } + ] + }, + { + "id": "equipment", + "title": "Equipment", + "blocks": [ + { + "list": [ + "Starting gear: 1 weapon (melee or firearm), light armor or clothing, 1-2 resources (e.g. rations, medical supplies), 1 toolkit or utility item.", + "Carry capacity: Strength Mod x 10 kg." + ] + }, + { + "p": "The brainstorm writes \"Might Modifier\" for the carry capacity (taken as Strength) and also mentions a slot-based inventory without weight. Both approaches are shown in the app; weights are approximate, and for ranges the upper value is used.", + "placeholder": true + }, + { + "equipment": true + } + ] + }, + { + "id": "progression", + "title": "Progression", + "blocks": [ + { + "p": "Experience points (XP) are earned from encounters, survival and exploration. Attributes rise slowly. Mutations and implants are acquired, not leveled." + }, + { + "p": "Open question: level-based like D&D, or freely spendable XP like DSA and Rogue Trader.", + "placeholder": true + } + ] + } + ] +} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8c6ce78 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +python-docx>=1.1 +reportlab>=4.2 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..edb9dc2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + awcc: + build: . + restart: unless-stopped + ports: + - "8080:8000" # Host-Port bei Bedarf ändern, oder hinter einen Reverse Proxy hängen + volumes: + - ./db:/db # SQLite-Datenbank (Charaktere) + - ./backend/data:/app/data:ro # rules.json: bearbeiten, Seite neu laden, fertig diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7a70ba2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Arcane Wasteland Character Sheet + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..10a2423 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1698 @@ +{ + "name": "aw-character-creator", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aw-character-creator", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.10.tgz", + "integrity": "sha512-oJ9ZfVIZjSjts0ZIYA48+5T0vurE5fQfX/Liad2U52Y6EN99x/OCdjGGuKMDP6jSk2i2lY6jwwA3X0drEc4PBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.10.tgz", + "integrity": "sha512-LylBINgYYwgHNz8OMCy5+Y0w/LEgRYE0PcAIZfEuTuLNMf0S40GSLX6SFwqZsBLplS7RIHHy4zOAbl8Y5cQEvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.10.tgz", + "integrity": "sha512-vbuT3VT1GBI2fAOoUJUrsHVoOg9nbrFXTM7e3DSnb9PYf4n6jZCN8v4Vo8O6x4u5B67+/c803b8BtCZe2nIeqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.10.tgz", + "integrity": "sha512-qYUp/qYvM3T4cF+VWalPl+przBUESILxi+qZ1m1MVRHpg06vS+jHaxXubjCaRpXjLJPDFdXzOZ0FABDghHOhNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.10.tgz", + "integrity": "sha512-v9NRQG+JztbP4DP/0yuhSyDcEJk047lYbqeNnqIgbXITcVDjGDS7OpkW5nadSY3tJ7MY8W1qIMMqP4Q3Q8BDjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.10.tgz", + "integrity": "sha512-CHc9LBHdxhn6/NXfb9IegXCWoz5VlvcDOx9QrzZ9q+XYhFlfzwymSWJjOlZcEMMtC1zgMV7qxDe6U18AI1SG0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.10.tgz", + "integrity": "sha512-ble2WR7kWfzZFJUd3ODMGCJn1XDszptfRC0QvWc0h9/EA6MxnrQ1mvbjPZifEdgHyfHhDrwWeKeJONDITcrHSg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.10.tgz", + "integrity": "sha512-DMCYegihxBMKvLpODdk4OsvIgxtHTi+Oddf1Pm3d6wMVNjcBiG2pqyXjMPy55HgG1gCdEFr9VfhB1CmvuKn1lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.10.tgz", + "integrity": "sha512-HQ6HuVH9TmhvPWIas7MA/KeAROgdwqH3REpUU6hZq47NW7m1b1QCrHgM+M3+N/B8DfMt88x1/z79ppRc9RflCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.10.tgz", + "integrity": "sha512-avBhxba1DdctX0kxKkN3JWdTSg72QsxSn6FdfhZ9MkL3TTYDOXqxY1GqbC7VNttlgviK7tCe2No5btfRkFgeIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.10.tgz", + "integrity": "sha512-UtQMex7fiXq0W/GqcWRpy6XKqUbXgQeGKB+Bd5rJngmHzpI+3kjwpWFLAhKg02onFsIMSh+Zx0zp9JIMQ7/05A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.10.tgz", + "integrity": "sha512-0Aum2pUsCSuOopi6oka+nR1DTj/nfvc1xjL979lvh6kXUjNa30TCX7sKFjyMsrX7bK3bNVs0FsJwQcE2HCWX1Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.10.tgz", + "integrity": "sha512-4dgsouKal3qfMFORgtNigLPt7K3HXiO6jTr7+nFJv1GWu1OkZx0kvQIXbK4sjrIGnaLBk0s80eQCQ9hPIelLrw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.10.tgz", + "integrity": "sha512-d7NE89try3jisYzBD7J9M5iwfgcrXYC4dwHEjQ3HIWBftvwch1U/cy3iZ26SthtUOyClKiu7iqYiFjQEwiOlqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.10.tgz", + "integrity": "sha512-dqB5ChSBp91JWXxpCKAS5ZVAE38esKQvdfyqANm0lF5JPiQrXnYwgG3oVBQmuNRcQ1WHkA3j5qYLsrl2CtfhCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.10.tgz", + "integrity": "sha512-7GMf3vbQQslorWLmfsbi5fdt9RVnglk205XpOMFvW6UY5jE5oJc6JLieQJPHtsYlZ2EO0PtC06nY9Hz/IG8guw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.10.tgz", + "integrity": "sha512-tuKbkB23DKc5LG+irPSUnZZztRHqC3CNmyxeWloueBteHB+i+1HkRCMzAE41lseLDlarybeLhqfibFitKOxKMg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.10.tgz", + "integrity": "sha512-ntHtQOMw7oIshHRF1swV3vyBw+beQt9PeZ04O3D32EeDZUMoHMJuZKp4/fDllH/+xogeK0GUCONVfjjKusqKLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.10.tgz", + "integrity": "sha512-Dlh8q8r145aYgh+N3gNIG1dz35Fz/FCxe5hs/43orDsffjP8S1P4Xz5I4FpSJsDS4LpQw1QmswZkQlUbkCldjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.10.tgz", + "integrity": "sha512-VBJhHWz/55u45CWFv9OmfSRDgJDVENESgFxSAn13gIKJpTuFJbP4/7ymMZhDfeKFx1zGn1XBsLnNB7Ryy71qww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.10.tgz", + "integrity": "sha512-Vjcazg+EEzUfIz+LcaxS6YtbQHo+sv0sCD+5VbwhY504bCKNW4OOreVgKq61pHT2esRQ/+AXYVPStDwxSvAOdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.10.tgz", + "integrity": "sha512-jJAaP4El+HWxYkM0jeJghASaFgfnluY0FRHhyHXxU6STs/nBhKMIxIMDrJEFg2ftKz6orX9WAo4diVan5NugNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.10.tgz", + "integrity": "sha512-bXXdRUfRx9PaHCToKt8R/7wjDV6Tbk/hmSHK0uQBKmD3rj4jkuYndct8uGX77ZuZsePrVkQbWUgPR2jJhCTMpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.10.tgz", + "integrity": "sha512-57Idx+VkEOQeG6Ys6sZOBFT7FfbeyxGv9A3k1m+Lf/z2SwqT40rNWbAINzjPHSMoCCBvY7Fnb9RGZWQin/HHAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.10.tgz", + "integrity": "sha512-vrvuasIpm/xf+BpZbzgirku0WnrQZMAc+H2Ai26jzqBxYEmQQE4DdJb7h8erkJMfRRipx0ma2Cty8jJwUm9ocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", + "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.459", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", + "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", + "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.10", + "@rollup/rollup-android-arm64": "4.64.10", + "@rollup/rollup-darwin-arm64": "4.64.10", + "@rollup/rollup-darwin-x64": "4.64.10", + "@rollup/rollup-freebsd-arm64": "4.64.10", + "@rollup/rollup-freebsd-x64": "4.64.10", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.10", + "@rollup/rollup-linux-arm-musleabihf": "4.64.10", + "@rollup/rollup-linux-arm64-gnu": "4.64.10", + "@rollup/rollup-linux-arm64-musl": "4.64.10", + "@rollup/rollup-linux-loong64-gnu": "4.64.10", + "@rollup/rollup-linux-loong64-musl": "4.64.10", + "@rollup/rollup-linux-ppc64-gnu": "4.64.10", + "@rollup/rollup-linux-ppc64-musl": "4.64.10", + "@rollup/rollup-linux-riscv64-gnu": "4.64.10", + "@rollup/rollup-linux-riscv64-musl": "4.64.10", + "@rollup/rollup-linux-s390x-gnu": "4.64.10", + "@rollup/rollup-linux-x64-gnu": "4.64.10", + "@rollup/rollup-linux-x64-musl": "4.64.10", + "@rollup/rollup-openbsd-x64": "4.64.10", + "@rollup/rollup-openharmony-arm64": "4.64.10", + "@rollup/rollup-win32-arm64-msvc": "4.64.10", + "@rollup/rollup-win32-ia32-msvc": "4.64.10", + "@rollup/rollup-win32-x64-gnu": "4.64.10", + "@rollup/rollup-win32-x64-msvc": "4.64.10", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bc48f1b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "aw-character-creator", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..facb266 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react' +import Creator from './Creator.jsx' +import Rules from './Rules.jsx' +import Characters from './Characters.jsx' +import Presets from './Presets.jsx' + +const useHash = () => { + const [h, setH] = useState(window.location.hash.slice(1) || 'creator') + useEffect(() => { + const f = () => setH(window.location.hash.slice(1) || 'creator') + window.addEventListener('hashchange', f) + return () => window.removeEventListener('hashchange', f) + }, []) + return h +} + +export default function App() { + const hash = useHash() + const [rules, setRules] = useState(null) + const [error, setError] = useState('') + // Used to load a saved character into the creator + const [loadRequest, setLoadRequest] = useState(null) + + const loadRules = () => fetch('/api/rules').then((r) => r.json()).then(setRules).catch(() => setError('Could not load the rules.')) + useEffect(() => { loadRules() }, []) + + const page = hash.split('/')[0] + const nav = [['creator', 'Create character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']] + + return ( + <> +
+ + + +
+
+ {error &&

{error}

} + {!rules && !error &&

Loading rules …

} + {rules && page === 'creator' && setLoadRequest(null)} />} + {rules && page === 'rules' && } + {rules && page === 'presets' && } + {rules && page === 'characters' && ( + { setLoadRequest(entry); window.location.hash = 'creator' }} /> + )} +
+ + ) +} diff --git a/frontend/src/Characters.jsx b/frontend/src/Characters.jsx new file mode 100644 index 0000000..6eb623f --- /dev/null +++ b/frontend/src/Characters.jsx @@ -0,0 +1,50 @@ +import { useState } from 'react' + +const KEY = 'aw_chars' +export const loadList = () => { + try { return JSON.parse(localStorage.getItem(KEY)) || [] } catch { return [] } +} +export const saveList = (l) => { + try { localStorage.setItem(KEY, JSON.stringify(l)) } catch { /* storage blocked */ } +} +export const rememberCharacter = (id, token, name) => { + const l = loadList().filter((c) => c.id !== id) + l.unshift({ id, token, name: name || 'Nameless', updated: Date.now() }) + saveList(l) +} + +export default function Characters({ onOpen }) { + const [list, setList] = useState(loadList) + + const remove = async (c) => { + if (!window.confirm(`Really delete "${c.name}"?`)) return + await fetch(`/api/characters/${c.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': c.token } }).catch(() => {}) + const l = list.filter((x) => x.id !== c.id) + saveList(l); setList(l) + } + + return ( +
+

My characters

+

+ This list is stored in this browser. The characters themselves live on the server and can be shared by link + (#creator/<ID>), but only this browser holds the key needed to change them. +

+ {list.length === 0 &&

Nothing saved yet. Create a character and click "Save".

} +
    + {list.map((c) => ( +
  • +
    + {c.name} +
    ID {c.id} · {new Date(c.updated).toLocaleString('en-GB')}
    +
    +
    + + +
    +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/Creator.jsx b/frontend/src/Creator.jsx new file mode 100644 index 0000000..25fc960 --- /dev/null +++ b/frontend/src/Creator.jsx @@ -0,0 +1,523 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js' +import { rememberCharacter } from './Characters.jsx' +import SheetPanel from './SheetPanel.jsx' + +const DRAFT = 'aw_draft' +const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary'] +const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' } +const Badge = () => Placeholder + +function readDraft() { + try { return JSON.parse(localStorage.getItem(DRAFT)) } catch { return null } +} + +export default function Creator({ rules: baseRules, loadRequest, clearLoad }) { + const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard') + const [ch, setCh] = useState(() => { + const d = readDraft() + return d?.ch ? normalize(d.ch, baseRules) : freshCharacter() + }) + // `ch` is the working copy the pages edit; `committed` is the applied state shown as "before" in the side panel + const [committed, setCommitted] = useState(() => { + const d = readDraft() + const src = d?.committed || d?.ch + return src ? normalize(src, baseRules) : freshCharacter() + }) + const setBoth = (x) => { setCh(x); setCommitted(x) } + const pending = JSON.stringify(ch) !== JSON.stringify(committed) + const apply = () => setCommitted(ch) + const discard = () => setCh(committed) + const [saved, setSaved] = useState(() => readDraft()?.saved || null) // {id, token} + const [step, setStep] = useState(0) + const [msg, setMsg] = useState('') + const fileRef = useRef(null) + + // Rules for this character: base rules plus the creation numbers of the chosen preset + const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch.creation]) // eslint-disable-line + const c = useMemo(() => compute(ch, rules), [ch, rules]) + const prof = rules.professions.find((p) => p.id === ch.profession) + const cls = rules.classes.find((k) => k.id === ch.class_id) + const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id) + const set = (patch) => setCh((x) => ({ ...x, ...patch })) + const cr = rules.creation + + // Keep the draft in this browser + useEffect(() => { + try { localStorage.setItem(DRAFT, JSON.stringify({ ch, committed, saved })) } catch { /* ignore */ } + }, [ch, committed, saved]) + + // Load a character picked in "My characters" + useEffect(() => { + if (!loadRequest) return + fetch(`/api/characters/${loadRequest.id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }) + .then((data) => { setBoth(normalize(data, baseRules)); setSaved({ id: loadRequest.id, token: loadRequest.token }); setStep(7) }) + .catch(() => setMsg('Could not load the character.')) + .finally(clearLoad) + }, [loadRequest]) // eslint-disable-line + + // Direct link #creator/ (read only; saving creates a copy) + useEffect(() => { + const id = window.location.hash.split('/')[1] + if (!id || loadRequest) return + fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }) + .then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') }) + .catch(() => setMsg('Shared character not found.')) + }, []) // eslint-disable-line + + // Moving between pages: pending edits are applied first (Next applies silently, other jumps ask). + const go = (i, silent) => { + if (i === step) return + if (pending) { + if (!silent && !window.confirm('You have changes that are not applied yet.\nOK = apply them and continue, Cancel = stay on this page.')) return + apply() + } + setStep(Math.max(0, Math.min(STEPS.length - 1, i))) + } + + const flash = (t) => { setMsg(t); setTimeout(() => setMsg(''), 4000) } + + const resetAll = () => { + if (!window.confirm('Discard all input and start over?')) return + setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation }); setSaved(null); setStep(0) + } + const resetThisStep = () => { + if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return + setCh((x) => resetStep(x, rules, step)) + } + + const save = async () => { + try { + if (saved) { + const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Edit-Token': saved.token }, body: JSON.stringify(ch) }) + if (!r.ok) throw new Error() + rememberCharacter(saved.id, saved.token, ch.name) + flash('Saved.') + } else { + const r = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) }) + if (!r.ok) throw new Error() + const { id, token } = await r.json() + setSaved({ id, token }); rememberCharacter(id, token, ch.name) + flash('Saved. The share link is shown below.') + } + } catch { flash('Saving failed.') } + } + + const fileName = (ext) => `${(ch.name || 'Character').replace(/[^\w-]+/g, '_')}.${ext}` + const download = async (fmt) => { + const r = await fetch(`/api/export/${fmt}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) }) + if (!r.ok) return flash('Export failed.') + const url = URL.createObjectURL(await r.blob()) + const a = document.createElement('a'); a.href = url; a.download = fileName(fmt); a.click(); URL.revokeObjectURL(url) + } + const exportJson = () => { + const url = URL.createObjectURL(new Blob([JSON.stringify(ch, null, 2)], { type: 'application/json' })) + const a = document.createElement('a'); a.href = url; a.download = fileName('json'); a.click(); URL.revokeObjectURL(url) + } + const importJson = (e) => { + const f = e.target.files[0]; if (!f) return + f.text().then((t) => { setBoth(normalize(JSON.parse(t), baseRules)); setSaved(null); flash('Imported.') }).catch(() => flash('Invalid file.')) + e.target.value = '' + } + + // Click on pip k (1 = Proficient, 2 = Expertise) of a skill: sets the skill's total pips to k, or one lower if already there. + const clickPip = (s, k) => { + const target = s.pips >= k ? k - 1 : k + const wantFree = Math.max(0, target - s.profPip) + if (wantFree - s.freePips > c.profLeft) return flash('No proficiency picks left.') + set({ proficiency_picks: { ...ch.proficiency_picks, [s.id]: wantFree } }) + } + + const toggle = (key, id, max) => { + const cur = ch[key] + if (cur.includes(id)) return set({ [key]: cur.filter((x) => x !== id) }) + if (max && cur.length >= max) return flash(`At most ${max} can be chosen.`) + set({ [key]: [...cur, id] }) + } + + // ---------- Steps ---------- + const stepConcept = ( + <> +

Concept

+
+ +

{ch.preset === 'custom' ? 'Custom values (from an older version of this character).' : baseRules.presets?.find((p) => p.id === ch.preset)?.desc}

+ {(() => { + const diff = (baseRules.tunables || []).filter((x) => cr[x.key] !== baseRules.creation[x.key]) + return diff.length > 0 &&

Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}

+ })()} +

Create or edit presets on the Presets page.

+
+
+ + +
+
+ + +
+ {sub &&

{sub.desc}

} + +