625 lines
25 KiB
Python
625 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
traderx_check.py - Validate and (optionally) fix TraderX legacy configuration.
|
|
|
|
Checks TraderXConfig/Products/*.json and TraderXConfig/Categories/*.json against
|
|
the rules enforced by the mod's Enforce Script loaders (TraderXProductId.IsValidUUID,
|
|
TraderXCategoryId.IsValidUUID, TraderXJsonProduct, TraderXJsonCategory), plus
|
|
cross-references to TraderXGeneralSettings.json.
|
|
|
|
Usage:
|
|
python3 traderx_check.py /path/to/profiles/TraderX # report only
|
|
python3 traderx_check.py /path/to/profiles/TraderX --fix # apply safe fixes
|
|
python3 traderx_check.py /path/to/profiles/TraderX --fix --no-backup
|
|
python3 traderx_check.py /path/to/profiles/TraderX --decode # also print decoded bitfields
|
|
|
|
The path can be the TraderX root, the TraderXConfig dir, or the profiles dir.
|
|
|
|
Fixes applied with --fix:
|
|
* rename product/category files whose names fail validation
|
|
(sanitise, append _001 / bump number on collision) and rewrite every
|
|
reference to the old id in categories, attachments and variants
|
|
* add missing fields with the mod's defaults
|
|
* coerce numeric strings ("5") to numbers, floats to ints where an int is expected
|
|
* drop unknown keys (e.g. a stray "productId" inside legacy files)
|
|
* remove references to products/categories that do not exist
|
|
* rewrite files with consistent 2-space indentation
|
|
|
|
Never fixed automatically (reported only): invalid JSON syntax, missing className,
|
|
prices/stock out of range, coefficient with unlimited stock, orphan products,
|
|
compiled files shadowing legacy files, license ids not found.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rules mirrored from the mod source
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PROD_RE = re.compile(r"^prod_([A-Za-z0-9_]+)_(\d{3})$")
|
|
CAT_RE = re.compile(r"^cat_([a-z0-9_]+)_(\d{3})$")
|
|
|
|
PRODUCT_SCHEMA = {
|
|
# key: (type, default)
|
|
"className": (str, ""),
|
|
"coefficient": (float, 1.0),
|
|
"maxStock": (int, -1),
|
|
"tradeQuantity": (int, 18), # buy full / sell full
|
|
"buyPrice": (int, -1),
|
|
"sellPrice": (int, -1),
|
|
"stockSettings": (int, 0),
|
|
"attachments": (list, []),
|
|
"variants": (list, []),
|
|
}
|
|
PRODUCT_ORDER = list(PRODUCT_SCHEMA.keys())
|
|
|
|
CATEGORY_SCHEMA = {
|
|
"isVisible": (bool, True),
|
|
"icon": (str, ""),
|
|
"categoryName": (str, ""),
|
|
"licensesRequired": (list, []),
|
|
"productIds": (list, []),
|
|
}
|
|
CATEGORY_ORDER = list(CATEGORY_SCHEMA.keys())
|
|
|
|
SELL_MODES = {0: "NO_MATTER", 1: "EMPTY", 2: "FULL", 3: "COEFFICIENT", 4: "STATIC"}
|
|
BUY_MODES = {0: "EMPTY", 1: "EMPTY(1)", 2: "FULL", 3: "COEFFICIENT", 4: "STATIC"}
|
|
RESTART_MODES = {0: "keep", 1: "reset to max", 2: "random"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reporting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class Report:
|
|
def __init__(self):
|
|
self.errors = []
|
|
self.warnings = []
|
|
self.fixes = []
|
|
self.infos = []
|
|
|
|
def error(self, where, msg):
|
|
self.errors.append((where, msg))
|
|
|
|
def warn(self, where, msg):
|
|
self.warnings.append((where, msg))
|
|
|
|
def fix(self, where, msg):
|
|
self.fixes.append((where, msg))
|
|
|
|
def info(self, where, msg):
|
|
self.infos.append((where, msg))
|
|
|
|
def dump(self, show_info, group_threshold=10, ignore=()):
|
|
def section(title, items):
|
|
items = [(w, m) for w, m in items if not any(i in m for i in ignore)]
|
|
if not items:
|
|
return
|
|
print(f"\n=== {title} ({len(items)}) ===")
|
|
# group identical messages (ignoring the numbers in them) when repeated a lot
|
|
groups = {}
|
|
for where, msg in items:
|
|
key = re.sub(r"[-0-9.]+", "#", msg)
|
|
groups.setdefault(key, []).append((where, msg))
|
|
for key, lst in groups.items():
|
|
if len(lst) >= group_threshold:
|
|
print(f" ({len(lst)}x) {lst[0][1]}")
|
|
print(" " + ", ".join(w for w, _ in lst[:8]) + (", ..." if len(lst) > 8 else ""))
|
|
else:
|
|
for where, msg in lst:
|
|
print(f" [{where}] {msg}")
|
|
|
|
section("ERRORS", self.errors)
|
|
section("WARNINGS", self.warnings)
|
|
section("FIXES APPLIED" if applied else "FIXES AVAILABLE (run with --fix)", self.fixes)
|
|
if show_info:
|
|
section("INFO", self.infos)
|
|
print(
|
|
f"\nSummary: {len(self.errors)} errors, {len(self.warnings)} warnings, "
|
|
f"{len(self.fixes)} fixes {'applied' if applied else 'available'}"
|
|
)
|
|
|
|
|
|
applied = False # set by main()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def find_config_dir(path):
|
|
"""Accept profiles dir, TraderX dir or TraderXConfig dir."""
|
|
path = os.path.abspath(path)
|
|
candidates = [
|
|
path,
|
|
os.path.join(path, "TraderXConfig"),
|
|
os.path.join(path, "TraderX", "TraderXConfig"),
|
|
]
|
|
for c in candidates:
|
|
if os.path.isdir(os.path.join(c, "Products")) or os.path.isdir(os.path.join(c, "Categories")):
|
|
return c
|
|
sys.exit(f"Could not find TraderXConfig with Products/ or Categories/ under {path}")
|
|
|
|
|
|
def load_json(path, rep):
|
|
try:
|
|
with open(path, "r", encoding="utf-8-sig") as f:
|
|
return json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
rep.error(os.path.basename(path), f"invalid JSON: {e.msg} at line {e.lineno} col {e.colno}")
|
|
except OSError as e:
|
|
rep.error(os.path.basename(path), f"cannot read: {e}")
|
|
return None
|
|
|
|
|
|
def save_json(path, data):
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
|
|
def sanitize_middle(s, lowercase):
|
|
if lowercase:
|
|
s = s.lower()
|
|
s = re.sub(r"[^A-Za-z0-9_]", "_", s)
|
|
s = re.sub(r"_+", "_", s).strip("_")
|
|
return s or "item"
|
|
|
|
|
|
def make_valid_id(stem, prefix, regex, taken):
|
|
"""Turn an arbitrary filename stem into a valid id not present in `taken`."""
|
|
lowercase = prefix == "cat_"
|
|
if regex.match(stem) and stem not in taken:
|
|
return stem
|
|
m = regex.match(stem)
|
|
if m:
|
|
middle, num = m.group(1), int(m.group(2))
|
|
else:
|
|
body = stem[len(prefix):] if stem.lower().startswith(prefix) else stem
|
|
# if it already ends with _NNN (but middle was invalid) keep the number
|
|
m2 = re.match(r"^(.*)_(\d{3})$", body)
|
|
if m2:
|
|
middle, num = sanitize_middle(m2.group(1), lowercase), int(m2.group(2))
|
|
else:
|
|
middle, num = sanitize_middle(body, lowercase), 1
|
|
num = max(1, min(num, 999))
|
|
while True:
|
|
cand = f"{prefix}{middle}_{num:03d}"
|
|
if cand not in taken:
|
|
return cand
|
|
num += 1
|
|
if num > 999:
|
|
raise RuntimeError(f"no free id for {stem}")
|
|
|
|
|
|
def coerce(value, typ):
|
|
"""Return (coerced_value, changed) or (None, False) if impossible."""
|
|
if typ is bool:
|
|
if isinstance(value, bool):
|
|
return value, False
|
|
if isinstance(value, (int, float)):
|
|
return bool(value), True
|
|
if isinstance(value, str) and value.strip().lower() in ("true", "1", "yes"):
|
|
return True, True
|
|
if isinstance(value, str) and value.strip().lower() in ("false", "0", "no", ""):
|
|
return False, True
|
|
return None, False
|
|
if typ is int:
|
|
if isinstance(value, bool):
|
|
return int(value), True
|
|
if isinstance(value, int):
|
|
return value, False
|
|
if isinstance(value, float):
|
|
return int(round(value)), True
|
|
if isinstance(value, str):
|
|
try:
|
|
return int(round(float(value.strip()))), True
|
|
except ValueError:
|
|
return None, False
|
|
return None, False
|
|
if typ is float:
|
|
if isinstance(value, bool):
|
|
return float(value), True
|
|
if isinstance(value, (int, float)):
|
|
return value, False
|
|
if isinstance(value, str):
|
|
try:
|
|
return float(value.strip()), True
|
|
except ValueError:
|
|
return None, False
|
|
return None, False
|
|
if typ is str:
|
|
if isinstance(value, str):
|
|
return value, False
|
|
return str(value), True
|
|
if typ is list:
|
|
if isinstance(value, list):
|
|
return value, False
|
|
if value is None or value == "":
|
|
return [], True
|
|
if isinstance(value, str):
|
|
return [v.strip() for v in value.split(";") if v.strip()], True
|
|
return None, False
|
|
return value, False
|
|
|
|
|
|
def decode_trade_quantity(tq):
|
|
return (
|
|
f"sell={SELL_MODES.get(tq & 7, tq & 7)} buy={BUY_MODES.get((tq >> 3) & 7, (tq >> 3) & 7)} "
|
|
f"sellVal={(tq >> 6) & 0x1FFF} buyVal={tq >> 19}"
|
|
)
|
|
|
|
|
|
def decode_stock_settings(ss):
|
|
return f"destock={(ss & 0x7F) / 100:.2f} restart={RESTART_MODES.get((ss >> 7) & 3, (ss >> 7) & 3)}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def scan_folder(folder, rep):
|
|
"""Return {stem: {'path':..., 'data':...}} for every .json in folder."""
|
|
out = {}
|
|
if not os.path.isdir(folder):
|
|
return out
|
|
for name in sorted(os.listdir(folder)):
|
|
if not name.lower().endswith(".json"):
|
|
continue
|
|
stem = name[:-5]
|
|
data = load_json(os.path.join(folder, name), rep)
|
|
out[stem] = {"path": os.path.join(folder, name), "data": data}
|
|
return out
|
|
|
|
|
|
def schema_fix(stem, data, schema, order, rep, do_fix):
|
|
"""Add defaults, coerce types, drop unknown keys. Mutates & returns data."""
|
|
if not isinstance(data, dict):
|
|
rep.error(stem, f"top-level JSON is a {type(data).__name__}, expected an object {{...}} - file skipped")
|
|
return data, False
|
|
changed = False
|
|
for key, (typ, default) in schema.items():
|
|
if key not in data:
|
|
rep.fix(stem, f"missing '{key}' -> default {default!r}")
|
|
data[key] = default if not isinstance(default, list) else []
|
|
changed = True
|
|
continue
|
|
newval, ch = coerce(data[key], typ)
|
|
if newval is None:
|
|
rep.error(stem, f"'{key}' has wrong type {type(data[key]).__name__} = {data[key]!r}")
|
|
elif ch:
|
|
rep.fix(stem, f"'{key}': {data[key]!r} -> {newval!r}")
|
|
data[key] = newval
|
|
changed = True
|
|
for key in list(data.keys()):
|
|
if key not in schema:
|
|
rep.fix(stem, f"unknown key '{key}' removed")
|
|
del data[key]
|
|
changed = True
|
|
ordered = {k: data[k] for k in order if k in data}
|
|
if list(data.keys()) != list(ordered.keys()):
|
|
changed = True
|
|
data.clear()
|
|
data.update(ordered)
|
|
return data, changed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
global applied
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("path", help="TraderX root, TraderXConfig dir, or server profiles dir")
|
|
ap.add_argument("--fix", action="store_true", help="apply safe fixes (rename files, add defaults, fix refs)")
|
|
ap.add_argument("--no-backup", action="store_true", help="do not create a backup before fixing")
|
|
ap.add_argument("--fix-settings", action="store_true",
|
|
help="with --fix: also rewrite renamed category ids inside TraderXGeneralSettings.json traders[].categoriesId")
|
|
ap.add_argument("--decode", action="store_true", help="print decoded tradeQuantity/stockSettings per product")
|
|
ap.add_argument("--ignore", action="append", default=[], metavar="TEXT",
|
|
help="hide warnings/errors whose message contains TEXT (repeatable)")
|
|
ap.add_argument("--no-group", action="store_true", help="never collapse repeated messages")
|
|
ap.add_argument("--quiet-info", action="store_true", help="hide INFO section")
|
|
args = ap.parse_args()
|
|
applied = args.fix
|
|
|
|
cfg = find_config_dir(args.path)
|
|
prod_dir = os.path.join(cfg, "Products")
|
|
cat_dir = os.path.join(cfg, "Categories")
|
|
rep = Report()
|
|
print(f"TraderXConfig: {cfg}")
|
|
|
|
# --- compiled shadowing ------------------------------------------------
|
|
for fname in ("Products_compiled.json", "Categories_compiled.json"):
|
|
p = os.path.join(cfg, "Compiled", fname)
|
|
if os.path.exists(p) and os.path.getsize(p) > 2:
|
|
rep.warn("Compiled/" + fname,
|
|
"exists - the mod loads this INSTEAD of the Products/ and Categories/ folders. "
|
|
"Delete it (or switch to CSV/COMPILED workflow) if you edit legacy files.")
|
|
|
|
# --- backup ------------------------------------------------------------
|
|
if args.fix and not args.no_backup:
|
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
bdir = os.path.join(cfg, f"Backup_traderx_check_{stamp}")
|
|
os.makedirs(bdir, exist_ok=True)
|
|
for d in (prod_dir, cat_dir):
|
|
if os.path.isdir(d):
|
|
shutil.copytree(d, os.path.join(bdir, os.path.basename(d)))
|
|
print(f"Backup written to {bdir}")
|
|
|
|
products = scan_folder(prod_dir, rep)
|
|
categories = scan_folder(cat_dir, rep)
|
|
print(f"Found {len(products)} product files, {len(categories)} category files")
|
|
|
|
# --- 1. filenames / ids ------------------------------------------------
|
|
rename_prod = {} # old stem -> new stem
|
|
taken = set(s for s in products if PROD_RE.match(s))
|
|
for stem in list(products):
|
|
if PROD_RE.match(stem):
|
|
continue
|
|
new = make_valid_id(stem, "prod_", PROD_RE, taken)
|
|
taken.add(new)
|
|
rename_prod[stem] = new
|
|
rep.error(stem + ".json", f"invalid product filename - mod will SKIP this file (fix: rename to {new}.json)")
|
|
rep.fix(stem, f"rename -> {new}")
|
|
|
|
rename_cat = {}
|
|
taken = set(s for s in categories if CAT_RE.match(s))
|
|
for stem in list(categories):
|
|
if CAT_RE.match(stem):
|
|
continue
|
|
new = make_valid_id(stem, "cat_", CAT_RE, taken)
|
|
taken.add(new)
|
|
rename_cat[stem] = new
|
|
rep.error(stem + ".json", f"invalid category filename - mod will SKIP this file (fix: rename to {new}.json)")
|
|
rep.fix(stem, f"rename -> {new}")
|
|
|
|
def new_pid(old):
|
|
return rename_prod.get(old, old)
|
|
|
|
def new_cid(old):
|
|
return rename_cat.get(old, old)
|
|
|
|
all_product_ids = {new_pid(s) for s in products if products[s]["data"] is not None}
|
|
all_category_ids = {new_cid(s) for s in categories if categories[s]["data"] is not None}
|
|
|
|
# --- 2. product contents ----------------------------------------------
|
|
dirty_products = set()
|
|
class_seen = {}
|
|
for stem, entry in products.items():
|
|
data = entry["data"]
|
|
if data is None:
|
|
continue
|
|
label = new_pid(stem)
|
|
data, changed = schema_fix(label, data, PRODUCT_SCHEMA, PRODUCT_ORDER, rep, args.fix)
|
|
if changed:
|
|
dirty_products.add(stem)
|
|
if not isinstance(data, dict):
|
|
continue
|
|
|
|
cn = data.get("className", "")
|
|
if not isinstance(cn, str) or not cn.strip():
|
|
rep.error(label, "className is empty")
|
|
else:
|
|
key = cn.lower()
|
|
if key in class_seen:
|
|
rep.info(label, f"className '{cn}' also used by {class_seen[key]} (ok if intentional, e.g. variants)")
|
|
else:
|
|
class_seen[key] = label
|
|
|
|
bp, sp, ms, co = data.get("buyPrice"), data.get("sellPrice"), data.get("maxStock"), data.get("coefficient")
|
|
if isinstance(bp, int) and bp < -1:
|
|
rep.error(label, f"buyPrice {bp} < -1")
|
|
if isinstance(sp, int) and sp < -1:
|
|
rep.error(label, f"sellPrice {sp} < -1")
|
|
if isinstance(bp, int) and isinstance(sp, int) and bp == -1 and sp == -1:
|
|
rep.warn(label, "buyPrice and sellPrice are both -1 - item can neither be bought nor sold")
|
|
if isinstance(bp, int) and isinstance(sp, int) and bp >= 0 and sp > bp:
|
|
rep.warn(label, f"sellPrice {sp} > buyPrice {bp} - players can profit by buy/sell loops")
|
|
if isinstance(ms, int) and ms < -1:
|
|
rep.error(label, f"maxStock {ms} < -1")
|
|
if isinstance(co, (int, float)):
|
|
if co <= 0:
|
|
rep.error(label, f"coefficient {co} must be > 0")
|
|
elif co != 1.0 and ms == -1:
|
|
rep.warn(label, f"coefficient {co} has no effect because maxStock is -1 (unlimited)")
|
|
elif co < 0.5 and isinstance(ms, int) and ms > 3:
|
|
rep.warn(label, f"coefficient {co} is very low - price at stock {ms} = {co ** (ms - 1) * 100:.1f}% of base")
|
|
|
|
tq, ss = data.get("tradeQuantity"), data.get("stockSettings")
|
|
if isinstance(tq, int):
|
|
if tq < 0:
|
|
rep.error(label, f"tradeQuantity {tq} negative")
|
|
elif (tq & 7) > 4 or ((tq >> 3) & 7) > 4:
|
|
rep.warn(label, f"tradeQuantity {tq} has unknown mode bits ({decode_trade_quantity(tq)})")
|
|
elif args.decode:
|
|
rep.info(label, f"tradeQuantity {tq}: {decode_trade_quantity(tq)}")
|
|
if isinstance(ss, int):
|
|
if ss < 0 or (ss & 0x7F) > 100 or ((ss >> 7) & 3) == 3:
|
|
rep.warn(label, f"stockSettings {ss} out of range ({decode_stock_settings(ss)})")
|
|
elif args.decode:
|
|
rep.info(label, f"stockSettings {ss}: {decode_stock_settings(ss)}")
|
|
|
|
for field in ("attachments", "variants"):
|
|
lst = data.get(field)
|
|
if not isinstance(lst, list):
|
|
continue
|
|
newlst = []
|
|
for ref in lst:
|
|
if not isinstance(ref, str):
|
|
rep.fix(label, f"{field}: non-string entry {ref!r} removed")
|
|
continue
|
|
r = new_pid(ref)
|
|
if r != ref:
|
|
rep.fix(label, f"{field}: reference {ref} -> {r}")
|
|
if r not in all_product_ids:
|
|
rep.error(label, f"{field}: references unknown product '{ref}'")
|
|
rep.fix(label, f"{field}: dangling reference '{ref}' removed")
|
|
continue
|
|
if r == label:
|
|
rep.warn(label, f"{field}: references itself")
|
|
if r in newlst:
|
|
rep.fix(label, f"{field}: duplicate '{r}' removed")
|
|
continue
|
|
newlst.append(r)
|
|
if newlst != lst:
|
|
data[field] = newlst
|
|
dirty_products.add(stem)
|
|
|
|
# --- 3. category contents ---------------------------------------------
|
|
dirty_cats = set()
|
|
used_products = set()
|
|
name_seen = {}
|
|
# license ids from general settings (optional)
|
|
license_ids = None
|
|
gs_path = os.path.join(cfg, "TraderXGeneralSettings.json")
|
|
gs = load_json(gs_path, rep) if os.path.exists(gs_path) else None
|
|
if isinstance(gs, dict):
|
|
license_ids = {l.get("licenseId") for l in gs.get("licenses", []) if isinstance(l, dict)}
|
|
license_names = {l.get("licenseName", "").lower(): l.get("licenseId") for l in gs.get("licenses", []) if isinstance(l, dict)}
|
|
else:
|
|
license_names = {}
|
|
|
|
for stem, entry in categories.items():
|
|
data = entry["data"]
|
|
if data is None:
|
|
continue
|
|
label = new_cid(stem)
|
|
data, changed = schema_fix(label, data, CATEGORY_SCHEMA, CATEGORY_ORDER, rep, args.fix)
|
|
if changed:
|
|
dirty_cats.add(stem)
|
|
if not isinstance(data, dict):
|
|
continue
|
|
|
|
name = data.get("categoryName", "")
|
|
if not name:
|
|
rep.warn(label, "categoryName is empty")
|
|
elif name.lower() in name_seen:
|
|
rep.warn(label, f"categoryName '{name}' duplicated in {name_seen[name.lower()]}")
|
|
else:
|
|
name_seen[name.lower()] = label
|
|
|
|
lst = data.get("productIds", [])
|
|
newlst = []
|
|
for ref in lst:
|
|
if not isinstance(ref, str):
|
|
rep.fix(label, f"productIds: non-string entry {ref!r} removed")
|
|
continue
|
|
r = new_pid(ref)
|
|
if r != ref:
|
|
rep.fix(label, f"productIds: {ref} -> {r}")
|
|
if r not in all_product_ids:
|
|
rep.error(label, f"productIds: unknown product '{ref}'")
|
|
rep.fix(label, f"productIds: dangling '{ref}' removed")
|
|
continue
|
|
if r in newlst:
|
|
rep.fix(label, f"productIds: duplicate '{r}' removed")
|
|
continue
|
|
newlst.append(r)
|
|
used_products.add(r)
|
|
if newlst != lst:
|
|
data["productIds"] = newlst
|
|
dirty_cats.add(stem)
|
|
if not newlst:
|
|
rep.warn(label, "category has no products")
|
|
|
|
lic = data.get("licensesRequired", [])
|
|
if license_ids is not None:
|
|
newlic = []
|
|
for l in lic:
|
|
if l in license_ids:
|
|
newlic.append(l)
|
|
elif isinstance(l, str) and l.lower() in license_names:
|
|
rep.fix(label, f"licensesRequired: name '{l}' -> id {license_names[l.lower()]}")
|
|
newlic.append(license_names[l.lower()])
|
|
else:
|
|
rep.error(label, f"licensesRequired: '{l}' is not a licenseId in TraderXGeneralSettings.json")
|
|
newlic.append(l) # keep - don't silently unlock categories
|
|
if newlic != lic:
|
|
data["licensesRequired"] = newlic
|
|
dirty_cats.add(stem)
|
|
|
|
# --- 4. orphans & trader references ------------------------------------
|
|
for pid in sorted(all_product_ids - used_products):
|
|
rep.warn(pid, "product is not listed in any category (only reachable as attachment/variant)")
|
|
|
|
if isinstance(gs, dict):
|
|
seen_npc = set()
|
|
for t in gs.get("traders", []):
|
|
if not isinstance(t, dict):
|
|
continue
|
|
tid = t.get("npcId")
|
|
tl = f"trader {tid} ({t.get('givenName', '?')})"
|
|
if tid in seen_npc:
|
|
rep.error(tl, "duplicate npcId")
|
|
seen_npc.add(tid)
|
|
for c in t.get("categoriesId", []):
|
|
if new_cid(c) not in all_category_ids:
|
|
rep.error(tl, f"categoriesId references unknown category '{c}'")
|
|
elif c in rename_cat:
|
|
if args.fix_settings:
|
|
rep.fix(tl, f"categoriesId '{c}' -> '{new_cid(c)}' in TraderXGeneralSettings.json")
|
|
else:
|
|
rep.warn(tl, f"categoriesId '{c}' will be renamed to '{new_cid(c)}' - "
|
|
f"update TraderXGeneralSettings.json manually or rerun with --fix --fix-settings")
|
|
if not t.get("categoriesId"):
|
|
rep.warn(tl, "trader has no categories")
|
|
|
|
# --- 5. apply ----------------------------------------------------------
|
|
if args.fix:
|
|
# write product files (under new names)
|
|
for stem, entry in products.items():
|
|
data = entry["data"]
|
|
if data is None:
|
|
continue
|
|
new = new_pid(stem)
|
|
newpath = os.path.join(prod_dir, new + ".json")
|
|
if stem != new:
|
|
os.rename(entry["path"], newpath)
|
|
save_json(newpath, data)
|
|
elif stem in dirty_products:
|
|
save_json(newpath, data)
|
|
for stem, entry in categories.items():
|
|
data = entry["data"]
|
|
if data is None:
|
|
continue
|
|
new = new_cid(stem)
|
|
newpath = os.path.join(cat_dir, new + ".json")
|
|
if stem != new:
|
|
os.rename(entry["path"], newpath)
|
|
save_json(newpath, data)
|
|
elif stem in dirty_cats:
|
|
save_json(newpath, data)
|
|
if args.fix_settings and isinstance(gs, dict) and rename_cat:
|
|
shutil.copy2(gs_path, gs_path + ".bak")
|
|
for t in gs.get("traders", []):
|
|
if isinstance(t, dict):
|
|
t["categoriesId"] = [new_cid(c) for c in t.get("categoriesId", [])]
|
|
save_json(gs_path, gs)
|
|
# stock files follow the product id
|
|
stock_dir = os.path.join(os.path.dirname(cfg), "TraderXDatabase", "Stock")
|
|
for old, new in rename_prod.items():
|
|
sp = os.path.join(stock_dir, old + ".json")
|
|
if os.path.exists(sp):
|
|
d = load_json(sp, rep)
|
|
if isinstance(d, dict):
|
|
d["productId"] = new
|
|
save_json(os.path.join(stock_dir, new + ".json"), d)
|
|
os.remove(sp)
|
|
rep.fix(old, f"stock file renamed -> {new}.json")
|
|
|
|
rep.dump(show_info=not args.quiet_info,
|
|
group_threshold=10 ** 9 if args.no_group else 10,
|
|
ignore=tuple(args.ignore))
|
|
return 1 if rep.errors and not args.fix else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|