Files
AWCharacterCreator/backend/app/rules_edit.py
T

126 lines
5.9 KiB
Python

"""Validation for the rules editor (items, skills, professions and their categories).
Only whitelisted fields are kept and every reference (category, group, attribute, skill) has to exist."""
import re
SECTIONS = ("equipment", "equipment_categories", "skills", "skill_groups", "professions")
MAX_ENTRIES = 400
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
class RuleError(ValueError):
pass
def _text(v, limit, label, required=False):
v = str(v if v is not None else "").strip()
if required and not v:
raise RuleError(f"{label} must not be empty")
if len(v) > limit:
raise RuleError(f"{label} is too long (max {limit} characters)")
return v
def _num(v, lo, hi, label, integer=False):
try:
n = float(v)
except (TypeError, ValueError):
raise RuleError(f"{label} must be a number")
if not lo <= n <= hi:
raise RuleError(f"{label} must be between {lo} and {hi}")
return int(n) if integer else round(n, 2)
def _ids(v, allowed, label, lo=0, hi=50):
if not isinstance(v, list) or not lo <= len(v) <= hi:
raise RuleError(f"{label}: choose {lo} to {hi}" if hi < 50 else f"{label} must be a list")
out = []
for x in v:
if x not in allowed:
raise RuleError(f"{label}: unknown entry '{x}'")
if x not in out:
out.append(x)
return out
def clean(section: str, items, rules: dict) -> list:
"""Returns the cleaned list for one section. `rules` is the effective rules (for the other sections' ids)."""
if section not in SECTIONS:
raise RuleError("Unknown section")
if not isinstance(items, list) or len(items) > MAX_ENTRIES:
raise RuleError(f"A list of at most {MAX_ENTRIES} entries is expected")
attrs = {a["id"] for a in rules["attributes"]}
out, seen = [], set()
cats = {c["id"] for c in (items if section == "equipment_categories" else rules["equipment_categories"])}
groups = {g["id"] for g in (items if section == "skill_groups" else rules["skill_groups"])}
skills = {s["id"] for s in (items if section == "skills" else rules["skills"])}
for it in items:
if not isinstance(it, dict):
raise RuleError("Every entry has to be an object")
iid = str(it.get("id", ""))
if not ID_RE.match(iid):
raise RuleError(f"Invalid id '{iid}' (lowercase letters, digits and _ only, up to 40)")
if iid in seen:
raise RuleError(f"The id '{iid}' is used twice")
seen.add(iid)
name = _text(it.get("name"), 60, f"Name of '{iid}'", True)
e = {"id": iid, "name": name}
label = f"'{name}'"
if section == "equipment":
if it.get("cat") not in cats:
raise RuleError(f"{label}: unknown category")
e.update(cat=it["cat"], stat=_text(it.get("stat"), 40, f"{label} stat"), kg=_num(it.get("kg", 0), 0, 1000, f"{label} weight"),
note=_text(it.get("note"), 200, f"{label} note"))
if it.get("tier"):
e["tier"] = _text(it["tier"], 20, f"{label} tier")
elif section == "equipment_categories":
e["stat"] = _text(it.get("stat"), 40, f"{label} stat column")
elif section == "skills":
if it.get("group") not in groups:
raise RuleError(f"{label}: unknown group")
if it.get("type") not in ("basic", "advanced"):
raise RuleError(f"{label}: type must be basic or advanced")
a = _ids(it.get("attrs"), attrs, f"{label} attributes", 1, 3)
e.update(group=it["group"], attrs=a, type=it["type"], desc=_text(it.get("desc"), 300, f"{label} description"))
elif section == "professions":
e.update(desc=_text(it.get("desc"), 500, f"{label} description"), bonus_attrs=_ids(it.get("bonus_attrs"), attrs, f"{label} bonus attributes", 0, 7),
proficiencies=_ids(it.get("proficiencies"), skills, f"{label} proficiencies", 0, 12),
picks=_num(it.get("picks", 0), 0, 10, f"{label} free picks", True), note=_text(it.get("note"), 200, f"{label} note"))
if not e["note"]:
del e["note"]
if it.get("placeholder"):
e["placeholder"] = True
out.append(e)
return out
def dangling(section: str, new: list, rules: dict) -> list:
"""Things in the other sections that would point to an entry that no longer exists."""
ids = {x["id"] for x in new}
msgs = []
if section == "equipment_categories":
msgs += [f"Item '{e['name']}' uses the removed category" for e in rules["equipment"] if e["cat"] not in ids]
if section == "skill_groups":
msgs += [f"Skill '{s['name']}' uses the removed group" for s in rules["skills"] if s["group"] not in ids]
if section == "skills":
for p in rules["professions"]:
gone = [i for i in p["proficiencies"] if i not in ids]
if gone:
msgs.append(f"Profession '{p['name']}' grants proficiency in a removed skill ({', '.join(gone)})")
return msgs
def character_refs(ch: dict) -> dict:
"""Ids a saved character points to, per section."""
kit = ch.get("kit") or {}
items = {i for i in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or []), *(kit.get("extras") or {})] if i}
sk = set((ch.get("skill_points") or {}).keys()) | set((ch.get("proficiencies") or {}).keys()) | set((ch.get("proficiency_picks") or {}).keys()) \
| set(ch.get("unlocked") or []) | set(ch.get("profession_picks") or [])
return {"equipment": items, "skills": sk, "professions": {ch["profession"]} if ch.get("profession") else set()}
def usage(chars: list, section: str, removed: set) -> int:
"""How many saved characters use one of the removed ids."""
if section not in ("equipment", "skills", "professions") or not removed:
return 0
return sum(1 for ch in chars if character_refs(ch)[section] & removed)