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