first commit
This commit is contained in:
@@ -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)}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Character sheet as DOCX and PDF."""
|
||||
import io
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.shared import Pt
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Table, TableStyle
|
||||
|
||||
from .derive import compute, kit_items
|
||||
|
||||
SOURCE = {"noxium": "Noxium", "diesium": "Diesium", "hybrid": "Hybrid", "precyber": "Pre-meteor cybernetics"}
|
||||
KIND = {"mutation": "Mutation", "implant": "Implant"}
|
||||
|
||||
|
||||
def _sheet(ch: dict, rules: dict) -> dict:
|
||||
"""Prepares all data once for both formats."""
|
||||
c = compute(ch, rules)
|
||||
prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None)
|
||||
talents = [t for t in rules["talents"] if t["id"] in ch.get("talents", [])]
|
||||
cls = next((k for k in rules.get("classes", []) if k["id"] == ch.get("class_id")), None)
|
||||
sub = next((x for x in (cls or {}).get("subclasses", []) if x["id"] == ch.get("subclass_id")), None)
|
||||
gear_rows = []
|
||||
for label, item, qty in kit_items(ch, rules):
|
||||
name = item["name"] + (f" x{qty}" if qty > 1 else "")
|
||||
gear_rows.append([label, name, item["stat"], f"{round(item['kg'] * qty, 1)} kg"])
|
||||
preset = next((x["name"] for x in rules.get("presets", []) if x["id"] == ch.get("preset")), "Custom")
|
||||
return {
|
||||
"c": c,
|
||||
"preset": preset,
|
||||
"class": " / ".join(x["name"] for x in (cls, sub) if x) or "-",
|
||||
"profession": prof["name"] if prof else "-",
|
||||
"talents": talents,
|
||||
"gear_rows": gear_rows,
|
||||
"other": (ch.get("gear") or {}).get("other", ""),
|
||||
"attr_rows": [[a["name"], str(c["attrs"][a["id"]]), str(c["mods"][a["id"]])] for a in rules["attributes"]],
|
||||
"derived_rows": [[d["name"], str(c["derived"][d["id"]])] for d in rules["derived"]],
|
||||
"skill_rows": [
|
||||
[s["name"], "/".join(a["short"] for a in rules["attributes"] if a["id"] in s["attrs"]),
|
||||
str(s["points"]), {0: "", 1: "Proficient", 2: "Expertise"}.get(s["pips"], ""), str(s["total"])]
|
||||
for s in c["skills"] if s["points"] or s["prof"] or (s["unlocked"] and s["type"] == "advanced")
|
||||
],
|
||||
"augment_rows": [[a["name"], KIND.get(a["kind"], a["kind"]), SOURCE.get(a["source"]) or "-", str(a["cost"])]
|
||||
for a in c["augments"]],
|
||||
}
|
||||
|
||||
|
||||
def to_docx(ch: dict, rules: dict) -> bytes:
|
||||
s = _sheet(ch, rules)
|
||||
c = s["c"]
|
||||
doc = Document()
|
||||
doc.styles["Normal"].font.name = "Calibri"
|
||||
doc.styles["Normal"].font.size = Pt(10)
|
||||
|
||||
title = doc.add_heading(ch.get("name") or "Nameless", 0)
|
||||
title.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
doc.add_paragraph(f"Arcane Wasteland | Player: {ch.get('player') or '-'} | Profession: {s['profession']} | Class: {s['class']} | Rules: {s['preset']}")
|
||||
if ch.get("concept"):
|
||||
doc.add_paragraph().add_run(ch["concept"]).italic = True
|
||||
|
||||
def table(head, rows):
|
||||
t = doc.add_table(rows=1, cols=len(head))
|
||||
t.style = "Light Grid Accent 1"
|
||||
for i, h in enumerate(head):
|
||||
t.rows[0].cells[i].text = h
|
||||
for r in rows:
|
||||
cells = t.add_row().cells
|
||||
for i, v in enumerate(r):
|
||||
cells[i].text = v
|
||||
|
||||
doc.add_heading("Attributes", 1)
|
||||
table(["Attribute", "Value", "Mod"], s["attr_rows"])
|
||||
doc.add_heading("Derived stats", 1)
|
||||
table(["Stat", ""], s["derived_rows"])
|
||||
doc.add_heading("Skills", 1)
|
||||
if s["skill_rows"]:
|
||||
table(["Skill", "Attr.", "Points", "Training", "Target"], s["skill_rows"])
|
||||
else:
|
||||
doc.add_paragraph("No skill points spent.")
|
||||
doc.add_heading("Talents", 1)
|
||||
for t in s["talents"]:
|
||||
doc.add_paragraph(f"{t['name']}: {t['desc']}", style="List Bullet")
|
||||
if not s["talents"]:
|
||||
doc.add_paragraph("-")
|
||||
doc.add_heading(f"Mutations and implants ({c['capacity_used']} / {c['capacity_max']} capacity)", 1)
|
||||
if s["augment_rows"]:
|
||||
table(["Name", "Type", "Source", "Cost"], s["augment_rows"])
|
||||
else:
|
||||
doc.add_paragraph("-")
|
||||
doc.add_heading(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", 1)
|
||||
if s["gear_rows"]:
|
||||
table(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
|
||||
else:
|
||||
doc.add_paragraph("-")
|
||||
if s["other"]:
|
||||
p = doc.add_paragraph()
|
||||
p.add_run("Other: ").bold = True
|
||||
p.add_run(s["other"])
|
||||
for key, head in (("background", "Background"), ("notes", "Notes")):
|
||||
if ch.get(key):
|
||||
doc.add_heading(head, 1)
|
||||
doc.add_paragraph(ch[key])
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def to_pdf(ch: dict, rules: dict) -> bytes:
|
||||
s = _sheet(ch, rules)
|
||||
c = s["c"]
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm,
|
||||
topMargin=16 * mm, bottomMargin=16 * mm,
|
||||
title=ch.get("name") or "Character", author=ch.get("player") or "")
|
||||
st = getSampleStyleSheet()
|
||||
h1 = ParagraphStyle("h1", parent=st["Heading1"], fontSize=22, spaceAfter=2)
|
||||
h2 = ParagraphStyle("h2", parent=st["Heading2"], fontSize=12, spaceBefore=10, spaceAfter=4,
|
||||
textColor=colors.HexColor("#2f5d3a"))
|
||||
body = ParagraphStyle("b", parent=st["BodyText"], fontSize=9.5, leading=12.5)
|
||||
|
||||
def esc(t):
|
||||
return (t or "").replace("&", "&").replace("<", "<").replace(">", ">").replace("\n", "<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 | Player: {esc(ch.get('player') or '-')} | "
|
||||
f"Profession: {esc(s['profession'])} | Class: {esc(s['class'])} | Rules: {esc(s['preset'])}", body)]
|
||||
if ch.get("concept"):
|
||||
el.append(Paragraph(f"<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()
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user