first commit

This commit is contained in:
DuOtto
2026-10-19 22:27:05 +02:00
commit afbdc4b139
28 changed files with 4242 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
frontend/dist/
backend/static/
backend/__pycache__/
backend/app/__pycache__/
backend/aw.db
db/
+19
View File
@@ -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"]
+72
View File
@@ -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://<server>: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/<ID>` lets others view the character; only the holder of the key can change it. To move to another browser use the JSON backup (Summary page: "JSON backup" and "Import JSON").
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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+57
View File
@@ -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)}
+179
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br/>")
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 &nbsp;|&nbsp; Player: {esc(ch.get('player') or '-')} &nbsp;|&nbsp; "
f"Profession: {esc(s['profession'])} &nbsp;|&nbsp; Class: {esc(s['class'])} &nbsp;|&nbsp; Rules: {esc(s['preset'])}", body)]
if ch.get("concept"):
el.append(Paragraph(f"<i>{esc(ch['concept'])}</i>", 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"<b>{esc(t['name'])}</b>: {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"<b>Other:</b> {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()
+193
View File
@@ -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")
+611
View File
@@ -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
}
]
}
]
}
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.30
python-docx>=1.1
reportlab>=4.2
+9
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arcane Wasteland Character Sheet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1698
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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"
}
}
+55
View File
@@ -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 (
<>
<header className="top">
<a className="brand" href="#creator">
<span className="brand-mark" aria-hidden="true" />
<span>Arcane Wasteland</span>
</a>
<nav>
{nav.map(([id, label]) => (
<a key={id} href={`#${id}`} className={page === id ? 'active' : ''}>{label}</a>
))}
</nav>
</header>
<main>
{error && <p className="warn">{error}</p>}
{!rules && !error && <p className="muted">Loading rules …</p>}
{rules && page === 'creator' && <Creator rules={rules} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} />}
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
{rules && page === 'characters' && (
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
)}
</main>
</>
)
}
+50
View File
@@ -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 (
<section className="page narrow">
<h1>My characters</h1>
<p className="muted">
This list is stored in this browser. The characters themselves live on the server and can be shared by link
(<code>#creator/&lt;ID&gt;</code>), but only this browser holds the key needed to change them.
</p>
{list.length === 0 && <p className="card">Nothing saved yet. Create a character and click "Save".</p>}
<ul className="charlist">
{list.map((c) => (
<li key={c.id} className="card row">
<div>
<strong>{c.name}</strong>
<div className="muted small">ID {c.id} · {new Date(c.updated).toLocaleString('en-GB')}</div>
</div>
<div className="actions">
<button onClick={() => onOpen(c)}>Open</button>
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
</div>
</li>
))}
</ul>
</section>
)
}
+523
View File
@@ -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 = () => <span className="badge" title="Not yet worked out in the brainstorm">Placeholder</span>
function readDraft() {
try { return JSON.parse(localStorage.getItem(DRAFT)) } catch { return null }
}
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/<id> (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 = (
<>
<h2>Concept</h2>
<div className="card presetbox">
<label>Rules preset
<select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
{(baseRules.presets || []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
{ch.preset === 'custom' && <option value="custom">Custom</option>}
</select>
</label>
<p className="small muted">{ch.preset === 'custom' ? 'Custom values (from an older version of this character).' : baseRules.presets?.find((p) => p.id === ch.preset)?.desc}</p>
{(() => {
const diff = (baseRules.tunables || []).filter((x) => cr[x.key] !== baseRules.creation[x.key])
return diff.length > 0 && <p className="small muted">Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}</p>
})()}
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
</div>
<div className="grid2">
<label>Character name<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
<label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>
</div>
<div className="grid2">
<label>Class (optional)
<select value={ch.class_id} onChange={(e) => set({ class_id: e.target.value, subclass_id: '' })}>
<option value="">–</option>
{rules.classes.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
</label>
<label>Sub-class
<select value={ch.subclass_id} disabled={!ch.class_id} onChange={(e) => set({ subclass_id: e.target.value })}>
<option value="">–</option>
{(cls?.subclasses || []).map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
</select>
</label>
</div>
{sub && <p className="small muted">{sub.desc}</p>}
<label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label>
<label>Background<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
</>
)
const stepAttrs = (
<>
<h2>Attributes</h2>
<p className="muted">All attributes start at {cr.attr_start}. Distribute {cr.attr_points} points (at most +{cr.attr_max_raise} per attribute, cap {cr.attr_max}).</p>
<div className={`pool ${c.attrLeft < 0 ? 'over' : ''}`}>Points left: <strong>{c.attrLeft}</strong> / {cr.attr_points}</div>
<div className="attrs">
{rules.attributes.map((a) => {
const v = ch.attributes[a.id]
const cap = Math.min(cr.attr_max, cr.attr_start + cr.attr_max_raise)
const setV = (n) => set({ attributes: { ...ch.attributes, [a.id]: n } })
return (
<div key={a.id} className="attr card">
<div><strong>{a.name}</strong> <span className="muted small">{a.short}</span><div className="small muted">{a.desc}</div></div>
<div className="stepper">
<button onClick={() => setV(Math.max(v - 5, cr.attr_start))} aria-label={`${a.name} minus 5`}>−5</button>
<button onClick={() => setV(Math.max(v - 1, cr.attr_start))} aria-label={`${a.name} minus 1`}>−</button>
<span className="val">{v}</span>
<button onClick={() => setV(Math.min(v + 1, cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 1`}>+</button>
<button onClick={() => setV(Math.min(v + Math.min(5, c.attrLeft), cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 5`}>+5</button>
<span className="mod" title="Modifier">Mod {c.mods[a.id]}</span>
</div>
</div>
)
})}
</div>
</>
)
const stepProfession = (
<>
<h2>Profession</h2>
<p className="muted">Your profession gives +{cr.profession_attr_bonus} to one attribute and proficiencies (+{cr.proficiency_bonus} to the skill).</p>
<div className="cards">
{rules.professions.map((p) => (
<button key={p.id} className={`card choice ${ch.profession === p.id ? 'on' : ''}`} onClick={() => setCh(applyProfession(ch, rules, p.id))}>
<strong>{p.name}</strong> {p.placeholder && <Badge />}
<span className="small">{p.desc}</span>
{p.note && <span className="small muted">{p.note}</span>}
</button>
))}
</div>
{prof && (
<>
<label>+{cr.profession_attr_bonus} to:
<select value={ch.profession_bonus_attr} onChange={(e) => set({ profession_bonus_attr: e.target.value })}>
{prof.bonus_attrs.length !== 1 && <option value="">Please choose</option>}
{rules.attributes.filter((a) => !prof.bonus_attrs.length || prof.bonus_attrs.includes(a.id)).map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</label>
{prof.picks > 0 && (
<>
<h3>Choose {prof.picks} basic skills ({ch.profession_picks.length}/{prof.picks})</h3>
<div className="chips">
{rules.skills.filter((s) => s.type === 'basic').map((s) => {
const on = ch.profession_picks.includes(s.id)
const next = on ? ch.profession_picks.filter((x) => x !== s.id) : [...ch.profession_picks, s.id]
return (
<button key={s.id} className={`chip ${on ? 'on' : ''}`} disabled={!on && ch.profession_picks.length >= prof.picks}
onClick={() => setCh(setProfessionPicks(ch, rules, next))}>{s.name}</button>
)
})}
</div>
</>
)}
</>
)}
</>
)
const stepSkills = (
<>
<h2>Skills</h2>
<p className="muted">
At most half the attribute per skill. Advanced skills must be unlocked first for {c.unlockCost} points from the pool
(free if your profession grants them). The pool size comes from the rules preset.
</p>
<div className="pools">
<div className={`pool ${c.skillLeft < 0 ? 'over' : ''}`} title="Change the pool size in the rules preset on the Concept page">
Points left: <strong>{c.skillLeft}</strong> / {cr.skill_points_default}
</div>
<div className={`pool ${c.profLeft < 0 ? 'over' : ''}`} title="Two picks on the same skill make it Expertise">
Proficiency picks left: <strong>{c.profLeft}</strong> / {c.profAllowed}
</div>
</div>
<p className="small muted">
Proficiency adds +{cr.proficiency_bonus}. Two picks on the same skill make it Expertise (+{cr.expertise_bonus}); a profession proficiency counts as one of them.
Proficiency in an advanced skill unlocks it for free. The talent "Versatile Training" grants 2 more picks.
</p>
{rules.skill_groups.map((g) => (
<div key={g.id}>
<h3>{g.name}</h3>
<div className="skills">
{c.skills.filter((s) => s.group === g.id).map((s) => {
const setPts = (n) => set({ skill_points: { ...ch.skill_points, [s.id]: Math.max(0, Math.min(n, s.max)) } })
return (
<div key={s.id} className={`skill ${s.usable ? '' : 'locked'}`} title={s.desc}>
<div>
<strong>{s.name}</strong> {s.placeholder && <Badge />}
<div className="small muted">
{s.attrs.map((id) => rules.attributes.find((a) => a.id === id).short).join('/')} · {s.type === 'basic' ? 'Basic' : s.usable ? 'Advanced (unlocked)' : 'Advanced (locked)'}
{s.pips === 1 && ' · Proficient'}{s.pips === 2 && ' · Expertise'}
</div>
<div className="pips" title={`Proficiency: ${s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : 'none'}`}>
<span className="small muted">Training</span>
{[1, 2].map((k) => (
<button key={k} className={`pip ${s.pips >= k ? 'on' : ''} ${s.profPip >= k ? 'from-prof' : ''}`}
disabled={s.profPip >= k} onClick={() => clickPip(s, k)}
title={s.profPip >= k ? 'From your profession' : k === 2 ? 'Expertise (uses 2 picks in total)' : 'Proficiency'}
aria-label={`${s.name} proficiency ${k}`}>{s.pips >= k ? '◆' : '◇'}</button>
))}
</div>
</div>
{!s.usable ? (
<button className="unlock" onClick={() => set({ unlocked: [...ch.unlocked, s.id] })} disabled={c.skillLeft < c.unlockCost}
title={c.skillLeft < c.unlockCost ? 'Not enough points left' : 'Unlock this advanced skill'}>
Unlock ({c.unlockCost} pts)
</button>
) : (
<div className="stepper">
{s.type === 'advanced' && !s.free && (
<button className="ghost small-btn" title={`Lock again and get ${c.unlockCost} points back`}
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 } }))}>Lock</button>
)}
<button onClick={() => setPts(s.points - 1)} aria-label={`${s.name} minus`}>−</button>
<span className="val">{s.points}</span>
<button onClick={() => setPts(s.points + 1)} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
<span className="mod" title="Target number">{s.total}</span>
</div>
)}
</div>
)
})}
</div>
</div>
))}
</>
)
const stepTalents = (
<>
<h2>Talents</h2>
<p className="muted">Choose {cr.talents_min}-{cr.talents_max} starting talents. Chosen: {ch.talents.length}</p>
{rules.talent_categories.map((cat) => {
const ts = rules.talents.filter((t) => t.category === cat.id)
if (!ts.length) return null
return (
<div key={cat.id}><h3>{cat.name}</h3>
<div className="cards">{ts.map((t) => (
<button key={t.id} className={`card choice ${ch.talents.includes(t.id) ? 'on' : ''}`} onClick={() => toggle('talents', t.id, cr.talents_max)}>
<strong>{t.name}</strong> {t.placeholder && <Badge />}
<span className="small">{t.desc}</span>
</button>
))}</div>
</div>
)
})}
</>
)
const over = c.capacityUsed > c.capacityMax
const stepAugments = (
<>
<h2>Mutations and implants</h2>
<p className="muted">Optional. Both share the Augmentation Capacity. Exceeding it risks corruption or rejection.</p>
<div className={`pool ${over ? 'over' : ''}`}>Capacity: <strong>{c.capacityUsed}</strong> / {c.capacityMax}{over && ' – exceeded, talk to your GM'}</div>
{['mutation', 'implant'].map((kind) => (
<div key={kind}><h3>{kind === 'mutation' ? 'Mutations' : 'Implants'}</h3>
<div className="cards">{rules.augments.filter((a) => a.kind === kind).map((a) => (
<button key={a.id} className={`card choice ${ch.augments.includes(a.id) ? 'on' : ''}`} onClick={() => toggle('augments', a.id)}>
<strong>{a.name}</strong> {a.placeholder && <Badge />}
<span className="small muted">{SOURCE[a.source] ? `${SOURCE[a.source]} · ` : ''}Cost {a.cost}</span>
<span className="small">{a.desc}</span>
</button>
))}</div>
</div>
))}
</>
)
// --- Equipment ---
const kitRule = cr.kit
const items = rules.equipment
const catName = (id) => rules.equipment_categories.find((x) => x.id === id).name
const setKit = (patch) => set({ kit: { ...ch.kit, ...patch } })
const weaponItems = items.filter((e) => kitRule.weapon_cats.includes(e.cat))
const armorItems = items.filter((e) => e.cat === 'armor' && kitRule.armor_tiers.includes(e.tier))
const resourceItems = items.filter((e) => e.cat === 'resource')
const toolItems = items.filter((e) => e.cat === 'tool')
const ItemCard = ({ e, on, onClick, disabled }) => (
<button className={`card choice ${on ? 'on' : ''}`} onClick={onClick} disabled={disabled}>
<strong>{e.name}</strong>
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
{e.note && <span className="small">{e.note}</span>}
</button>
)
const SingleSlot = ({ title, list, slot }) => (
<>
<h3>{title}</h3>
<div className="cards">{list.map((e) => (
<ItemCard key={e.id} e={e} on={ch.kit[slot] === e.id} onClick={() => setKit({ [slot]: ch.kit[slot] === e.id ? '' : e.id })} />
))}</div>
</>
)
const setExtra = (id, qty) => {
const extras = { ...ch.kit.extras }
if (qty <= 0) delete extras[id]; else extras[id] = qty
setKit({ extras })
}
const overWeight = c.weight > c.carryMax
const stepGear = (
<>
<h2>Equipment</h2>
<p className="muted">Starting gear: one weapon (melee or firearm), light armor or clothing, {kitRule.resources_min}-{kitRule.resources_max} resources and one tool. Everything else can be added as extra items below.</p>
<div className={`pool ${overWeight ? 'over' : ''}`}>Carried: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
<SingleSlot title="Weapon" list={weaponItems} slot="weapon" />
<SingleSlot title="Armor or clothing" list={armorItems} slot="armor" />
<h3>Resources ({ch.kit.resources.length}/{kitRule.resources_max})</h3>
<div className="cards">{resourceItems.map((e) => {
const on = ch.kit.resources.includes(e.id)
return <ItemCard key={e.id} e={e} on={on} disabled={!on && ch.kit.resources.length >= kitRule.resources_max}
onClick={() => setKit({ resources: on ? ch.kit.resources.filter((x) => x !== e.id) : [...ch.kit.resources, e.id] })} />
})}</div>
<SingleSlot title="Tool or utility item" list={toolItems} slot="tool" />
<h3>Extra items</h3>
<p className="small muted">Anything else from the document's item lists. Ask your GM what you may take along.</p>
{rules.equipment_categories.map((cat) => (
<details key={cat.id}>
<summary>{cat.name} <span className="muted small">({items.filter((e) => e.cat === cat.id).reduce((n, e) => n + (ch.kit.extras[e.id] || 0), 0)} chosen)</span></summary>
<div className="extras">{items.filter((e) => e.cat === cat.id).map((e) => (
<div key={e.id} className="extra">
<div><strong>{e.name}</strong><div className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg{e.note ? ` · ${e.note}` : ''}</div></div>
<div className="stepper">
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) - 1)} aria-label={`${e.name} minus`}>−</button>
<span className="val">{ch.kit.extras[e.id] || 0}</span>
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) + 1)} aria-label={`${e.name} plus`}>+</button>
</div>
</div>
))}</div>
</details>
))}
<label>Other items (free text)<textarea rows="3" value={ch.gear.other} onChange={(e) => set({ gear: { ...ch.gear, other: e.target.value } })} placeholder="Anything not in the lists, e.g. a unique starter multi-tool" /></label>
<label>Notes<textarea rows="4" value={ch.notes} onChange={(e) => set({ notes: e.target.value })} /></label>
</>
)
const warnings = []
if (c.attrLeft < 0) warnings.push('Too many attribute points spent.')
if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`)
if (!ch.profession) warnings.push('No profession chosen.')
else {
if (!ch.profession_bonus_attr) warnings.push('No attribute chosen for the profession bonus.')
if ((prof?.picks || 0) > ch.profession_picks.length) warnings.push('Profession proficiencies not fully chosen.')
}
if (c.skillLeft < 0) warnings.push('More skill points spent than the pool allows.')
if (c.profLeft < 0) warnings.push('More proficiency picks used than you have.')
if (c.profLeft > 0) warnings.push(`${c.profLeft} proficiency pick${c.profLeft > 1 ? 's' : ''} left.`)
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
if (over) warnings.push('Augmentation Capacity exceeded.')
if (!ch.kit.weapon) warnings.push('No starting weapon chosen.')
if (!ch.kit.armor) warnings.push('No armor or clothing chosen.')
if (ch.kit.resources.length < kitRule.resources_min) warnings.push('No starting resource chosen.')
if (!ch.kit.tool) warnings.push('No tool or utility item chosen.')
if (overWeight) warnings.push('Carrying more than the carry capacity.')
const gearLines = kitItems(ch, rules)
const stepSummary = (
<>
<h2>Summary</h2>
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
<div className="sheet card">
<h3 className="sheet-name">{ch.name || 'Nameless'}</h3>
<p className="muted">{ch.player && `Player: ${ch.player} · `}Profession: {prof?.name || '–'}{cls && ` · Class: ${[cls.name, sub?.name].filter(Boolean).join(' / ')}`}</p>
<div className="sheet-cols">
<div>
<h4>Attributes</h4>
<table><tbody>{rules.attributes.map((a) => <tr key={a.id}><td>{a.name}</td><td>{c.attrs[a.id]}</td><td className="muted">Mod {c.mods[a.id]}</td></tr>)}</tbody></table>
<h4>Derived stats</h4>
<table><tbody>{rules.derived.map((d) => <tr key={d.id}><td>{d.name}</td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
</div>
<div>
<h4>Skills</h4>
<table><tbody>
{c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced')).map((s) => <tr key={s.id}><td>{s.name}</td><td>{s.total}</td><td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : ''}</td></tr>)}
{!c.skills.some((s) => s.points || s.prof || s.unlocked) && <tr><td className="muted">None</td></tr>}
</tbody></table>
<h4>Talents</h4>
<ul className="desclist">{rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => <li key={t.id}><strong>{t.name}</strong><div className="rowdesc">{t.desc}</div></li>)}</ul>
{!ch.talents.length && <p>–</p>}
<h4>Mutations / implants ({c.capacityUsed}/{c.capacityMax})</h4>
<ul className="desclist">{rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => <li key={a.id}><strong>{a.name}</strong> <span className="muted small">cost {a.cost}</span><div className="rowdesc">{a.desc}</div></li>)}</ul>
{!ch.augments.length && <p>–</p>}
<h4>Equipment ({c.weight}/{c.carryMax} kg)</h4>
<ul className="desclist">{gearLines.map((g) => <li key={g.item.id}><strong>{g.item.name}{g.qty > 1 ? ` x${g.qty}` : ''}</strong><div className="rowdesc">{[g.item.stat, `${Math.round(g.item.kg * g.qty * 10) / 10} kg`, g.item.note].filter(Boolean).join(' · ')}</div></li>)}</ul>
{!gearLines.length && <p>–</p>}
{ch.gear.other && <p className="small"><strong>Other:</strong> {ch.gear.other}</p>}
</div>
</div>
</div>
<div className="actions wrap">
<button onClick={() => download('pdf')}>Download as PDF</button>
<button onClick={() => download('docx')}>Download as Word</button>
<button className="ghost" onClick={save}>{saved ? 'Save changes' : 'Save on server'}</button>
<button className="ghost" onClick={exportJson}>JSON backup</button>
<button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button>
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
</div>
{saved && (
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
)}
</>
)
const views = [stepConcept, stepAttrs, stepProfession, stepSkills, stepTalents, stepAugments, stepGear, stepSummary]
return (
<div className="page creator">
<p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>
<ol className="steps">
{STEPS.map((s, i) => (
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
<span className="n">{i + 1}</span><span className="t">{s}</span></button></li>
))}
</ol>
<div className={`creator-body ${step === STEPS.length - 1 ? 'wide' : ''}`}>
<section className="step">
{views[step]}
{msg && <p className="toast" role="status">{msg}</p>}
<div className="nav">
<button className="ghost" onClick={() => go(step - 1)} disabled={step === 0}>Back</button>
<span className="resets">
{step < STEPS.length - 1 && <button className="ghost danger" onClick={resetThisStep}>Reset this page</button>}
<button className="ghost danger" onClick={resetAll}>Start over</button>
</span>
<button onClick={() => go(step + 1, true)} disabled={step === STEPS.length - 1}>{pending ? 'Apply & next' : 'Next'}</button>
</div>
</section>
{step < STEPS.length - 1 && <SheetPanel ch={ch} committed={committed} baseRules={baseRules} onApply={apply} onDiscard={discard} />}
</div>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { useState } from 'react'
const KEY = 'aw_presets' // {presetId: editToken}, only for presets created in this browser
const loadTokens = () => { try { return JSON.parse(localStorage.getItem(KEY)) || {} } catch { return {} } }
const saveTokens = (t) => { try { localStorage.setItem(KEY, JSON.stringify(t)) } catch { /* storage blocked */ } }
const BLANK = { id: null, name: '', desc: '', creation: {} }
export default function Presets({ rules, reload }) {
const [tokens, setTokens] = useState(loadTokens)
const [sel, setSel] = useState(rules.default_preset || 'standard')
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
const [msg, setMsg] = useState('')
const presets = rules.presets || []
const current = draft || presets.find((p) => p.id === sel) || presets[0]
const editable = !!draft
const own = !!current?.id && !!tokens[current.id]
const base = rules.creation
const groups = [...new Set((rules.tunables || []).map((x) => x.group))]
const value = (k) => (current.creation?.[k] ?? base[k])
const flash = (t) => { setMsg(t); setTimeout(() => setMsg(''), 4000) }
const startNew = (from) => setMsg('') || setDraft({ id: null, name: from ? `${from.name} (copy)` : '', desc: from?.desc || '', creation: { ...(from?.creation || {}) } })
const edit = () => setMsg('') || setDraft({ id: current.id, name: current.name, desc: current.desc || '', creation: { ...current.creation } })
const setVal = (x, raw) => {
const v = parseInt(raw, 10)
if (!Number.isFinite(v)) return
setDraft((d) => ({ ...d, creation: { ...d.creation, [x.key]: Math.max(x.min, Math.min(x.max, v)) } }))
}
const save = async () => {
const body = { name: draft.name, desc: draft.desc, creation: draft.creation }
if (!draft.name.trim()) return flash('Give the preset a name.')
try {
const res = await fetch(draft.id ? `/api/presets/${draft.id}` : '/api/presets', {
method: draft.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json', ...(draft.id ? { 'X-Edit-Token': tokens[draft.id] } : {}) },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Saving failed')
const data = await res.json()
let id = draft.id
if (!id) {
id = data.id
const t = { ...tokens, [id]: data.token }
saveTokens(t); setTokens(t)
}
await reload()
setSel(id); setDraft(null); flash('Preset saved.')
} catch (e) { flash(String(e.message || e)) }
}
const remove = async () => {
if (!window.confirm(`Delete the preset "${current.name}"? Characters that already use it keep their numbers.`)) return
try {
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': tokens[current.id] } })
if (!res.ok) throw new Error('Deleting failed')
const t = { ...tokens }; delete t[current.id]
saveTokens(t); setTokens(t)
await reload(); setSel(rules.default_preset || 'standard')
} catch (e) { flash(String(e.message || e)) }
}
const kind = (p) => (p.builtin ? 'Built in' : tokens[p.id] ? 'Yours' : 'Shared')
return (
<section className="page">
<h1>Rules presets</h1>
<p className="muted narrow">
A preset is a named set of creation numbers (points for attributes and skills, talents, proficiency ...). Pick one on the Concept page of the creator.
Presets are stored on the server, so everyone using this site can use them; only the browser that created a preset can change or delete it.
Built-in presets come from <code>rules.json</code>. Duplicate one to make your own version.
</p>
<div className="presets-layout">
<aside>
<ul className="presetlist">
{presets.map((p) => (
<li key={p.id}>
<button className={`card choice ${!draft && current?.id === p.id ? 'on' : ''}`} onClick={() => { setMsg(''); setDraft(null); setSel(p.id) }}>
<strong>{p.name}</strong>
<span className="small muted">{kind(p)}</span>
</button>
</li>
))}
</ul>
<button className="ghost" onClick={() => startNew(null)}>+ New preset</button>
</aside>
<div className="card">
{editable ? (
<>
<h2>{draft.id ? 'Edit preset' : 'New preset'}</h2>
<label>Name<input value={draft.name} maxLength={60} onChange={(e) => setDraft({ ...draft, name: e.target.value })} /></label>
<label>Description<input value={draft.desc} maxLength={300} onChange={(e) => setDraft({ ...draft, desc: e.target.value })} /></label>
</>
) : (
<>
<h2>{current.name} <span className="badge">{kind(current)}</span></h2>
<p className="muted">{current.desc || 'No description.'}</p>
</>
)}
{groups.map((g) => (
<div key={g}><h4>{g}</h4>
<div className="tunables">
{rules.tunables.filter((x) => x.group === g).map((x) => (
<label key={x.key}>{x.label}
<input type="number" min={x.min} max={x.max} value={value(x.key)} readOnly={!editable} disabled={!editable}
onChange={(e) => setVal(x, e.target.value)} />
{value(x.key) !== base[x.key] && <span className="small muted"> Standard: {base[x.key]}</span>}
</label>
))}
</div>
</div>
))}
{msg && <p className="toast">{msg}</p>}
<div className="actions wrap">
{editable ? (
<>
<button onClick={save}>Save preset</button>
<button className="ghost" onClick={() => setDraft(null)}>Cancel</button>
</>
) : (
<>
{own && <button onClick={edit}>Edit</button>}
<button className="ghost" onClick={() => startNew(current)}>Duplicate</button>
{own && <button className="ghost danger" onClick={remove}>Delete</button>}
</>
)}
</div>
</div>
</div>
</section>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react'
const Badge = () => <span className="badge" title="Not yet worked out in the brainstorm">Placeholder</span>
const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' }
function Block({ b, rules }) {
if (b.h) return <h3>{b.h}</h3>
if (b.p) return <p className={b.placeholder ? 'placeholder-text' : ''}>{b.p} {b.placeholder && <Badge />}</p>
if (b.list) return <ul>{b.list.map((x, i) => <li key={i}>{x}</li>)}</ul>
if (b.table) return (
<div className="tablewrap"><table>
<thead><tr>{b.table.head.map((h) => <th key={h}>{h}</th>)}</tr></thead>
<tbody>{b.table.rows.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}</tbody>
</table></div>
)
if (b.attributes) return (
<div className="tablewrap"><table>
<thead><tr><th>Attribute</th><th>Short</th><th>Meaning</th></tr></thead>
<tbody>{rules.attributes.map((a) => <tr key={a.id}><td>{a.name}</td><td>{a.short}</td><td>{a.desc}</td></tr>)}</tbody>
</table></div>
)
if (b.derived) return (
<div className="tablewrap"><table>
<thead><tr><th>Stat</th><th>Formula</th><th>Note</th></tr></thead>
<tbody>{rules.derived.map((d) => <tr key={d.id}><td>{d.name}</td><td>{d.text}</td><td>{d.note || ''}</td></tr>)}</tbody>
</table></div>
)
if (b.skilllist) return rules.skill_groups.map((g) => (
<div key={g.id}>
<h3>{g.name}</h3>
<div className="tablewrap"><table>
<thead><tr><th>Skill</th><th>Attribute</th><th>Type</th><th>Description</th></tr></thead>
<tbody>{rules.skills.filter((s) => s.group === g.id).map((s) => (
<tr key={s.id}><td>{s.name} {s.placeholder && <Badge />}</td>
<td>{s.attrs.map((id) => rules.attributes.find((a) => a.id === id).short).join(' / ')}</td>
<td>{s.type === 'basic' ? 'Basic' : 'Advanced'}</td>
<td>{s.desc}</td></tr>
))}</tbody>
</table></div>
</div>
))
if (b.professions) return (
<div className="cards">{rules.professions.map((p) => (
<div key={p.id} className="card">
<strong>{p.name}</strong> {p.placeholder && <Badge />}
<p className="small">{p.desc}</p>
{p.note && <p className="small muted">{p.note}</p>}
</div>
))}</div>
)
if (b.classes) return (
<div className="cards">{rules.classes.map((k) => (
<div key={k.id} className="card">
<strong>{k.name}</strong>
<ul className="small">{k.subclasses.map((x) => <li key={x.id}><strong>{x.name}</strong>: {x.desc}</li>)}</ul>
</div>
))}</div>
)
if (b.conditions) return (
<div className="tablewrap"><table>
<thead><tr><th>Condition</th><th>Level I</th><th>Level II</th><th>Level III</th><th>Removal</th></tr></thead>
<tbody>{rules.conditions.map((c) => (
<tr key={c.id}><td><strong>{c.name}</strong></td>{c.levels.map((l, i) => <td key={i}>{l}</td>)}<td>{c.removal}</td></tr>
))}</tbody>
</table></div>
)
if (b.augmentlist) return (
<div className="tablewrap"><table>
<thead><tr><th>Name</th><th>Type</th><th>Source</th><th>Cost</th><th>Effect</th></tr></thead>
<tbody>{rules.augments.map((a) => (
<tr key={a.id}><td>{a.name} {a.placeholder && <Badge />}</td><td>{a.kind === 'mutation' ? 'Mutation' : 'Implant'}</td>
<td>{SOURCE[a.source] || '–'}</td><td>{a.cost}</td><td>{a.desc}</td></tr>
))}</tbody>
</table></div>
)
if (b.talentlist) return rules.talent_categories.map((c) => {
const ts = rules.talents.filter((t) => t.category === c.id)
if (!ts.length) return null
return (
<div key={c.id}><h3>{c.name}</h3>
<ul>{ts.map((t) => <li key={t.id}><strong>{t.name}</strong> {t.placeholder && <Badge />}<br />{t.desc}</li>)}</ul>
</div>
)
})
if (b.equipment) return rules.equipment_categories.map((cat) => (
<div key={cat.id}>
<h3>{cat.name}</h3>
<div className="tablewrap"><table>
<thead><tr><th>Name</th><th>{cat.stat}</th><th>Weight</th><th>Notes</th></tr></thead>
<tbody>{rules.equipment.filter((e) => e.cat === cat.id).map((e) => (
<tr key={e.id}><td>{e.name}</td><td>{e.stat}{e.tier ? ` (${e.tier})` : ''}</td><td>{e.kg} kg</td><td>{e.note}</td></tr>
))}</tbody>
</table></div>
</div>
))
return null
}
// All text of a section, for the search
const sectionText = (s, rules) => JSON.stringify(s).toLowerCase() +
(s.blocks.some((b) => b.skilllist) ? rules.skills.map((x) => x.name + x.desc).join(' ').toLowerCase() : '') +
(s.blocks.some((b) => b.talentlist) ? rules.talents.map((x) => x.name + x.desc).join(' ').toLowerCase() : '') +
(s.blocks.some((b) => b.augmentlist) ? rules.augments.map((x) => x.name + x.desc).join(' ').toLowerCase() : '') +
(s.blocks.some((b) => b.equipment) ? rules.equipment.map((x) => x.name + x.note).join(' ').toLowerCase() : '') +
(s.blocks.some((b) => b.conditions) ? JSON.stringify(rules.conditions).toLowerCase() : '') +
(s.blocks.some((b) => b.classes) ? JSON.stringify(rules.classes).toLowerCase() : '')
export default function Rules({ rules, section }) {
const [q, setQ] = useState('')
const shown = useMemo(() => {
const t = q.trim().toLowerCase()
return t ? rules.sections.filter((s) => sectionText(s, rules).includes(t)) : rules.sections
}, [q, rules])
useEffect(() => {
if (section) document.getElementById(`sec-${section}`)?.scrollIntoView({ behavior: 'smooth' })
}, [section])
return (
<div className="page rules">
<aside>
<input type="search" placeholder="Search the rules …" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search the rules" />
<ul>{rules.sections.map((s) => (
<li key={s.id}><a href={`#rules/${s.id}`} className={shown.includes(s) ? '' : 'dim'}>{s.title}</a></li>
))}</ul>
</aside>
<div className="content">
<h1>{rules.meta.name} <span className="muted small">Rules {rules.meta.version}</span></h1>
{shown.length === 0 && <p className="card">No results.</p>}
{shown.map((s) => (
<section key={s.id} id={`sec-${s.id}`}>
<h2>{s.title}</h2>
{s.blocks.map((b, i) => <Block key={i} b={b} rules={rules} />)}
</section>
))}
</div>
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useMemo } from 'react'
import { countChanges, diffRows, sheetModel } from './sheetModel.js'
function Rows({ now, before, valueLabel }) {
const rows = diffRows(now, before)
if (!rows.length) return <p className="muted small">–</p>
return (
<table className="difftable"><tbody>
{rows.map((r) => (
<tr key={r.k} className={`d-${r.status}`}>
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
<td>
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && <span className="was">{r.old.val} → </span>}
{valueLabel ? valueLabel(r) : r.val}
</td>
{r.extra !== undefined && <td className="muted">{r.status === 'changed' && (r.old.extra || '') !== (r.extra || '') ? `${r.old.extra || '–'} → ${r.extra || '–'}` : r.extra}</td>}
</tr>
))}
</tbody></table>
)
}
// Always-visible character sheet next to the wizard; shows the pending changes against the applied state.
export default function SheetPanel({ ch, committed, baseRules, onApply, onDiscard }) {
const now = useMemo(() => sheetModel(ch, baseRules), [ch, baseRules])
const before = useMemo(() => sheetModel(committed, baseRules), [committed, baseRules])
const changes = countChanges(now, before)
const pending = changes > 0 || JSON.stringify(ch) !== JSON.stringify(committed)
const c = now.c
return (
<aside className="live card sheetpanel">
<h3 className="sheet-name">{committed.name !== ch.name && <s className="muted">{committed.name || 'Nameless'} </s>}{ch.name || 'Nameless'}</h3>
<div className={`applybar ${pending ? 'pending' : ''}`}>
{pending ? (
<>
<span className="small">{changes > 0 ? `${changes} change${changes > 1 ? 's' : ''} not applied yet` : 'Unapplied edits'}</span>
<span className="actions">
<button onClick={onApply}>Apply</button>
<button className="ghost" onClick={onDiscard}>Discard</button>
</span>
</>
) : <span className="small muted">All changes applied</span>}
</div>
<Rows now={now.head.filter((h) => h.k !== 'name')} before={before.head.filter((h) => h.k !== 'name')} />
<h4>Attributes</h4>
<Rows now={now.attrs} before={before.attrs} />
<h4>Derived</h4>
<Rows now={now.derived} before={before.derived} />
<h4>Skills</h4>
<Rows now={now.skills} before={before.skills} />
<h4>Talents</h4>
<Rows now={now.talents} before={before.talents} />
<h4>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></h4>
<Rows now={now.augments} before={before.augments} />
<h4>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></h4>
<Rows now={now.gear} before={before.gear} valueLabel={(r) => (r.val > 1 ? `x${r.val}` : '')} />
<h4>Points left</h4>
<div className="small muted">Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}</div>
</aside>
)
}
+187
View File
@@ -0,0 +1,187 @@
// Mirror of backend/app/derive.py: when formulas change, adjust both.
export const modOf = (v) => Math.floor(v / 10)
export function emptyCharacter(rules) {
const attributes = {}
rules.attributes.forEach((a) => (attributes[a.id] = rules.creation.attr_start))
return {
name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '',
attributes,
profession: '', profession_bonus_attr: '', profession_picks: [],
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
skill_points: {}, proficiencies: {}, proficiency_picks: {}, unlocked: [],
talents: [], augments: [],
kit: { weapon: '', armor: '', tool: '', resources: [], extras: {} },
gear: { other: '' },
notes: '',
}
}
// ---------- Rules presets ----------
export const effectiveRules = (rules, ch) => ({ ...rules, creation: { ...rules.creation, ...(ch.creation || {}) } })
const presetOverrides = (rules, id) => ({ ...(rules.presets?.find((p) => p.id === id)?.creation || {}) })
// Overrides that actually differ from the base values, as a comparable string.
const sig = (rules, creation) => JSON.stringify(Object.entries(creation).filter(([k, v]) => v !== rules.creation[k]).sort(([a], [b]) => a.localeCompare(b)))
// Which preset matches these values exactly ("custom" if none).
export function matchPreset(rules, creation) {
const s = sig(rules, creation)
return (rules.presets || []).find((p) => sig(rules, p.creation) === s)?.id || 'custom'
}
// After the creation numbers changed: re-base attributes to the new start value and keep everything within the new caps.
function reconcile(ch, oldRules, newRules) {
const o = oldRules.creation, n = newRules.creation
const cap = Math.min(n.attr_max, n.attr_start + n.attr_max_raise)
const attributes = {}
newRules.attributes.forEach((a) => {
const v = (ch.attributes[a.id] ?? o.attr_start) - o.attr_start + n.attr_start
attributes[a.id] = Math.max(n.attr_start, Math.min(cap, v))
})
const out = { ...ch, attributes }
const sp = { ...out.skill_points }
compute(out, newRules).skills.forEach((s) => { if (sp[s.id] > s.max) sp[s.id] = s.max })
return { ...out, skill_points: sp }
}
export function applyPreset(ch, rules, id) {
const next = { ...ch, preset: id, creation: presetOverrides(rules, id) }
return reconcile(next, effectiveRules(rules, ch), effectiveRules(rules, next))
}
export function setTunable(ch, rules, key, value) {
if (!Number.isFinite(value)) return ch
const creation = { ...ch.creation, [key]: value }
if (value === rules.creation[key]) delete creation[key]
const next = { ...ch, creation, preset: matchPreset(rules, creation) }
return reconcile(next, effectiveRules(rules, ch), effectiveRules(rules, next))
}
// Keeps old drafts and imports valid after the data model grew. `rules` are the base rules (without per-character overrides).
export function normalize(ch, rules) {
const preset = ch.preset || rules.default_preset || 'standard'
const creation = ch.creation ?? presetOverrides(rules, preset)
const base = emptyCharacter(effectiveRules(rules, { creation }))
const out = { ...base, ...ch, preset, creation, kit: { ...base.kit, ...(ch.kit || {}) }, gear: { ...base.gear, ...(ch.gear || {}) } }
if (typeof out.skill_pool === 'number') { // older characters stored the pool size on the character
if (out.skill_pool !== effectiveRules(rules, out).creation.skill_points_default) {
out.creation = { ...out.creation, skill_points_default: out.skill_pool }
out.preset = matchPreset(rules, out.creation)
}
}
delete out.skill_pool
// The preset may have been deleted since: fall back to whatever matches the numbers
if (out.preset !== 'custom' && !(rules.presets || []).some((x) => x.id === out.preset)) out.preset = matchPreset(rules, out.creation)
// Older characters: advanced skills that already hold points count as unlocked
const unlocked = new Set(out.unlocked)
rules.skills.forEach((s) => { if (s.type === 'advanced' && (out.skill_points[s.id] || 0) > 0) unlocked.add(s.id) })
return { ...out, unlocked: [...unlocked] }
}
// Clears points on advanced skills that are neither unlocked nor granted by a profession proficiency.
export function pruneLocked(ch, rules) {
const points = { ...ch.skill_points }
rules.skills.forEach((s) => {
if (s.type === 'advanced' && !ch.unlocked.includes(s.id) && !ch.proficiencies[s.id] && !(ch.proficiency_picks[s.id] > 0)) delete points[s.id]
})
return { ...ch, skill_points: points }
}
export function kitItems(ch, rules) {
const items = Object.fromEntries(rules.equipment.map((e) => [e.id, e]))
const k = ch.kit
const out = []
;[['Weapon', k.weapon], ['Armor', k.armor], ['Tool', k.tool]].forEach(([label, id]) => { if (items[id]) out.push({ label, item: items[id], qty: 1 }) })
k.resources.forEach((id) => { if (items[id]) out.push({ label: 'Resource', item: items[id], qty: 1 }) })
Object.entries(k.extras).forEach(([id, qty]) => { if (items[id] && qty > 0) out.push({ label: 'Extra', item: items[id], qty }) })
return out
}
export function compute(ch, rules) {
const cr = rules.creation
const bonusAttr = ch.profession ? ch.profession_bonus_attr : null
const attrs = {}
rules.attributes.forEach((a) => {
attrs[a.id] = (ch.attributes[a.id] ?? cr.attr_start) + (a.id === bonusAttr ? cr.profession_attr_bonus : 0)
})
const mods = Object.fromEntries(Object.entries(attrs).map(([k, v]) => [k, modOf(v)]))
const derived = Object.fromEntries(rules.derived.map((d) => [d.id, d.formula.reduce((s, x) => s + mods[x], 0)]))
const skills = rules.skills.map((s) => {
const points = ch.skill_points[s.id] || 0
// Proficiency "pips": 1 from the profession plus free picks, at most 2 (2 = Expertise)
const profPip = ch.proficiencies[s.id] ? 1 : 0
const freePips = Math.max(0, Math.min(ch.proficiency_picks[s.id] || 0, (cr.max_proficiency_pips ?? 2) - profPip))
const pips = profPip + freePips
const prof = [0, cr.proficiency_bonus, cr.expertise_bonus][pips]
const best = Math.max(...s.attrs.map((a) => attrs[a])) // with several attributes the higher one counts
const unlocked = ch.unlocked.includes(s.id)
return { ...s, points, prof, profPip, freePips, pips, unlocked, total: best + points + prof,
usable: s.type === 'basic' || unlocked || prof > 0, // advanced skills must be unlocked
free: s.type === 'advanced' && prof > 0, // unlocked by the profession, costs nothing
max: Math.floor(best / cr.skill_max_divisor) }
})
const profSpent = skills.reduce((s, x) => s + x.freePips, 0)
const profAllowed = (cr.free_proficiencies ?? 0) + rules.talents.filter((t) => ch.talents.includes(t.id)).reduce((s, t) => s + (t.proficiency_picks || 0), 0)
const unlockCost = cr.advanced_unlock_cost ?? 0
const paidUnlocks = skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.prof).length
const attrSpent = rules.attributes.reduce((s, a) => s + (ch.attributes[a.id] - cr.attr_start), 0)
const skillSpent = Object.values(ch.skill_points).reduce((s, v) => s + v, 0) + paidUnlocks * unlockCost
const chosen = rules.augments.filter((a) => ch.augments.includes(a.id))
const capacityUsed = chosen.reduce((s, a) => s + a.cost, 0)
const weight = Math.round(kitItems(ch, rules).reduce((s, x) => s + x.item.kg * x.qty, 0) * 10) / 10
return { attrs, mods, derived, skills, attrSpent, attrLeft: cr.attr_points - attrSpent,
skillSpent, skillLeft: cr.skill_points_default - skillSpent, unlockCost,
profSpent, profAllowed, profLeft: profAllowed - profSpent,
capacityUsed, capacityMax: derived.augmentation ?? 0,
weight, carryMax: Math.max(0, mods.strength) * (cr.carry_per_strength_mod ?? 10) }
}
// Removes all profession proficiencies (fixed and freely picked, "Proficient" level only) from the map.
function stripProfessionProfs(ch, rules) {
const prev = rules.professions.find((p) => p.id === ch.profession)
const prof = { ...ch.proficiencies }
const ids = [...(prev?.proficiencies || []), ...(ch.profession_picks || [])]
ids.forEach((id) => { if (prof[id]) delete prof[id] })
return prof
}
// Applies a profession choice: grants its fixed proficiencies (+10) and removes the previous profession's.
export function applyProfession(ch, rules, newId) {
const next = rules.professions.find((p) => p.id === newId)
const prof = stripProfessionProfs(ch, rules)
next?.proficiencies.forEach((id) => { if (!prof[id]) prof[id] = 1 })
const bonus = next?.bonus_attrs.length === 1 ? next.bonus_attrs[0] : (next?.bonus_attrs.includes(ch.profession_bonus_attr) ? ch.profession_bonus_attr : '')
return pruneLocked({ ...ch, profession: newId, profession_bonus_attr: bonus, profession_picks: [], proficiencies: prof }, rules)
}
// Freely chosen profession proficiencies (Civilian: 2 basic skills).
export function setProfessionPicks(ch, rules, picks) {
const prev = rules.professions.find((p) => p.id === ch.profession)
const prof = { ...ch.proficiencies }
;(ch.profession_picks || []).forEach((id) => {
if (prof[id] && !prev?.proficiencies.includes(id)) delete prof[id]
})
picks.forEach((id) => { if (!prof[id]) prof[id] = 1 })
return { ...ch, profession_picks: picks, proficiencies: prof }
}
// Resets only the data of one wizard step (0 concept, 1 attributes, 2 profession, 3 skills, 4 talents, 5 augments, 6 equipment).
export function resetStep(ch, rules, step) {
const e = emptyCharacter(rules)
switch (step) {
case 0: return { ...ch, name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '' }
case 1: return { ...ch, attributes: e.attributes }
case 2: return pruneLocked({ ...ch, proficiencies: stripProfessionProfs(ch, rules), profession: '', profession_bonus_attr: '', profession_picks: [] }, rules)
case 3: return { ...ch, skill_points: {}, unlocked: [], proficiency_picks: {} }
case 4: return { ...ch, talents: [] }
case 5: return { ...ch, augments: [] }
case 6: return { ...ch, kit: e.kit, gear: e.gear, notes: '' }
default: return ch
}
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(<App />)
+47
View File
@@ -0,0 +1,47 @@
import { compute, effectiveRules, kitItems } from './calc.js'
// Flat, comparable view of a character, used by the side panel to show what changed.
export function sheetModel(ch, baseRules) {
const rules = effectiveRules(baseRules, ch)
const c = compute(ch, rules)
const prof = rules.professions.find((p) => p.id === ch.profession)
const cls = rules.classes.find((k) => k.id === ch.class_id)
const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id)
const preset = ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name || 'Custom'
const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : s.type === 'advanced' && s.unlocked ? 'Unlocked' : '')
return {
c,
head: [
{ k: 'name', label: 'Name', val: ch.name || 'Nameless' },
{ k: 'profession', label: 'Profession', val: prof?.name || '–' },
{ k: 'class', label: 'Class', val: cls ? [cls.name, sub?.name].filter(Boolean).join(' / ') : '–' },
{ k: 'preset', label: 'Rules', val: preset },
],
attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}` })),
derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id] })),
skills: c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced'))
.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s) })),
talents: rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => ({ k: t.id, label: t.name, desc: t.desc })),
augments: rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => ({ k: a.id, label: a.name, desc: `${a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost ${a.cost}${a.desc ? ` · ${a.desc}` : ''}` })),
gear: kitItems(ch, rules).map((g) => ({ k: g.item.id, label: g.item.name, val: g.qty, desc: [g.item.stat, `${g.item.kg} kg`, g.item.note].filter(Boolean).join(' · ') })),
}
}
// Merges two row lists into one with a status per row: same | changed | added | removed.
export function diffRows(now, before) {
const old = new Map(before.map((r) => [r.k, r]))
const seen = new Set()
const out = now.map((r) => {
seen.add(r.k)
const o = old.get(r.k)
if (!o) return { ...r, status: 'added' }
return { ...r, old: o, status: o.val === r.val && (o.extra || '') === (r.extra || '') ? 'same' : 'changed' }
})
before.forEach((o) => { if (!seen.has(o.k)) out.push({ ...o, status: 'removed' }) })
return out
}
export const countChanges = (a, b) => {
const n = (rows, bef) => diffRows(rows, bef).filter((r) => r.status !== 'same').length
return n(a.head, b.head) + n(a.attrs, b.attrs) + n(a.skills, b.skills) + n(a.talents, b.talents) + n(a.augments, b.augments) + n(a.gear, b.gear)
}
+149
View File
@@ -0,0 +1,149 @@
:root {
--bg: #12150f; --panel: #1b2017; --panel2: #232a1e; --line: #343d2c;
--text: #e6e9de; --muted: #9aa58c; --noxium: #8fd14f; --diesium: #e0a43a; --danger: #e5604d;
--radius: 8px;
}
:root[data-theme="light"] { color-scheme: light; }
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f3f4ee; --panel: #ffffff; --panel2: #eaede1; --line: #cfd5c0; --text: #1c2117; --muted: #5d6a50;
--noxium: #3d7d12; --diesium: #a5680a; --danger: #b8341f;
}
}
* { box-sizing: border-box; }
html { font-size: 16px; }
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, "Segoe UI", Roboto, sans-serif; line-height: 1.5; }
h1, h2, h3, h4 { line-height: 1.2; font-family: "Bahnschrift", "Segoe UI", system-ui, sans-serif; letter-spacing: .02em; }
h1 { font-size: 1.8rem; margin: .2em 0 .6em; } h2 { font-size: 1.35rem; margin-top: 0; }
h3 { font-size: 1.05rem; color: var(--diesium); margin: 1.2em 0 .4em; } h4 { margin: 1em 0 .3em; color: var(--noxium); }
a { color: var(--noxium); }
code { background: var(--panel2); padding: .1em .35em; border-radius: 4px; word-break: break-all; }
.muted { color: var(--muted); } .small { font-size: .85rem; }
.top { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; align-items: center; justify-content: space-between; padding: .7rem 1rem; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 5; }
.brand { display: flex; align-items: center; gap: .6rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text); text-decoration: none; }
.brand-mark { width: 16px; height: 16px; background: linear-gradient(135deg, var(--noxium) 50%, var(--diesium) 50%); border-radius: 3px; transform: rotate(45deg); }
.top nav { display: flex; gap: .25rem; flex-wrap: wrap; }
.top nav a { padding: .4rem .8rem; border-radius: var(--radius); text-decoration: none; color: var(--muted); }
.top nav a:hover { color: var(--text); background: var(--panel2); }
.top nav a.active { color: var(--bg); background: var(--noxium); font-weight: 600; }
main { max-width: 1100px; margin: 0 auto; padding: 1rem; }
button { font: inherit; cursor: pointer; border: 1px solid var(--noxium); background: var(--noxium); color: #12150f; padding: .5rem 1rem; border-radius: var(--radius); font-weight: 600; }
button:hover:not(:disabled) { filter: brightness(1.1); }
button:disabled { opacity: .4; cursor: not-allowed; }
button.ghost { background: transparent; color: var(--text); border-color: var(--line); }
button.ghost:hover:not(:disabled) { border-color: var(--noxium); }
button.danger { color: var(--danger); }
:focus-visible { outline: 2px solid var(--diesium); outline-offset: 2px; }
input, textarea, select { font: inherit; width: 100%; background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius); padding: .5rem .7rem; }
input.inline { width: 5rem; display: inline-block; margin-left: .4rem; }
label { display: block; margin: .8rem 0; font-size: .9rem; color: var(--muted); }
label > input, label > textarea, label > select { margin-top: .25rem; color: var(--text); }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: .8rem 1rem; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: .7rem; }
button.card.choice { display: flex; flex-direction: column; gap: .3rem; text-align: left; font-weight: 400; color: var(--text); background: var(--panel); border-color: var(--line); }
button.card.choice.on { border-color: var(--noxium); box-shadow: inset 0 0 0 1px var(--noxium); background: var(--panel2); }
.badge { font-size: .65rem; text-transform: uppercase; letter-spacing: .06em; color: var(--diesium); border: 1px solid var(--diesium); border-radius: 4px; padding: 0 .35em; vertical-align: middle; white-space: nowrap; }
.placeholder-text { color: var(--muted); }
.steps { list-style: none; display: flex; gap: .3rem; padding: 0; margin: 0 0 1rem; overflow-x: auto; }
.steps button { background: var(--panel); border-color: var(--line); color: var(--muted); display: flex; align-items: center; gap: .5rem; font-weight: 500; white-space: nowrap; padding: .4rem .7rem; }
.steps .n { display: inline-grid; place-items: center; width: 1.4rem; height: 1.4rem; border-radius: 50%; border: 1px solid currentColor; font-size: .75rem; }
.steps button.done { color: var(--noxium); } .steps button.on { background: var(--noxium); color: #12150f; border-color: var(--noxium); }
.creator-body { display: grid; grid-template-columns: 1fr 240px; gap: 1rem; align-items: start; }
.live { position: sticky; top: 4.5rem; } .live h4 { margin-top: 0; }
table { width: 100%; border-collapse: collapse; } td, th { padding: .3rem .5rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: .85rem; }
.tablewrap { overflow-x: auto; margin-bottom: .6rem; }
.chips { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
.chip { background: var(--panel); color: var(--text); border-color: var(--line); font-weight: 400; padding: .25rem .7rem; border-radius: 999px; }
.chip.on { background: var(--noxium); color: #12150f; border-color: var(--noxium); font-weight: 600; }
.pool { display: inline-block; padding: .4rem .8rem; border: 1px solid var(--noxium); border-radius: var(--radius); margin: .4rem 0 1rem; }
.pool.over { border-color: var(--danger); color: var(--danger); }
.attrs, .skills { display: grid; gap: .5rem; }
.attr, .skill { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.skill { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: .5rem .8rem; }
.skill.locked { opacity: .75; }
.skills { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
.stepper { display: flex; align-items: center; gap: .3rem; }
.stepper button { padding: .25rem .6rem; min-width: 2.2rem; background: var(--panel2); color: var(--text); border-color: var(--line); }
.stepper .val { min-width: 2.2rem; text-align: center; font-weight: 700; font-size: 1.1rem; }
.stepper .mod { min-width: 4rem; text-align: right; color: var(--diesium); font-size: .9rem; }
.nav { display: flex; justify-content: space-between; gap: .5rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--line); }
.actions { display: flex; gap: .5rem; } .actions.wrap { flex-wrap: wrap; margin-top: 1rem; }
.warn { color: var(--danger); border: 1px solid var(--danger); border-radius: var(--radius); padding: .5rem 1rem .5rem 2rem; }
p.warn { padding: .5rem 1rem; }
.toast { position: sticky; bottom: 1rem; background: var(--panel2); border: 1px solid var(--diesium); padding: .5rem 1rem; border-radius: var(--radius); }
.sheet-name { font-size: 1.6rem; color: var(--text); margin: 0; }
.sheet-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; }
.rules { display: grid; grid-template-columns: 220px 1fr; gap: 2rem; }
.rules aside { position: sticky; top: 4.5rem; align-self: start; max-height: calc(100vh - 6rem); overflow: auto; }
.rules aside ul { list-style: none; padding: 0; margin: .8rem 0; }
.rules aside a { display: block; padding: .3rem .5rem; text-decoration: none; color: var(--text); border-left: 2px solid var(--line); }
.rules aside a:hover { border-color: var(--noxium); } .rules aside a.dim { opacity: .35; }
.rules section { margin-bottom: 2.5rem; scroll-margin-top: 5rem; }
.rules ul { padding-left: 1.2rem; }
.narrow { max-width: 760px; }
.charlist { list-style: none; padding: 0; display: grid; gap: .6rem; }
.row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
@media (max-width: 800px) {
.creator-body { grid-template-columns: 1fr; } .live { position: static; order: -1; }
.rules { grid-template-columns: 1fr; } .rules aside { position: static; max-height: none; }
.grid2, .sheet-cols { grid-template-columns: 1fr; }
}
@media print { .top, .nav, .steps, .live, .actions { display: none; } body { background: #fff; color: #000; } }
details { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); margin: .4rem 0; padding: .4rem .8rem; }
details summary { cursor: pointer; font-weight: 600; }
.extras { display: grid; gap: .4rem; margin-top: .6rem; }
.extra { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .3rem 0; border-top: 1px solid var(--line); }
.resets { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
button.unlock { background: transparent; color: var(--diesium); border-color: var(--diesium); }
button.small-btn { padding: .15rem .5rem; font-size: .75rem; font-weight: 500; min-width: 0; }
.pools { display: flex; gap: .6rem; flex-wrap: wrap; }
.pips { display: flex; align-items: center; gap: .1rem; margin-top: .15rem; }
button.pip { background: transparent; border: none; color: var(--muted); padding: 0 .2rem; font-size: 1.05rem; line-height: 1; min-width: 0; font-weight: 400; }
button.pip.on { color: var(--noxium); }
button.pip.from-prof { color: var(--diesium); opacity: 1; cursor: default; }
.presetbox { margin-bottom: 1rem; }
.tunables { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0 1rem; }
.presetchip { margin: 0 0 .5rem; }
button.linklike { background: none; border: none; color: var(--noxium); padding: 0; font-weight: 600; text-decoration: underline; cursor: pointer; }
.presets-layout { display: grid; grid-template-columns: 240px 1fr; gap: 1.5rem; align-items: start; margin-top: 1rem; }
.presetlist { list-style: none; padding: 0; margin: 0 0 .6rem; display: grid; gap: .4rem; }
.presetlist button.card.choice { width: 100%; }
@media (max-width: 800px) { .presets-layout { grid-template-columns: 1fr; } }
.creator-body { grid-template-columns: minmax(0, 1fr) 460px; }
.creator-body.wide { grid-template-columns: 1fr; }
.sheetpanel { max-height: calc(100vh - 6rem); overflow: auto; font-size: .9rem; }
.sheetpanel h3.sheet-name { font-size: 1.15rem; }
.sheetpanel h4 { margin: .8em 0 .2em; font-size: .85rem; text-transform: uppercase; letter-spacing: .05em; }
.sheetpanel td { padding: .12rem .3rem; border-bottom: 1px solid var(--line); }
.sheetpanel td:nth-child(2) { text-align: right; white-space: nowrap; }
.applybar { position: sticky; top: 0; z-index: 1; display: flex; justify-content: space-between; align-items: center; gap: .5rem; flex-wrap: wrap; padding: .4rem .5rem; margin: .3rem 0 .5rem; border-radius: var(--radius); background: var(--panel2); border: 1px solid var(--line); }
.applybar.pending { border-color: var(--diesium); }
.applybar button { padding: .2rem .7rem; font-size: .85rem; }
.d-added td { color: var(--noxium); font-weight: 600; }
.d-changed td:nth-child(2) { color: var(--diesium); font-weight: 600; }
.d-removed td { color: var(--danger); text-decoration: line-through; }
.was { color: var(--muted); font-weight: 400; }
@media (max-width: 800px) { .creator-body { grid-template-columns: 1fr; } .sheetpanel { position: static; max-height: none; order: 1; } }
.rowdesc { font-size: .8rem; color: var(--muted); font-weight: 400; line-height: 1.3; text-decoration: none; }
.d-removed .rowdesc { text-decoration: line-through; }
.d-added .rowdesc { color: var(--muted); }
ul.desclist { list-style: none; padding: 0; margin: 0 0 .5rem; display: grid; gap: .35rem; }
main { max-width: 1280px; }
@media (max-width: 1100px) { .creator-body { grid-template-columns: minmax(0, 1fr) 360px; } }
@media (max-width: 800px) { .creator-body { grid-template-columns: 1fr; } }
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Dev: `npm run dev` im Ordner frontend, Backend separat auf Port 8000.
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:8000' } },
})