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
+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")