From c6893763dd93f683fd012026f26779a3b562d059 Mon Sep 17 00:00:00 2001 From: smthspec Date: Sun, 6 Sep 2026 22:47:27 +0300 Subject: [PATCH] Initial release --- .gitignore | 7 + CHANGELOG.md | 12 + LICENSE | 21 + README.md | 133 +++ docs/checking.md | 58 ++ docs/compat.md | 67 ++ docs/importing.md | 99 +++ docs/source-format.md | 211 +++++ docs/traderx-configuration-guide.md | 565 ++++++++++++ examples/compat.example.json | 10 + examples/expansion.example.json | 32 + examples/plain.example.txt | 4 + examples/source.example.yaml | 67 ++ examples/traderplus.example.json | 15 + examples/weapons-with-presets.example.yaml | 39 + requirements.txt | 1 + scripts/prestart-check.sh | 15 + traderx_build.py | 967 +++++++++++++++++++++ traderx_check.py | 625 +++++++++++++ 19 files changed, 2948 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/checking.md create mode 100644 docs/compat.md create mode 100644 docs/importing.md create mode 100644 docs/source-format.md create mode 100644 docs/traderx-configuration-guide.md create mode 100644 examples/compat.example.json create mode 100644 examples/expansion.example.json create mode 100644 examples/plain.example.txt create mode 100644 examples/source.example.yaml create mode 100644 examples/traderplus.example.json create mode 100644 examples/weapons-with-presets.example.yaml create mode 100644 requirements.txt create mode 100644 scripts/prestart-check.sh create mode 100644 traderx_build.py create mode 100644 traderx_check.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e8e90d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +# server-specific data: keep your real source next to the tools but out of the public repo +/traderx.yaml +/traderx.json +/Backup_*/ +/*.local.* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e7f860c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 1.0.0 – 2026-09-06 + +Initial release. + +- `traderx_build.py`: YAML/JSON source → TraderX legacy files; importers for TraderX, + TraderPlus v1, Expansion Market, plain class lists; groups, variants, presets, + quantity words, license/trader resolution, dry-run, backups, stale-file detection, + `@compat` references and compatibility validation, `compat` lookup. +- `traderx_check.py`: validation and safe in-place fixes for existing configs. +- `docs/traderx-configuration-guide.md`: reference derived from the mod source. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6293544 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 traderx-tools contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4de44a5 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# traderx-tools + +Command-line tools for administering the **TraderX** DayZ trader mod +() without hand-editing hundreds of JSON files. + +| Tool | What it does | +|---|---| +| `traderx_build.py` | Maintain your whole trader setup in **one human-readable YAML/JSON file** (class names, words instead of bitfields, reusable attachment groups, presets) and compile it into the mod's `Products/`, `Categories/`, `TraderXPresets/` files and trader assignments. Imports **TraderPlus**, **Expansion Market** and **plain class-list** exports. | +| `traderx_check.py` | Validate (and optionally fix) an existing TraderX config in place: invalid file names the mod silently skips, dangling references, wrong types, license ids, trader assignments, stale compiled files. | +| `docs/` | A configuration guide for TraderX written from the mod's source code – file formats, bitfields, pricing formula, load order and gotchas. | + +Pure Python 3.8+, standard library only. `pyyaml` is optional (enables `.yaml` sources; `.json` sources work without it). + +--- + +## Quick start + +```bash +git clone traderx-tools && cd traderx-tools +pip install -r requirements.txt # optional, for YAML + +# 1. Health-check what you have now (changes nothing) +python3 traderx_check.py ~/serverprofile/TraderX + +# 2. Turn the existing config into one editable source file +python3 traderx_build.py import-traderx ~/serverprofile/TraderX -o traderx.yaml + +# 3. Add a mod's export as a new category, assigned to a trader +python3 traderx_build.py import-expansion Terje_Tools.json --trader Eve --into traderx.yaml +python3 traderx_build.py import-traderplus SnafuTraderPlus.json --trader Boris --into traderx.yaml +python3 traderx_build.py import-plain cans.txt --price 100 --trader Eve --into traderx.yaml + +# 4. Edit traderx.yaml, then preview and write (server stopped) +python3 traderx_build.py build traderx.yaml --out ~/serverprofile/TraderX --dry-run +python3 traderx_build.py build traderx.yaml --out ~/serverprofile/TraderX +``` + +Every `build` takes a timestamped backup, only touches files that actually changed, +lists stale files (deleted only with `--clean`), refuses to write when there are errors, +and warns if a `Compiled/` folder would shadow your files. + +--- + +## Why + +TraderX's native format has three properties that make it painful to manage by hand: + +1. **References by opaque product id** (`prod_snafu_ak12a_001`) instead of class name – attachments, variants, presets and categories all point at ids, and a typo silently drops the reference. +2. **Bitfields** – `tradeQuantity: 78644644` and `stockSettings: 138` encode several settings each. +3. **No reuse** – "every AK gets these 12 optics" means editing 40 files. + +`traderx_build.py` treats the mod's files as a *build output*. You edit this instead: + +```yaml +groups: + ak_optics: [SNAFU_AKAimpoint_M5, SNAFU_AKKahles, SNAFU_AKTrijicon] + +categories: + - name: Assault Rifles + traders: [Boris] + licenses: [Weapons License] + price: 1200 + items: + - SNAFU_AK12A: + attachments: [ak_optics, "@compat_mags"] + variants: [SNAFU_AK12A_Tan] + presets: + "CQB*": [SNAFU_AKAimpoint_M5] + - SNAFU_AK103A: {stock: 5, coefficient: 0.95, restart: reset} + - name: Ammo + traders: [Boris] + price: 60 + buy: static 30 + sell: any + items: [Ammo_762x39, Ammo_545x39] +``` + +See [docs/source-format.md](docs/source-format.md) for the full schema. + +--- + +## Documentation + +| Document | Contents | +|---|---| +| [docs/source-format.md](docs/source-format.md) | The YAML/JSON source schema for `traderx_build.py` – every key, defaults cascade, groups, variants, presets, quantity words | +| [docs/importing.md](docs/importing.md) | How TraderPlus / Expansion / plain exports and an existing TraderX config are mapped into the source | +| [docs/compat.md](docs/compat.md) | Using weapon compatibility files (`@compat`, `@compat_mags`) and the `compat` lookup command | +| [docs/checking.md](docs/checking.md) | `traderx_check.py`: what it detects, what `--fix` changes, exit codes | +| [docs/traderx-configuration-guide.md](docs/traderx-configuration-guide.md) | Reference for TraderX itself: directory layout, load order, product/category/settings files, bitfield layouts, pricing formula, licenses, vehicles, safe zone, troubleshooting | +| [examples/](examples/) | Sample source files and one sample of each import format | + +--- + +## Command overview + +``` +traderx_build.py import-traderx [-o FILE] +traderx_build.py import-traderplus [--category NAME] [--trader T]... (-o FILE | --into FILE) +traderx_build.py import-expansion [--category NAME] [--trader T]... (-o FILE | --into FILE) +traderx_build.py import-plain --price N [--category NAME] [--trader T]... (-o FILE | --into FILE) +traderx_build.py build --out [--dry-run] [--clean] [--strict] [--compat FILE]... [--no-backup] +traderx_build.py check [--strict] [--compat FILE]... +traderx_build.py compat ... + +traderx_check.py [--fix] [--fix-settings] [--no-backup] [--decode] [--ignore TEXT]... [--no-group] [--quiet-info] +``` + +`--trader` accepts a trader's `givenName` or `npcId` from `TraderXGeneralSettings.json` and is repeatable. + +--- + +## Pre-start validation + +`scripts/prestart-check.sh` runs `check` on the source and `traderx_check.py` on the profile +and exits non-zero on errors, so it can gate a server start: + +```bash +scripts/prestart-check.sh ~/serverprofile/TraderX traderx.yaml compat.json && ./start-server.sh +``` + +--- + +## Compatibility + +Written against TraderX `main` (mod version string 1.0.1, September 2026). The file-name +rules, bitfield layouts and preset format are taken from the mod's Enforce Script sources; +if a future TraderX release changes them, the constants at the top of both scripts are the +place to adjust. + +## License + +MIT – see [LICENSE](LICENSE). diff --git a/docs/checking.md b/docs/checking.md new file mode 100644 index 0000000..88e3b9a --- /dev/null +++ b/docs/checking.md @@ -0,0 +1,58 @@ +# `traderx_check.py` – validate and fix an existing config + +Works directly on a TraderX profile folder; independent of the build tool. Useful for +configs made with the web editor, by hand, or for a quick health check before start. + +```bash +python3 traderx_check.py ~/serverprofile/TraderX # report only +python3 traderx_check.py ~/serverprofile/TraderX --decode # + decoded bitfields per product +python3 traderx_check.py ~/serverprofile/TraderX --fix # apply safe fixes (backup first) +python3 traderx_check.py ~/serverprofile/TraderX --fix --fix-settings +``` + +The path may be the profiles dir, the `TraderX` dir or `TraderXConfig`. + +## What it reports + +**Errors** (the mod will skip or misread the file): + +- file names not matching `prod__NNN.json` / `cat__NNN.json` +- invalid JSON (line/column), or top-level value not an object +- wrong field types that can't be coerced; missing `className` +- `buyPrice`/`sellPrice`/`maxStock` below `-1`; `coefficient ≤ 0` +- attachments / variants / `productIds` pointing at products that don't exist +- `licensesRequired` entries that aren't a `licenseId` in `TraderXGeneralSettings.json` +- trader `categoriesId` pointing at missing categories; duplicate `npcId` + +**Warnings:** + +- `coefficient ≠ 1` with unlimited stock (no effect); very low coefficients +- sell > buy; both prices `-1` +- unknown mode bits in `tradeQuantity`; out-of-range `stockSettings` +- products not listed in any category (fine for attachments) +- empty categories; traders with no categories +- a `Compiled/` folder shadowing `Products/` and `Categories/` + +Repeated messages are grouped (`(414x) coefficient 0.9 has no effect …`); use +`--no-group` to list them all and `--ignore TEXT` (repeatable) to mute known-acceptable ones. + +## What `--fix` changes + +After copying `Products/` and `Categories/` to `Backup_traderx_check_/`: + +- renames invalid file names (sanitise, append `_001`, bump on collision) and rewrites every + reference to the old id in categories, attachments, variants and the matching + `TraderXDatabase/Stock/.json` +- adds missing fields with the mod's defaults; coerces `"5"` → `5`, `25.0` → `25`; drops + unknown keys (e.g. a stray `productId` inside a legacy product file) +- removes dangling and duplicate references; converts license *names* to license *ids* +- `--fix-settings`: also rewrites renamed category ids in `traders[].categoriesId` + (writes `TraderXGeneralSettings.json.bak` first) + +Never changed automatically: invalid JSON, empty class names, prices, stock, coefficients, +missing licenses. Run with the server stopped – it renames stock files. + +## Exit code + +`0` when no errors remain, `1` otherwise – usable in start scripts (see +`scripts/prestart-check.sh`). diff --git a/docs/compat.md b/docs/compat.md new file mode 100644 index 0000000..e9446d6 --- /dev/null +++ b/docs/compat.md @@ -0,0 +1,67 @@ +# Weapon compatibility files + +Some weapon mods ship (or admin tools can dump) a file listing which attachments, magazines +and rounds each weapon class accepts. SNAFU's is called +`All classes and their attachments.json`; the accepted shape is + +```json +{ + "": [ + { "name": "SNAFU_AK12A", "attachments": [...], "magazines": [...], "bullets": [...] }, + ... + ] +} +``` + +or a bare list of those objects. Several files can be given; entries merge by class name. +Abstract classes (`*_Base`) and `Test_*` / `*_TEST` entries are ignored. + +## Using it + +```bash +python3 traderx_build.py build traderx.yaml --out ~/serverprofile/TraderX --compat snafu-compat.json +python3 traderx_build.py check traderx.yaml --compat snafu-compat.json --compat other-mod.json +``` + +### 1. Validation + +Every weapon that has an entry in the compat data gets its configured `attachments` +checked. Classes that aren't attachable produce + +``` +warning: SNAFU_AK12A: not attachable according to compat file: TerjeStethoscope +``` + +They still build (the mod will list them on the Customize page, they just won't attach), +so fix the source. Weapons without a compat entry are not checked. + +### 2. Generation + +Instead of listing attachments, reference the compat data: + +```yaml +- SNAFU_AK12A: + attachments: ["@compat", "@compat_mags"] +``` + +| Ref | Result | +|---|---| +| `@compat` | all compatible attachments **that are products somewhere in your source** | +| `@compat_mags` | all compatible magazines that are products in your source | +| `@compat_all` | everything compatible, creating hidden products at `attachment_price` for anything missing | + +The "defined in your source" filter is what keeps this usable: a SNAFU rifle accepts +~80 attachment classes, but if you only sell 12 optics and 4 suppressors, `@compat` +resolves to exactly those. Combine with groups for hand-picked kits: + +```yaml +attachments: [ak_optics_tier1, "@compat_mags"] +``` + +## Lookup + +```bash +python3 traderx_build.py compat SNAFU_AK12A snafu-compat.json +``` + +prints the attachment / magazine / bullet lists for one class – handy when writing groups. diff --git a/docs/importing.md b/docs/importing.md new file mode 100644 index 0000000..34d1504 --- /dev/null +++ b/docs/importing.md @@ -0,0 +1,99 @@ +# Importing + +All importers produce (or append to) a source file; they never write into the TraderX +profile directly. Review the result, then `build`. + +Common options: + +| Option | Meaning | +|---|---| +| `-o FILE` | Write a new source file (default `source.yaml`, or `source.json` without pyyaml) | +| `--into FILE` | Append the imported categories into an existing source (creates it if missing). Name clashes get ` (imported)` appended. | +| `--category NAME` | Override the category name | +| `--trader T` | Assign the category to trader `T` (givenName or npcId). Repeatable. | + +--- + +## `import-traderx` – reverse-import an existing config + +```bash +python3 traderx_build.py import-traderx ~/serverprofile/TraderX -o traderx.yaml +``` + +Reads `TraderXConfig/Products`, `Categories`, `TraderXPresets` and `TraderXGeneralSettings.json`: + +- every product keeps its `id:` so stock files in `TraderXDatabase/Stock/` keep matching; +- bitfields are decoded to words (`buy: static 22`, `restart: reset`); +- attachments/variants/presets are written as class names; +- products in no category go under top-level `items:`; +- license ids become license names; trader assignments become `traders:`. + +Files the mod would skip (bad names, invalid JSON, non-object files) are skipped here +too and printed. Run `traderx_check.py` first if you want them fixed. + +After the import the file is verbose – every product spells out its options. Most of it +collapses once you move shared attachment lists into `groups:` and delete options that +equal the defaults. + +--- + +## `import-traderplus` – TraderPlus v1 `TraderPlusPriceConfig.json` + +```bash +python3 traderx_build.py import-traderplus SnafuTraderPlus.json --trader Boris --into traderx.yaml +``` + +Accepts the full file (`TraderCategories: [...]`), a single category object, or a bare list +of category objects. Each `CategoryName` becomes a category. Product lines +`class,coefficient,maxStock,tradeQuantity,buyPrice,sellPrice` map 1:1; whitespace around +class names is trimmed, malformed lines are reported and skipped. + +Options equal to the defaults (`coefficient 1`, `maxStock -1`, `buy full / sell full`) are +omitted to keep the output short. + +--- + +## `import-expansion` – Expansion Market `.json` (file or folder) + +```bash +python3 traderx_build.py import-expansion ExpansionMod/Market/ --trader Eve --into traderx.yaml +``` + +A folder imports every `*.json` inside as one category each. Mapping per item: + +| Expansion | Source | +|---|---| +| `DisplayName` | category `name` (`#Terje_Tools` → `Terje Tools`) | +| `ClassName` | item | +| `MaxPriceThreshold` | `price` | +| `SellPricePercent` (≥0) | `sell` = price × percent | +| `SellPricePercent` (-1) | omitted → `sell_percent` default applies | +| `MaxStockThreshold > 1` and `Min ≠ Max price` | `stock` = max, `coefficient` ≈ (min/max)^(1/(stock−1)) | +| otherwise | unlimited, flat price | +| `QuantityPercent` 0 / 100 / n | `buy: empty` / `full` / `coef n%` | +| `SpawnAttachments` | `attachments` | +| `Variants` | `variants` | + +Expansion's `MaxStockThreshold: 1, MinStockThreshold: 1` with equal prices is its idiom for +"always available at a fixed price", which is what unlimited flat stock means in TraderX. + +--- + +## `import-plain` – class list + +```bash +python3 traderx_build.py import-plain cans.txt --category Cans --price 100 --trader Eve --into traderx.yaml +``` + +One class per line. Lines may carry `,buyPrice[,sellPrice]`; lines without a price get the +category `price` from `--price`. `#` comments and blank lines are ignored. Separators can +be `,`, `;` or tab. + +--- + +## Merging strategy + +A typical layout is one source file per server with imported categories appended over +time. If you prefer one file per mod, keep them in JSON/YAML and concatenate the +`categories` lists yourself, or use `--into` with a scratch file and copy blocks across. +The builder doesn't care where a category came from. diff --git a/docs/source-format.md b/docs/source-format.md new file mode 100644 index 0000000..423a003 --- /dev/null +++ b/docs/source-format.md @@ -0,0 +1,211 @@ +# Source file format (`traderx_build.py`) + +The source is a single YAML (or JSON – identical keys) file with four top-level sections. +Everything is optional except `categories`. + +```yaml +defaults: # global defaults +groups: # named, reusable lists of class names +items: # products that are NOT shown in any category (attachments, variants…) +categories: # what the traders sell +``` + +Class names are matched case-insensitively. Product and category ids are derived from +names automatically (`AKM` → `prod_akm_001`, `Assault Rifles` → `cat_assault_rifles_001`) +and are stable across builds as long as the name doesn't change. Set `id:` explicitly +to keep an id from an existing config (the reverse-importer does this for you). + +--- + +## `defaults` + +| Key | Default | Meaning | +|---|---|---| +| `stock` | `unlimited` | `unlimited` or an integer max stock | +| `coefficient` | `1.0` | Dynamic price factor; only has an effect with a finite `stock` | +| `sell_percent` | `25` | Sell price as % of buy price when `sell` isn't given | +| `buy` | `full` | Item quantity on purchase – see *Quantity words* | +| `sell` | `full` | Required quantity on sale | +| `restart` | `keep` | Stock behaviour at server restart: `keep` / `reset` / `random` | +| `destock` | `0` | Fraction (0–1) of stock removed at every restart | +| `attachment_price` | `50` | Buy price for attachments/variants that are not defined anywhere else (auto-created hidden products) | +| `attachment_sell_percent` | `25` | Sell % for those auto-created products | + +Every key except the two `attachment_*` ones can be overridden per category and per item. +Resolution order is **item → category → defaults**. + +--- + +## `groups` + +```yaml +groups: + ak_optics: [PSO1Optic, KobraOptic, SNAFU_AKKahles] + ak_muzzle: [AK_Suppressor, SNAFU_AK19Muzzel] + ak_full_kit: [ak_optics, ak_muzzle, Mag_AKM_30Rnd] # groups can contain groups +``` + +A group name can be used anywhere a list of class names is expected: `attachments`, +`variants`, preset contents. Duplicates are removed; recursion is detected. + +--- + +## `items` + +Products that exist but are not listed in any category. Typical use: attachments and +variants that you price explicitly rather than relying on `attachment_price`. + +```yaml +items: + - SNAFU_AKKahles: {price: 800} + - PSO1Optic: {price: 300, sell: 60} + - AKM_Tan # all defaults, price -1 (unbuyable) – only useful as variant +``` + +--- + +## `categories` + +```yaml +categories: + - name: Assault Rifles # required, shown in the trader UI + id: cat_assault_rifles_001 # optional, otherwise derived from name + traders: [Boris, 3] # givenName or npcId from TraderXGeneralSettings.json + licenses: [Weapons License] # license *names*; created if missing + visible: true # false hides the category (products still work as attachments) + icon: "" # image path for the category card + price: 1200 # default buy price for items below + stock / coefficient / sell_percent / buy / sell / restart / destock # category defaults + items: [...] +``` + +`traders` controls `categoriesId` in `TraderXGeneralSettings.json`. Every trader that is +named by at least one category gets its list rebuilt in source order; traders not named +anywhere are left untouched (with a warning). + +--- + +## Items + +Three spellings are accepted: + +```yaml +items: + - AKM # bare class, all defaults + - AKM: {price: 1000, stock: 5} # class as key + - {class: AKM, price: 1000} # explicit key +``` + +Item options: + +| Key | Meaning | +|---|---| +| `id` | Keep a specific product id (`prod_…_NNN`). | +| `price` | Buy price. `-1` = cannot be bought. Required somewhere (item, category or `price` in defaults is *not* a thing – an item without a resolvable price is an error). | +| `sell` | Sell price (integer). `-1` = cannot be sold. If omitted, derived from `sell_percent`. | +| `sell_percent` | Override the percentage for this item. | +| `stock` | `unlimited` or integer. | +| `coefficient` | Dynamic price factor (see the configuration guide, §4.2). | +| `buy` | Purchase quantity word. | +| `sell_mode` | Sale quantity word (named `sell_mode` because `sell` is the price). At category/defaults level the key is `sell`. | +| `restart`, `destock` | Stock behaviour at restart. | +| `attachments` | List of class names / group names / `@compat` refs. | +| `variants` | List of class names / groups. Variants that don't define their own `attachments` inherit the base item's. | +| `presets` | Map of preset name → attachment list (see below). | + +An item may appear in several categories; the first occurrence with options defines it, +later occurrences just add it to that category (a warning is printed if they carry +conflicting options). + +--- + +## Quantity words + +Used for `buy` (item state when purchased) and `sell` / `sell_mode` (state required when sold): + +| Word | Buy | Sell | +|---|---|---| +| `full` | item spawns full | must be full | +| `empty` | spawns empty | must be empty | +| `any` | – | any amount accepted, paid pro-rata | +| `static N` | exactly N units | exactly N units | +| `coef 50%` (or `coef 0.5`) | 50 % of max | 50 % of max | + +The build packs these into the mod's `tradeQuantity` bitfield using the layout the mod's +runtime actually reads (`TraderXTradeQuantity`), not the one in its CSV packer, which is +inconsistent. + +--- + +## Presets + +```yaml +- AKM: + attachments: [ak_optics, ak_muzzle, Mag_AKM_30Rnd] + presets: + "CQB*": [KobraOptic, AK_Suppressor, Mag_AKM_30Rnd] # * marks the default preset + "DMR": [PSO1Optic, Mag_AKM_30Rnd] +``` + +Each item with `presets` produces `TraderXPresets/presets_.json`. Rules enforced +at build time: + +- every preset attachment must be in the item's own `attachments` list (the mod won't + let players select it otherwise) – error; +- exactly one preset can carry `*`; if none does, the first one becomes the default; +- preset ids are derived (`preset_akm_cqb_001`) and stable. + +Player-saved presets live in the same folder and are not touched. + +--- + +## `@compat` references + +Require `build --compat FILE`. See [compat.md](compat.md). + +| Ref | Expands to | +|---|---| +| `@compat` | every attachment the compat file lists for this weapon **that is defined as a product somewhere in the source** | +| `@compat_mags` | same, for magazines | +| `@compat_all` | everything compatible, auto-creating products that don't exist (rarely what you want) | + +--- + +## What the build checks + +Errors (nothing is written): + +- item without a resolvable price +- unknown quantity/restart word, out-of-range values +- preset using a class not in the weapon's attachments, or not defined at all +- `@compat` used without `--compat` +- trader or category reference that doesn't exist +- trader `className` with the wrong `SurvivorM_`/`SurvivorF_` prefix for a known survivor name +- duplicate explicit ids +- with `--strict`: attachment/variant class not defined anywhere + +Warnings: + +- `coefficient` set while stock is unlimited (the mod ignores it) +- sell price higher than buy price +- attachment not attachable according to the compat file +- item listed twice in a category; item defined twice with options +- category with no items; trader not referenced by any category +- auto-created hidden products (as notes) + +--- + +## Minimal complete example + +```yaml +defaults: + sell_percent: 30 + +categories: + - name: Food + traders: [Eve] + price: 50 + items: [Apple, Pear, BakedBeansCan] +``` + +builds three product files, one category file and sets Eve's `categoriesId`. diff --git a/docs/traderx-configuration-guide.md b/docs/traderx-configuration-guide.md new file mode 100644 index 0000000..eea755c --- /dev/null +++ b/docs/traderx-configuration-guide.md @@ -0,0 +1,565 @@ +# TraderX – Server Configuration Guide + +*Written from a read-through of the TraderX source (`TheDmitri/TraderX`, `main`, mod version string `1.0.1`). Everything below describes what the code actually does, not what the README promises. Where the code is inconsistent or a feature is only partially implemented, it is called out.* + +--- + +## 1. Directory layout + +All paths are under the server **profile** folder (`$profile:`), i.e. wherever your `-profiles=` startup parameter points. TraderX creates the folders it needs on first start. + +``` +/TraderX/ +├── TraderXConfig/ +│ ├── TraderXGeneralSettings.json ← traders (NPCs), licenses, accepted item states +│ ├── TraderXCurrencySettings.json ← currency types and denominations +│ ├── TraderXDynamicTranslationSettings.json ← notification / error strings +│ ├── TraderXDebugSettings.json ← debug mode + test suites +│ ├── TraderXTXSafeZoneSettings.json ← safe zone module +│ ├── SourceConfig.json ← which product/category format to load +│ ├── Products/ prod__NNN.json ← LEGACY format (one file per product) +│ ├── Categories/ cat__NNN.json ← LEGACY format (one file per category) +│ ├── Source/ products.csv, categories.csv, presets.csv ← CSV format +│ ├── Compiled/ Products_compiled.json, Categories_compiled.json ← COMPILED (auto-generated) +│ ├── Backup/ *.backup ← last successful compile +│ ├── TraderXVehicleParking/ +│ │ ├── parking_config.json +│ │ └── parkings_.json +│ └── DebugTestCases/ *.json +├── TraderXDatabase/ ← runtime state, do NOT hand-edit while server is up +│ ├── Stock/ .json ← current stock per product +│ └── PlayerLicenses/ playerLicense_.json +├── TraderXPresets/ presets_.json ← player-saved attachment presets +└── TraderXLogs/ + ├── Config/LoggingSettings.json + ├── Config/ConfigReport.log ← CSV validation report + └── Logs/TraderX_.log +``` + +--- + +## 2. Startup / load order (important for "my edit did nothing") + +On server start, `TraderXModule.InitializeModule()` does, in order: + +1. Converts old **TraderPlus V1** configs if found (`TraderPlusPriceConfig.json`, `TraderPlusGeneralConfig.json`). +2. Loads `SourceConfig.json` → decides LEGACY / CSV / COMPILED. +3. Loads products, then categories, then vehicle parkings. +4. Applies "stock behavior at restart" to every limited-stock product. +5. Loads `TraderXGeneralSettings.json` and spawns NPCs. + +### ⚠ The compiled files always win + +`TraderXProductRepository.LoadAllProducts()` and `LoadAllCategories()` check for `Compiled/Products_compiled.json` / `Categories_compiled.json` **first, regardless of `preferredFormat`**. If those files exist and are non-empty, the `Products/` and `Categories/` folders are ignored completely. + +So: if you edit legacy JSON files and nothing changes, check whether a `Compiled/` folder exists. Either delete it (it will be regenerated only in CSV/COMPILED mode) or switch to editing the CSV source. + +### Fallback chain + +``` +Compiled JSON → (empty/corrupt) → Legacy Products/ + Categories/ +``` + +In CSV mode, `Source/*.csv` is compiled to `Compiled/*.json` before this chain runs. If validation fails, the previous `Backup/` copy is restored. + +--- + +## 3. `SourceConfig.json` + +```json +{ + "preferredFormat": "LEGACY", + "autoMigrate": false, + "allowCompilation": true +} +``` + +| Field | Values | Meaning | +|---|---|---| +| `preferredFormat` | `"LEGACY"`, `"CSV"`, `"COMPILED"` (case-insensitive) | Which editing workflow you use. Default `LEGACY`. | +| `autoMigrate` | bool | One-shot conversion on next start. CSV mode: legacy JSON → CSV. COMPILED mode: legacy → CSV → compiled. The mod sets this back to `false` automatically after a successful migration. Original files are **not** deleted. | +| `allowCompilation` | bool | CSV mode only. `false` skips CSV → compiled (effectively disables CSV loading; not recommended). | + +**Recommended workflows** + +- **Small server / few items:** stay on `LEGACY`, edit one JSON per product. Make sure no `Compiled/` folder exists. +- **Large item lists / spreadsheet editing:** set `"CSV"` + `"autoMigrate": true` once, then edit `Source/products.csv` and `Source/categories.csv` in Excel/LibreOffice. Every restart recompiles. Check `TraderXLogs/Config/ConfigReport.log` after each restart. +- **Web editor:** https://traderpluseditor.dmitri-dev-area.dev exports the legacy per-file format. + +--- + +## 4. Products + +### 4.1 Legacy file: `TraderXConfig/Products/.json` + +```json +{ + "className": "SNAFU_MKahles", + "coefficient": 0.66874, + "maxStock": 5, + "tradeQuantity": 18, + "buyPrice": 110, + "sellPrice": 30, + "stockSettings": 0, + "attachments": [], + "variants": [] +} +``` + +#### ⚠ File name **is** the product ID and it is validated + +`TraderXProductId.IsValidUUID()` rejects (and **skips**, with an error in the log) any file that does not match: + +``` +prod__NNN.json +``` + +- must start with `prod_` +- `` = letters, digits, underscores (other characters are sanitized to `_`) +- must end with `_` + **exactly three digits**, `001`–`999` +- minimum total length 9 chars + +Valid: `prod_akm_001.json`, `prod_snafu_mkahles_001.json` +**Invalid (silently skipped):** `prod_snafu_mkahles.json`, `akm.json`, `prod_akm_1.json`, `prod_akm_1000.json` + +The `productId` inside the file, if present, is **ignored** in legacy mode; the ID always comes from the filename. (In `Compiled/` files the `productId` field is used.) + +Log lines to grep for: `Skipping invalid UUID filename` and `Invalid product ID`. + +#### Field reference + +| Field | Type | Meaning | +|---|---|---| +| `className` | string | DayZ class name. Compared case-insensitively. | +| `buyPrice` | int | Price the player pays. `-1` = cannot be bought (item is sell-only). | +| `sellPrice` | int | Price the player receives. `-1` = cannot be sold (buy-only). | +| `maxStock` | int | `-1` = unlimited. `0` or more = limited stock, tracked in `TraderXDatabase/Stock/.json`. | +| `coefficient` | float | Dynamic price factor. **Only used when `maxStock != -1` and `coefficient != 1.0`**. See §4.2. | +| `tradeQuantity` | int (bitfield) | How full the item is when bought / must be when sold. See §4.3. | +| `stockSettings` | int (bitfield) | De-stock rate + restart behavior. See §4.4. | +| `attachments` | string[] | Product IDs (`prod_…`) that can be attached at purchase (customize page / presets). Each must be its own product file. | +| `variants` | string[] | Product IDs shown as colour/skin variants of this product. | + +### 4.2 Dynamic pricing (how `coefficient` actually works) + +From `TraderXPriceCalculation`: + +``` +if maxStock == -1 or coefficient == 1.0: + price = round(basePrice × quantity × stateMultiplier) + +else, per unit, with s = current trader stock of this product: + unitPrice = coefficient ^ (s − 1) × basePrice × stateMultiplier + (s is clamped to 1 if 0) + buy: s increases by 1 for each additional unit in the cart + sell: s increases by 1 for each additional unit sold + total = round(Σ unitPrice), minimum 1 +``` + +Consequences: + +- **`coefficient` does nothing if `maxStock` is `-1`.** Unlimited stock ⇒ flat price. If you want dynamic prices you need a finite `maxStock`. +- With stock `s = 1` the price equals `basePrice`. Every additional unit in stock multiplies by `coefficient`. + Example: `buyPrice 100`, `coefficient 0.9`: stock 1 → 100, stock 2 → 90, stock 5 → 65.6, stock 10 → 38.7. +- Lower coefficient = steeper price drop as the trader fills up. `0.9`–`0.98` is a sensible range; `0.66` (as in the sample above) collapses the price to ~5 % by stock 8. +- The same formula is used for buy and sell, so a trader that is out of stock (s = 0 → treated as 1) sells at full `sellPrice`. +- `stateMultiplier` (item condition) for sales: pristine 1.0, worn 0.8, damaged 0.6, badly damaged 0.4, ruined 0 (refused). Whether worn/damaged/badly-damaged items are accepted at all is set in `TraderXGeneralSettings.json → acceptedStates` (§6). Note: `TraderXStates.coefficientWorn/Damaged/BadlyDamaged` exist in the settings file but the pricing code uses the hard-coded `TraderXItemState` multipliers above. + +### 4.3 `tradeQuantity` bitfield + +Decoded at trade time by `TraderXTradeQuantity`: + +``` +bits 0–2 sellMode +bits 3–5 buyMode +bits 6–18 sellValue (13 bits, 0–8191) +bits 19–31 buyValue +``` + +| Mode value | Buy meaning | Sell meaning | +|---|---|---| +| 0 | `BUY_EMPTY` – item spawns empty | `SELL_NO_MATTER` – any fill level accepted, paid pro-rata | +| 1 | – | `SELL_EMPTY` – must be empty | +| 2 | `BUY_FULL` – item spawns full | `SELL_FULL` – must be full | +| 3 | `BUY_COEFFICIENT` – full × (buyValue/100) | `SELL_COEFFICIENT` – max × (sellValue/100) | +| 4 | `BUY_STATIC` – exactly `buyValue` units | `SELL_STATIC` – exactly `sellValue` units | + +`tradeQuantity = sellMode | (buyMode << 3) | (sellValue << 6) | (buyValue << 19)` + +**Common values** + +| Value | Meaning | +|---|---| +| `0` | buy empty, sell any | +| `2` | buy empty, sell only full | +| `16` | buy full, sell any | +| `18` | **buy full, sell full** (most common for mags, food, etc.) | +| `78644644` | (README example) buy static 150, sell static 22 – for stackables like mushrooms | + +Quick decode in bash: + +```bash +tq=18; echo "sell=$((tq&7)) buy=$(((tq>>3)&7)) sellVal=$(((tq>>6)&0x1FFF)) buyVal=$((tq>>19))" +``` + +> ⚠ **Known inconsistency:** the CSV compiler (`TraderXBitfieldPacker`) packs values with a *different* layout (sell value in bits 6–15 ×1000, buy value in bits 16–31 ×1000) than the runtime decoder above. For `FULL`/`EMPTY`/`NO_MATTER` modes this doesn't matter (values are 0). For `COEFFICIENT`/`STATIC` products created via CSV, the resulting quantities may be wrong. If you need STATIC/COEFFICIENT quantities, verify in-game, or set `tradeQuantity` directly in legacy JSON using the runtime layout. + +### 4.4 `stockSettings` bitfield + +``` +bits 0–6 destockCoefficient × 100 (0–100) +bits 7–8 stockBehaviorAtRestart (0, 1, 2) +``` + +`stockSettings = round(destock × 100) | (behavior << 7)` + +| `stockBehaviorAtRestart` | Effect on server start | +|---|---| +| 0 | keep stock as saved | +| 1 | reset stock to `maxStock` | +| 2 | random stock between 0 and `maxStock` | + +`destockCoefficient` (0.0–1.0): additionally, on every restart, `stock = round(stock × (1 − destock))`. Applied *after* the behavior above. + +| Example | Value | +|---|---| +| keep, no destock | `0` | +| reset to max every restart | `128` | +| random every restart | `256` | +| keep, lose 25 % per restart | `25` | +| reset to max + destock 10 % (nets 90 %) | `138` | + +Only applies when `maxStock != -1`. + +### 4.5 Stock database + +`TraderXDatabase/Stock/.json`: + +```json +{ "productId": "prod_akm_001", "stock": 3 } +``` + +Created automatically the first time a limited-stock product is touched. Buying decreases stock, selling increases it (capped at `maxStock` – trader refuses with "Trader stock is full"). Safe to delete the whole `Stock/` folder while the server is **down** to reset everything to 0 (or to `maxStock` if `stockBehaviorAtRestart = 1`). + +### 4.6 Attachments and variants + +- Every entry in `attachments` / `variants` is a **product ID**, not a class name. `["prod_mag_akm_30rnd_001", "prod_pso1optic_001"]` +- The referenced product must exist (be a valid file). Missing references are simply dropped at runtime; the CSV validator reports them as errors. +- Attachment products need their own `buyPrice`; the customize page adds their price to the base item. +- Attachments are also stock-checked: an out-of-stock attachment blocks the purchase ("Attachment out of stock"). +- Variants are shown as selectable alternatives on the purchase page; each variant is a full product with its own price/stock. + +--- + +## 5. Categories + +### 5.1 Legacy file: `TraderXConfig/Categories/.json` + +File name rule is the same as products, but with the `cat_` prefix and **lowercase** letters only in the middle part: + +``` +cat__NNN.json e.g. cat_assault_rifles_001.json +``` + +```json +{ + "isVisible": true, + "icon": "", + "categoryName": "Assault Rifles", + "licensesRequired": [], + "productIds": [ + "prod_akm_001", + "prod_m4a1_001" + ] +} +``` + +| Field | Meaning | +|---|---| +| `isVisible` | `false` hides the category from the catalog (products stay usable as attachments/variants). | +| `icon` | Path to an image passed to `ImageWidget.LoadImageFile()`, e.g. `"TraderX/datasets/images/car.edds"` or a `set:… image:…` string. Empty = no icon. | +| `categoryName` | Display name. Also used for `GetCategoryIdByName` lookups (case-insensitive). | +| `licensesRequired` | Array of **license IDs** (the `licenseId` UUID from `TraderXGeneralSettings.json`, not the name). Player must hold all of them or the card is greyed out. | +| `productIds` | Ordered list of product IDs shown in this category. A product can appear in multiple categories. | + +Categories are assigned to traders in `TraderXGeneralSettings.json → traders[].categoriesId`. + +--- + +## 6. `TraderXGeneralSettings.json` + +Generated with defaults on first start (a Chernarus example trader camp). Structure: + +```json +{ + "version": "1.0.1", + "serverID": "", + "licenses": [ + { + "licenseId": "", + "licenseName": "Car License", + "description": "Allows you to drive a car." + } + ], + "acceptedStates": { + "acceptWorn": true, + "acceptDamaged": true, + "acceptBadlyDamaged": true, + "coefficientWorn": 0.75, + "coefficientDamaged": 0.5, + "coefficientBadlyDamaged": 0.25 + }, + "traders": [ + { + "npcId": 0, + "className": "SurvivorM_Boris", + "givenName": "Boris", + "role": "Weapons", + "position": [11596.6, 58.7, 14630.3], + "orientation": [180.0, 0.0, 0.0], + "categoriesId": ["cat_assault_rifles_001", "cat_pistols_001"], + "currenciesAccepted": ["EUR"], + "loadouts": [ + { "className": "TTSKOJacket_Camo", "quantity": -1, "slotName": "Body", "attachments": [] }, + { "className": "TTSKOPants", "quantity": -1, "slotName": "Legs", "attachments": [] }, + { + "className": "AKM", "quantity": -1, "slotName": "Hand", + "attachments": [ { "className": "Mag_AKM_30Rnd", "quantity": 30 } ] + } + ] + } + ], + "traderObjects": [ + { "className": "bldr_Misc_RoadBarrier", "position": [11537.2, 59.0, 14726.1], "orientation": [55.0, 0.0, 0.0] } + ] +} +``` + +### `licenses[]` +Define the license catalogue here. `licenseId` must be unique; generate any UUID. Reference the **ID** from `categories[].licensesRequired`. + +There is **no in-game UI or admin command to grant licenses** in this codebase. Licenses are granted by editing `TraderXDatabase/PlayerLicenses/playerLicense_.json`: + +```json +{ + "steamId": "76561198000000000", + "playerName": "Someone", + "licenses": [ + { "licenseId": "", "licenseName": "Car License", "description": "" } + ] +} +``` + +The file is read when the player joins; edit while they're offline. + +### `acceptedStates` +Controls which conditions the trader will **buy**. Ruined is never accepted. The `coefficient*` values are stored but not used by the current pricing code (see §4.2) – condition discounts are 0.8 / 0.6 / 0.4. + +### `traders[]` + +| Field | Meaning | +|---|---| +| `npcId` | Integer, must be unique. Used for parking file names (`parkings_.json`) and session tracking. | +| `className` | Any `PlayerBase`-derived class (e.g. `SurvivorM_Boris`, `SurvivorF_Linda`) **or** a static object that inherits `BuildingBase`. Anything else logs "obj was not created" and is skipped. | +| `givenName`, `role` | Shown in the action prompt (`Trade – Boris (Weapons)`) and in the menu header. | +| `position`, `orientation` | World coordinates. `orientation` is `[yaw, pitch, roll]`. | +| `categoriesId` | Category IDs this trader sells. Order = display order. | +| `currenciesAccepted` | Names from `TraderXCurrencySettings.json → currencyTypes[].currencyName`. Empty = all currencies. | +| `loadouts` | Clothing/items for the NPC. `slotName` = DayZ inventory slot (`Body`, `Legs`, `Feet`, `Headgear`, `Vest`, `Back`, `Gloves`, `Mask`, `Eyewear`, `Shoulder`, `Melee`…), `"Hand"` = in hands, empty string = into cargo. `quantity -1` = don't touch. Attachments spawn onto the item (magazines are loaded into weapons). | + +NPCs are invulnerable (`SetAllowDamage(false)`). + +### `traderObjects[]` +Kept for TraderPlus V1 compatibility and written by the converter, but **no code in this version spawns them**. Use your map's object spawner (init.c / DayZ Editor loader) for decoration. + +--- + +## 7. `TraderXCurrencySettings.json` + +```json +{ + "version": "1.0.1", + "currencyTypes": [ + { + "currencyName": "EUR", + "currencies": [ + { "className": "TraderX_Money_Euro100", "value": 100 }, + { "className": "TraderX_Money_Euro50", "value": 50 }, + { "className": "TraderX_Money_Euro10", "value": 10 }, + { "className": "TraderX_Money_Euro5", "value": 5 }, + { "className": "TraderX_Money_Euro1", "value": 1 } + ] + } + ] +} +``` + +- Each `currencyType` is an independent wallet. A trader with `currenciesAccepted: ["EUR"]` counts only EUR items. +- `value` is per unit; stackable money items are multiplied by their quantity. Always include a `value: 1` denomination or change can't be given. +- Any item class can be money (nails, bullets, custom `TraderX_Money_*` items from the Workshop build). The `TraderX_Money_*` classes are **not** defined in this repo's `config.cpp`; they ship with the Workshop package. +- Note: the default generator has a bug and puts the USD denominations into the EUR type. Check the generated file before use. + +Money is counted by walking the player's whole inventory (including nested containers). + +--- + +## 8. Vehicles + +A product is treated as a vehicle if its class inherits `Transport` (cars, boats). Vehicles: + +- can only be bought/sold one at a time; +- on purchase spawn at a free parking spot for that trader; no free spot ⇒ "No parking available"; +- on sale the player must be near the vehicle, and it must not be occupied or engine-running. + +### `TraderXVehicleParking/parkings_.json` + +```json +{ + "traderId": "3", + "positions": [ + { "position": [11620.0, 58.9, 14690.0], "rotation": [50.0, 0.0, 0.0], "size": [3.0, 2.0, 6.0] }, + { "position": [11625.0, 58.9, 14694.0], "rotation": [50.0, 0.0, 0.0], "size": [3.0, 2.0, 6.0] } + ] +} +``` + +`size` = width, height, length of the box that must be free of non-whitelisted objects. `traderId` = the trader's `npcId` as a string; the file name must match. + +### `TraderXVehicleParking/parking_config.json` + +```json +{ "whitelistedObjects": ["Land_Grass", "Land_Stone", "Land_Dirt", "Land_Sand", "Land_Gravel", "ClutterCutter", "Particle", "EffectSound", "Land_Underground", "Terrain"] } +``` + +Object class-name substrings that do **not** count as "blocking" a parking spot. Add your barrier/prop classes here if spots are always reported as occupied. + +--- + +## 9. CSV format (`Source/`) + +Files are **tab-delimited** (despite the `.csv` extension), first row = header, header names case-insensitive. Multiple packs are supported: `10_vanilla.csv`, `50_community_weapons.csv` merge in filename order (numeric prefix = priority). Quotes `"…"` are stripped and allow tabs inside a field. + +### `products.csv` columns + +| Column | Notes | +|---|---| +| `productKey` | Becomes the product ID. Use the `prod__NNN` form to stay compatible with legacy export. | +| `className` | | +| `buyPrice`, `sellPrice` | `-1` = not tradeable that direction | +| `maxStock` | `-1` = unlimited | +| `buyQtyMode` | `EMPTY`, `FULL`, `COEFFICIENT`, `STATIC` | +| `buyQtyValue` | for COEFFICIENT (0–1) / STATIC | +| `sellQtyMode` | `NO_MATTER`, `EMPTY`, `FULL`, `COEFFICIENT`, `STATIC` | +| `sellQtyValue` | | +| `destockCoefficient` | 0.0–1.0 | +| `stockBehaviorAtRestart` | 0 / 1 / 2 | +| `attachments` | `;`-separated productKeys | +| `variants` | `;`-separated productKeys | +| `notes` | ignored by the game | + +> `coefficient` (price coefficient) is **not** a CSV column in this version – the compiler uses the class default (`1.0`). If you rely on dynamic pricing, use legacy JSON or edit `Compiled/Products_compiled.json` directly (COMPILED mode). + +### `categories.csv` columns +`categoryKey`, `categoryName`, `icon`, `isVisible` (1/0/true/false), `licensesRequired` (`;`-separated license IDs), `productKeys` (`;`-separated), `notes`. + +### `presets.csv` columns +`presetKey`, `productKey`, `presetName`, `attachments` (`;`-separated productKeys), `notes` – server-defined attachment presets. + +### Validation +Every restart writes `TraderXLogs/Config/ConfigReport.log`. Errors (duplicate keys, empty className, unknown product references, out-of-range values) abort compilation and restore the last good `Backup/`. Warnings do not. + +--- + +## 10. Other settings files + +### `TraderXDynamicTranslationSettings.json` +Every player-facing notification string (`successfullyBought`, `insufficientFunds`, `traderStockFull`, …). `%1`, `%2` are placeholders. Edit for localisation or flavour. + +### `TraderXLogs/Config/LoggingSettings.json` +```json +{ "logLevel": 0, "refreshRateInSeconds": 60 } +``` +`logLevel`: 0 Debug, 1 Info, 2 Warn, 3 Error. Debug is very verbose (every price calculation); use 1 in production. + +### `TraderXDebugSettings.json` +`enableDebugMode`, `runTestsOnStartup` + per-suite flags, `authorizedAdmins` (Steam64 list). Leave `runTestsOnStartup: false` on a live server – the tests create and destroy items. + +### `TraderXTXSafeZoneSettings.json` (TraderXSafeZone module) +```json +{ + "version": "1.0.0", + "notificationTimer": 10, + "notificationTitle": "SafeZone", + "msgOnEnteringZone": "You entered the safezone!", + "msgOnLeavingZone": "You have left the safezone!", + "leaveZoneCountdown": 30, + "msgOnLeavingZoneCountdown": "You are leaving the safezone in %1 seconds", + "adminSteamUIDs": ["76561198000000000"], + "enableAfkDisconnect": false, + "kickAfterDelay": 300, + "cleanupTimer": 1200, + "objectsToDelete": ["Paper"], + "locations": [ + { "id": 1, "isActive": true, "name": "Trader Camp", "position": [11600, 58, 14650], "radius": 150, + "isEntitiesCleanActive": true, "allowedAnimals": [] } + ] +} +``` +Inside an active zone: no damage, melee, fire, traps, zombies; `objectsToDelete` are removed every `cleanupTimer` seconds; AFK kick after `kickAfterDelay` seconds if enabled. Admins in `adminSteamUIDs` are exempt. + +--- + +## 11. Bulk-editing tips (Linux) + +Set every `prod_*.json` in the folder to unlimited stock and coefficient 0.9: + +```bash +cd ~/serverprofile/TraderX/TraderXConfig/Products +sed -i -E 's/("maxStock":\s*)[-0-9.]+/\1-1/; s/("coefficient":\s*)[-0-9.]+/\10.90/' prod_*.json +``` + +(Reminder: with `maxStock: -1` the coefficient is ignored – see §4.2.) + +Find product files that will be rejected by the filename validator: + +```bash +ls | grep -Ev '^prod_[A-Za-z0-9_]+_[0-9]{3}\.json$' +``` + +Rename them by appending `_001`: + +```bash +for f in prod_*.json; do + [[ $f =~ _[0-9]{3}\.json$ ]] || mv -- "$f" "${f%.json}_001.json" +done +``` + +Then update every category's `productIds` to the new names (they must match exactly), e.g.: + +```bash +sed -i -E 's/("prod_[a-z0-9_]+)"/\1_001"/g' ../Categories/*.json # review before running +``` + +Reset all stock (server stopped): + +```bash +rm -f ~/serverprofile/TraderX/TraderXDatabase/Stock/*.json +``` + +--- + +## 12. Troubleshooting checklist + +| Symptom | Check | +|---|---| +| Product missing from trader | Filename matches `prod__NNN.json`; product ID listed in a category's `productIds`; category listed in the trader's `categoriesId`; no stale `Compiled/` folder. | +| Category greyed out with lock | `licensesRequired` contains license IDs the player doesn't have in `PlayerLicenses/`. | +| Prices never change | `maxStock` is `-1` or `coefficient` is `1.0`. | +| Price drops to 1 | `coefficient` too low for your `maxStock`. | +| "Trader stock is full" on sell | stock ≥ `maxStock`; raise `maxStock` or set `-1`. | +| Vehicle "No parking available" | No `parkings_.json` for that trader, or all spots blocked – add blocking classes to `parking_config.json`. | +| NPC not spawning | `className` must inherit `PlayerBase` or `BuildingBase`; look for "obj was not created" in the log. | +| Edits ignored | `Compiled/` folder present while editing legacy files. | +| Can't pay despite having money | Trader's `currenciesAccepted` doesn't include the currency type holding those items. | + +Log file: `TraderXLogs/Logs/TraderX_.log`. Startup logs the format used (`Configuration format selected: …`) and product/category counts (`LoadAllProducts - Completed. Total products loaded: N`). diff --git a/examples/compat.example.json b/examples/compat.example.json new file mode 100644 index 0000000..dcd271c --- /dev/null +++ b/examples/compat.example.json @@ -0,0 +1,10 @@ +{ + "MyWeaponMod": [ + { + "name": "AKM", + "attachments": ["AK_WoodBttstck", "PSO1Optic", "KobraOptic", "AK_Suppressor", "SNAFU_AKKahles"], + "bullets": ["Ammo_762x39"], + "magazines": ["Mag_AKM_30Rnd", "Mag_AKM_Drum75Rnd"] + } + ] +} diff --git a/examples/expansion.example.json b/examples/expansion.example.json new file mode 100644 index 0000000..21ad2ea --- /dev/null +++ b/examples/expansion.example.json @@ -0,0 +1,32 @@ +{ + "m_Version": 12, + "DisplayName": "Medical Tools", + "Icon": "Deliver", + "Color": "FBFCFEFF", + "IsExchange": 0, + "InitStockPercent": 75, + "Items": [ + { + "ClassName": "TerjeSyringeNew", + "MaxPriceThreshold": 5000, + "MinPriceThreshold": 5000, + "SellPricePercent": -1, + "MaxStockThreshold": 100, + "MinStockThreshold": 100, + "QuantityPercent": -1, + "SpawnAttachments": [], + "Variants": [] + }, + { + "ClassName": "AKM", + "MaxPriceThreshold": 1000, + "MinPriceThreshold": 500, + "SellPricePercent": 30, + "MaxStockThreshold": 10, + "MinStockThreshold": 1, + "QuantityPercent": 50, + "SpawnAttachments": ["Mag_AKM_30Rnd", "PSO1Optic"], + "Variants": ["AKM_Tan"] + } + ] +} diff --git a/examples/plain.example.txt b/examples/plain.example.txt new file mode 100644 index 0000000..6348a47 --- /dev/null +++ b/examples/plain.example.txt @@ -0,0 +1,4 @@ +# one class per line; optional ",buyPrice[,sellPrice]" +FC_Conserv_Angel_Closed +FC_Conserv_Angel_Opened +BandageDressing,40,10 diff --git a/examples/source.example.yaml b/examples/source.example.yaml new file mode 100644 index 0000000..35b25f0 --- /dev/null +++ b/examples/source.example.yaml @@ -0,0 +1,67 @@ +defaults: + stock: unlimited + coefficient: 1.0 + sell_percent: 25 + buy: full + sell: full + restart: keep + destock: 0 + attachment_price: 50 + attachment_sell_percent: 25 +categories: +- name: Export + traders: + - Boris + items: + - FC_Conserv_Angel_Closed: + price: 1000 + sell: 100 + buy: empty + sell_mode: empty + - FC_Conserv_Angel_Opened: + price: 1000 + sell: 100 + buy: empty + sell_mode: empty + - FC_Conserv_Angelfish_Closed: + price: 1000 + sell: 100 + stock: 5 + coefficient: 0.9 +- name: Fc Conserv Angel + traders: + - 1 + items: + - FC_Conserv_Angel_Closed: + price: 100 + - AKM: + price: 1000 + sell: 300 + stock: 10 + coefficient: 0.9259 + buy: coef 50% + attachments: + - ak_kit + - M4_Suppressor + variants: + - AKM_Tan + presets: + Scoped*: + - PSO1Optic + - Mag_AKM_30Rnd + Quiet: + - M4_Suppressor +- name: Food + traders: + - Eve + price: 50 + items: + - FC_Conserv_Angel_Closed + - FC_Conserv_Angel_Opened + - BandageDressing: + price: 40 + sell: 10 +groups: + ak_kit: + - Mag_AKM_30Rnd + - PSO1Optic diff --git a/examples/traderplus.example.json b/examples/traderplus.example.json new file mode 100644 index 0000000..77ba81e --- /dev/null +++ b/examples/traderplus.example.json @@ -0,0 +1,15 @@ +{ + "EnableAutoCalculation": 1, + "EnableAutoDestockAtRestart": 0, + "EnableDefaultTraderStock": 0, + "TraderCategories": [ + { + "CategoryName": "Canned Food", + "Products": [ + "FC_Conserv_Angel_Closed,1,-1,18,1000,100", + "FC_Conserv_Angel_Opened,1,-1,0,1000,100", + "FC_Conserv_Angelfish_Closed,0.9,5,18,1000,100" + ] + } + ] +} diff --git a/examples/weapons-with-presets.example.yaml b/examples/weapons-with-presets.example.yaml new file mode 100644 index 0000000..6dbb38f --- /dev/null +++ b/examples/weapons-with-presets.example.yaml @@ -0,0 +1,39 @@ +# Example: a weapons category that uses groups, variants, compat-driven +# attachments and server presets. Build with: +# traderx_build.py build weapons-with-presets.example.yaml --out /path/TraderX --compat compat.example.json --dry-run +defaults: + stock: unlimited + sell_percent: 25 + attachment_price: 150 # price for optics/mags that are not sold anywhere else + +groups: + ak_optics: [PSO1Optic, KobraOptic, SNAFU_AKKahles] + ak_muzzle: [AK_Suppressor] + ak_mags: [Mag_AKM_30Rnd, Mag_AKM_Drum75Rnd] + +categories: + - name: Assault Rifles + traders: [Boris] + licenses: [Weapons License] # created in TraderXGeneralSettings.json if missing + price: 1200 + items: + - AKM: + attachments: [ak_optics, ak_muzzle, ak_mags] + variants: [AKM_Tan] # inherits AKM's attachments + presets: + "CQB*": [KobraOptic, AK_Suppressor, Mag_AKM_30Rnd] # * = default preset + "DMR": [PSO1Optic, Mag_AKM_30Rnd] + - AKM_Tan + - SNAFU_AK103A: {price: 1100, stock: 5, coefficient: 0.95, restart: reset} + + - name: AK Optics + traders: [Boris] + price: 400 + items: [PSO1Optic, KobraOptic, SNAFU_AKKahles] + + - name: Ammo + traders: [Boris] + price: 60 + buy: static 30 # buying gives a stack of 30 + sell: any # any stack size accepted, paid pro-rata + items: [Ammo_762x39, Ammo_545x39] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9465ec6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyyaml>=6.0 # optional: enables .yaml sources; .json sources work without it diff --git a/scripts/prestart-check.sh b/scripts/prestart-check.sh new file mode 100644 index 0000000..28ef10a --- /dev/null +++ b/scripts/prestart-check.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Validate the TraderX config before starting the server. Exits non-zero on errors. +# Usage: scripts/prestart-check.sh /path/to/profiles/TraderX [source.yaml] [compat.json] +set -euo pipefail +HERE="$(cd "$(dirname "$0")/.." && pwd)" +PROFILE="${1:?TraderX profile folder required}" +SRC="${2:-}" +COMPAT="${3:-}" + +if [[ -n "$SRC" ]]; then + args=(check "$SRC") + [[ -n "$COMPAT" ]] && args+=(--compat "$COMPAT") + python3 "$HERE/traderx_build.py" "${args[@]}" +fi +python3 "$HERE/traderx_check.py" "$PROFILE" --ignore "coefficient" --ignore "not listed" --quiet-info diff --git a/traderx_build.py b/traderx_build.py new file mode 100644 index 0000000..f08249c --- /dev/null +++ b/traderx_build.py @@ -0,0 +1,967 @@ +#!/usr/bin/env python3 +""" +traderx_build.py - Maintain TraderX trader configuration from ONE human-readable +source file (class names, words instead of bitfields, reusable attachment groups) +and compile it into the mod's legacy Products/ Categories/ Presets files. + +Commands +-------- + import-traderx reverse-import an existing config + import-traderplus TraderPlus v1 export + import-expansion Expansion market export + import-plain --price N plain class list + build --out [--dry-run] [--clean] + check validate only + +Every import command writes a source file (-o) or appends a category into an +existing one (--into source.yaml). Use --category / --trader to name the +category and the trader(s) it belongs to. + +Source schema (YAML shown; JSON with the same keys works without pyyaml) +----------------------------------------------------------------------- +defaults: # every key optional + stock: unlimited # unlimited | + coefficient: 1.0 + sell_percent: 25 # sell = buy * 25% when 'sell' not given + buy: full # empty | full | static N | coef N% (item quantity on purchase) + sell: full # any | empty | full | static N | coef N% + restart: keep # keep | reset | random + destock: 0 # 0..1 fraction removed on restart + attachment_price: 50 # price for attachments that are not defined anywhere else + attachment_sell_percent: 25 + +groups: # reusable lists of class names (or other group names) + ak_optics: [SNAFU_AKAimpoint_M5, SNAFU_AKKahles] + +items: # optional: products not shown in any category (attachments etc.) + - SNAFU_AKKahles: {price: 800} + +categories: + - name: Assault Rifles + id: cat_assault_rifles_001 # optional, derived from name if absent + traders: [Boris, 3] # givenName or npcId from TraderXGeneralSettings.json + licenses: [Weapons License] # names; created in GeneralSettings if missing + visible: true + icon: "" + price: 1000 # category default buy price + stock / coefficient / sell_percent / buy / sell / restart / destock (category defaults) + items: + - SNAFU_AK12A # bare class -> category defaults + - SNAFU_AK103A: {price: 1200, sell: 300, stock: 5, coefficient: 0.95} + - SNAFU_AK19: + id: prod_snafu_ak19_001 # optional, keeps an existing product id / stock file + attachments: [ak_optics, SNAFU_Standardized_Suppressor_Black] + - SNAFU_AK12A: + attachments: ["@compat", "@compat_mags"] # needs build --compat FILE: + # @compat = every compatible attachment that is a product in this source + # @compat_mags = same for magazines + # @compat_all = everything compatible, auto-creating missing products + variants: [SNAFU_AK19_Tan] # variants inherit attachments unless they define their own + presets: + "CQB*": [SNAFU_AKAimpoint_M5, SNAFU_Standardized_Suppressor_Black] # * = default preset + "DMR": [SNAFU_AKKahles] +""" + +import argparse +import json +import os +import re +import shutil +import sys +from collections import OrderedDict +from datetime import datetime + +try: + import yaml # type: ignore +except ImportError: # pragma: no cover + yaml = None + +# --------------------------------------------------------------------------- +# constants mirrored from the mod +# --------------------------------------------------------------------------- +SELL_MODES = {"any": 0, "no_matter": 0, "empty": 1, "full": 2, "coef": 3, "static": 4} +BUY_MODES = {"empty": 0, "full": 2, "coef": 3, "static": 4} +SELL_MODE_NAMES = {0: "any", 1: "empty", 2: "full", 3: "coef", 4: "static"} +BUY_MODE_NAMES = {0: "empty", 1: "empty", 2: "full", 3: "coef", 4: "static"} +RESTART = {"keep": 0, "reset": 1, "random": 2} +RESTART_NAMES = {0: "keep", 1: "reset", 2: "random"} + +PRODUCT_KEYS = ["className", "coefficient", "maxStock", "tradeQuantity", "buyPrice", + "sellPrice", "stockSettings", "attachments", "variants"] + +DEFAULTS = { + "stock": "unlimited", "coefficient": 1.0, "sell_percent": 25, + "buy": "full", "sell": "full", "restart": "keep", "destock": 0, + "attachment_price": 50, "attachment_sell_percent": 25, +} +ITEM_LEVEL_KEYS = ("price", "sell", "stock", "coefficient", "buy", "sell_mode", + "restart", "destock", "attachments", "variants", "presets", "id", "sell_percent") + +KNOWN_SURVIVORS = { + "M": "Boris Cyril Denis Elias Francis Guo Hassan Indar Jose Kaito Lewis Lucas Malik Manua Mirek Niki Oliver Peter Quinn Rolf Seth Taiki".split(), + "F": "Eva Frida Gabi Helga Irena Judy Keiko Linda Maria Naomi Baty".split(), +} + + +class BuildError(Exception): + pass + + +def is_abstract(cls): + return cls.endswith("_Base") or cls.endswith("Base") and cls[:-4].endswith("_") or cls.startswith("Test_") or cls.endswith("_TEST") + + +def load_compat(paths): + """Load one or more 'All classes and their attachments' files. + Returns {lower_classname: {"attachments": [...], "magazines": [...], "bullets": [...]}}""" + out = {} + for p in paths or []: + with open(p, encoding="utf-8-sig") as f: + d = json.load(f) + entries = [] + if isinstance(d, dict): + for v in d.values(): + if isinstance(v, list): + entries.extend(v) + elif isinstance(d, list): + entries = d + for e in entries: + if not isinstance(e, dict) or not e.get("name"): + continue + rec = out.setdefault(e["name"].lower(), {"name": e["name"], "attachments": [], "magazines": [], "bullets": []}) + for k in ("attachments", "magazines", "bullets"): + for c in e.get(k, []) or []: + if c and not is_abstract(c) and c not in rec[k]: + rec[k].append(c) + return out + + +# --------------------------------------------------------------------------- +# small helpers +# --------------------------------------------------------------------------- +def sanitize(s, lowercase=True): + s = s.lower() if lowercase else s + s = re.sub(r"[^A-Za-z0-9_]", "_", s) + s = re.sub(r"_+", "_", s).strip("_") + return s or "x" + + +def load_source(path): + with open(path, "r", encoding="utf-8-sig") as f: + text = f.read() + if path.lower().endswith((".yaml", ".yml")): + if not yaml: + sys.exit("pyyaml is not installed: pip install pyyaml (or use a .json source)") + return yaml.safe_load(text) or {} + return json.loads(text) + + +def dump_source(data, path): + with open(path, "w", encoding="utf-8") as f: + if path.lower().endswith((".yaml", ".yml")) and yaml: + yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True, width=120) + else: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + +def load_json(path): + with open(path, "r", encoding="utf-8-sig") as f: + return json.load(f) + + +def save_json(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + +def find_traderx_root(path): + """Return the TraderX profile folder (parent of TraderXConfig).""" + path = os.path.abspath(path) + for c in (path, os.path.join(path, "TraderX")): + if os.path.isdir(os.path.join(c, "TraderXConfig")): + return c + return path # will be created on build + + +def parse_qty(spec, is_buy): + """'full' | 'empty' | 'any' | 'static 22' | 'coef 50%' | 'coef 0.5' -> (mode, value)""" + if isinstance(spec, (int, float)) and not isinstance(spec, bool): + return ("static", int(spec)) + s = str(spec).strip().lower() + parts = s.split() + mode = parts[0] + modes = BUY_MODES if is_buy else SELL_MODES + if mode not in modes: + raise BuildError(f"unknown {'buy' if is_buy else 'sell'} quantity '{spec}' " + f"(use {', '.join(sorted(set(modes)))})") + val = 0 + if mode in ("static", "coef"): + if len(parts) < 2: + raise BuildError(f"'{spec}' needs a value, e.g. 'static 22' or 'coef 50%'") + v = parts[1].rstrip("%") + val = float(v) + if mode == "coef": + if parts[1].endswith("%") or val > 1: + val = val # already percent + else: + val = val * 100 + val = int(round(val)) + return (mode, val) + + +def pack_trade_quantity(buy_spec, sell_spec): + bm, bv = parse_qty(buy_spec, True) + sm, sv = parse_qty(sell_spec, False) + if not 0 <= sv <= 0x1FFF: + raise BuildError(f"sell value {sv} out of range 0-8191") + return SELL_MODES[sm] | (BUY_MODES[bm] << 3) | (sv << 6) | (bv << 19) + + +def unpack_trade_quantity(tq): + sm, bm, sv, bv = tq & 7, (tq >> 3) & 7, (tq >> 6) & 0x1FFF, tq >> 19 + sell = SELL_MODE_NAMES.get(sm, "full") + buy = BUY_MODE_NAMES.get(bm, "full") + if sell == "static": + sell = f"static {sv}" + elif sell == "coef": + sell = f"coef {sv}%" + if buy == "static": + buy = f"static {bv}" + elif buy == "coef": + buy = f"coef {bv}%" + return buy, sell + + +def pack_stock_settings(restart, destock): + if restart not in RESTART: + raise BuildError(f"restart must be keep|reset|random, got '{restart}'") + d = float(destock) + if not 0 <= d <= 1: + raise BuildError(f"destock must be 0..1, got {destock}") + return int(round(d * 100)) | (RESTART[restart] << 7) + + +def unpack_stock_settings(ss): + return RESTART_NAMES.get((ss >> 7) & 3, "keep"), (ss & 0x7F) / 100 + + +def parse_stock(v): + if v in (None, "unlimited", "inf", -1, "-1"): + return -1 + return int(v) + + +# --------------------------------------------------------------------------- +# BUILD +# --------------------------------------------------------------------------- +class Builder: + def __init__(self, src, root, dry_run=False, clean=False, strict=False, compat=None): + self.compat = compat or {} + self.src = src + self.root = root + self.cfg = os.path.join(root, "TraderXConfig") + self.dry = dry_run + self.clean = clean + self.strict = strict + self.defaults = dict(DEFAULTS, **(src.get("defaults") or {})) + self.groups = src.get("groups") or {} + self.errors, self.warnings, self.notes = [], [], [] + self.products = OrderedDict() # productId -> dict (mod format) + self.class_to_id = {} # lower classname -> productId + self.categories = OrderedDict() # categoryId -> dict + self.presets = OrderedDict() # productId -> TraderXPresets dict + self.cat_traders = OrderedDict() # categoryId -> [trader refs] + self.cat_licenses = {} # categoryId -> [license names] + self.used_ids = set() + self.gs = None + + # -- id allocation ----------------------------------------------------- + def alloc_id(self, prefix, name, wanted=None): + if wanted: + if wanted in self.used_ids: + raise BuildError(f"duplicate id {wanted}") + self.used_ids.add(wanted) + return wanted + base = f"{prefix}{sanitize(name)}" + n = 1 + while f"{base}_{n:03d}" in self.used_ids: + n += 1 + if n > 999: + raise BuildError(f"no free id for {name}") + pid = f"{base}_{n:03d}" + self.used_ids.add(pid) + return pid + + # -- normalisation ----------------------------------------------------- + @staticmethod + def norm_item(entry): + """Accept 'Class' | {'Class': {...}} | {'class': 'Class', ...} -> (class, opts)""" + if isinstance(entry, str): + return entry.strip(), {} + if isinstance(entry, dict): + if "class" in entry: + d = dict(entry) + return d.pop("class").strip(), d + if len(entry) == 1: + k, v = next(iter(entry.items())) + return k.strip(), (v or {}) + raise BuildError(f"cannot understand item entry {entry!r}") + + def compat_refs(self, ref, owner): + """@compat / @compat_mags / @compat_all -> class list from the compat file.""" + rec = self.compat.get(owner.lower()) + if not rec: + if not self.compat: + raise BuildError(f"{owner}: '{ref}' used but no --compat file was given") + self.warnings.append(f"{owner}: '{ref}' used but {owner} is not in the compat file") + return [] + if ref == "@compat_mags": + pool = rec["magazines"] + elif ref == "@compat_all": + pool = rec["attachments"] + rec["magazines"] + else: + pool = rec["attachments"] + if ref == "@compat_all": + return list(pool) + # only classes that are defined as products somewhere in the source + return [c for c in pool if c.lower() in self._declared] + + def expand_refs(self, lst, where, seen=None): + """Resolve group names recursively -> list of class names.""" + seen = seen or set() + out = [] + for ref in lst or []: + if isinstance(ref, str) and ref.startswith("@compat"): + out.extend(self.compat_refs(ref, where)) + elif isinstance(ref, str) and ref in self.groups: + if ref in seen: + raise BuildError(f"{where}: group '{ref}' references itself") + out.extend(self.expand_refs(self.groups[ref], where, seen | {ref})) + elif isinstance(ref, str): + out.append(ref.strip()) + else: + raise BuildError(f"{where}: bad reference {ref!r}") + res = [] + for c in out: + if c not in res: + res.append(c) + return res + + # -- pass 1: register every product ------------------------------------ + def register(self, cls, opts, ctx, category_id=None): + key = cls.lower() + if key in self.class_to_id: + pid = self.class_to_id[key] + if category_id and opts and any(k in opts for k in ITEM_LEVEL_KEYS if k not in ("attachments", "variants", "presets")): + self.warnings.append(f"{cls}: defined twice with options; first definition wins") + return pid + pid = self.alloc_id("prod_", cls, opts.get("id")) + self.class_to_id[key] = pid + price = opts.get("price", ctx.get("price")) + if price is None: + price = -1 + if category_id: + self.errors.append(f"{cls}: no price (set 'price' on the item, the category, or defaults)") + price = int(price) + if "sell" in opts and not isinstance(opts["sell"], str): + sell = int(opts["sell"]) + elif "sell" in opts and str(opts["sell"]).lstrip("-").isdigit(): + sell = int(opts["sell"]) + else: + pct = float(opts.get("sell_percent", ctx.get("sell_percent", self.defaults["sell_percent"]))) + sell = -1 if price < 0 else int(round(price * pct / 100)) + # 'sell' may be a quantity word ("full") rather than a price -> handle sell_mode + sell_mode = opts.get("sell_mode") + if isinstance(opts.get("sell"), str) and not str(opts["sell"]).lstrip("-").isdigit(): + sell_mode = opts["sell"] + sell_mode = sell_mode or ctx.get("sell_mode", ctx.get("sell", self.defaults["sell"])) + if not isinstance(sell_mode, str) or sell_mode.lstrip("-").isdigit(): + sell_mode = self.defaults["sell"] + buy_mode = opts.get("buy", ctx.get("buy", self.defaults["buy"])) + stock = parse_stock(opts.get("stock", ctx.get("stock", self.defaults["stock"]))) + coef = float(opts.get("coefficient", ctx.get("coefficient", self.defaults["coefficient"]))) + restart = opts.get("restart", ctx.get("restart", self.defaults["restart"])) + destock = opts.get("destock", ctx.get("destock", self.defaults["destock"])) + try: + tq = pack_trade_quantity(buy_mode, sell_mode) + ss = pack_stock_settings(restart, destock) + except BuildError as e: + self.errors.append(f"{cls}: {e}") + tq, ss = 18, 0 + if coef != 1.0 and stock == -1: + self.warnings.append(f"{cls}: coefficient {coef} ignored by the mod because stock is unlimited") + if price >= 0 and sell > price: + self.warnings.append(f"{cls}: sell {sell} > buy {price}") + self.products[pid] = { + "className": cls, "coefficient": coef, "maxStock": stock, "tradeQuantity": tq, + "buyPrice": price, "sellPrice": sell, "stockSettings": ss, + "attachments": [], "variants": [], + "_att": self.expand_refs(opts.get("attachments"), cls), + "_var": self.expand_refs(opts.get("variants"), cls), + "_presets": opts.get("presets") or {}, + } + return pid + + def collect_declared(self): + """All class names written anywhere in the source (items, categories, groups).""" + decl = set() + def add_list(lst): + for r in lst or []: + if isinstance(r, str) and not r.startswith("@") and r not in self.groups: + decl.add(r.lower()) + for entry in self.src.get("items") or []: + cls, opts = self.norm_item(entry) + decl.add(cls.lower()) + add_list(opts.get("attachments")); add_list(opts.get("variants")) + for cat in self.src.get("categories") or []: + for entry in cat.get("items") or []: + cls, opts = self.norm_item(entry) + decl.add(cls.lower()) + add_list(opts.get("attachments")); add_list(opts.get("variants")) + for g in self.groups.values(): + add_list(g) + self._declared = decl + + def walk(self): + self.collect_declared() + ctx0 = {k: self.defaults[k] for k in ("sell_percent", "buy", "sell", "stock", "coefficient", "restart", "destock")} + for entry in self.src.get("items") or []: + cls, opts = self.norm_item(entry) + self.register(cls, opts, ctx0) + for cat in self.src.get("categories") or []: + name = cat.get("name") + if not name: + self.errors.append("category without 'name'") + continue + cid = self.alloc_id("cat_", name, cat.get("id")) + ctx = dict(ctx0) + for k in ("price", "sell_percent", "buy", "sell", "stock", "coefficient", "restart", "destock"): + if k in cat: + ctx[k] = cat[k] + pids = [] + for entry in cat.get("items") or []: + cls, opts = self.norm_item(entry) + pid = self.register(cls, opts, ctx, cid) + if pid not in pids: + pids.append(pid) + else: + self.warnings.append(f"{cls}: listed twice in category '{name}'") + self.categories[cid] = { + "isVisible": bool(cat.get("visible", True)), + "icon": cat.get("icon", "") or "", + "categoryName": name, + "licensesRequired": [], + "productIds": pids, + } + self.cat_traders[cid] = cat.get("traders") or ([cat["trader"]] if cat.get("trader") else []) + self.cat_licenses[cid] = cat.get("licenses") or [] + if not pids: + self.warnings.append(f"category '{name}' has no items") + + # -- pass 2: resolve attachments / variants / presets ------------------ + def ensure_attachment(self, cls, owner): + key = cls.lower() + if key in self.class_to_id: + return self.class_to_id[key] + if self.strict: + self.errors.append(f"{owner}: attachment/variant '{cls}' is not defined anywhere (strict mode)") + return None + self.notes.append(f"{owner}: '{cls}' auto-created as hidden product at attachment_price") + pid = self.register(cls, {"price": self.defaults["attachment_price"], + "sell_percent": self.defaults["attachment_sell_percent"]}, + {"sell_percent": self.defaults["attachment_sell_percent"]}) + return pid + + def resolve(self): + for pid in list(self.products): + p = self.products[pid] + cls = p["className"] + rec = self.compat.get(cls.lower()) + if rec and p["_att"]: + ok = {c.lower() for c in rec["attachments"] + rec["magazines"]} + bad = [c for c in p["_att"] if c.lower() not in ok] + if bad: + self.warnings.append(f"{cls}: not attachable according to compat file: {', '.join(bad[:6])}" + + (f" (+{len(bad)-6} more)" if len(bad) > 6 else "")) + p["attachments"] = [x for x in (self.ensure_attachment(c, cls) for c in p["_att"]) if x] + for pid in list(self.products): + p = self.products[pid] + cls = p["className"] + vids = [] + for vc in p["_var"]: + vid = self.ensure_attachment(vc, cls) + if not vid or vid == pid: + continue + vids.append(vid) + v = self.products[vid] + if not v["attachments"] and not v["_att"]: + v["attachments"] = list(p["attachments"]) + p["variants"] = vids + for pid, p in self.products.items(): + if not p["_presets"]: + continue + cls = p["className"] + presets = {"productId": pid, "defaultPresetId": "", "presets": []} + for pname, atts in p["_presets"].items(): + is_default = pname.endswith("*") + clean = pname.rstrip("*").strip() + preset_id = f"preset_{sanitize(cls)}_{sanitize(clean)}_001" + att_ids = [] + for c in self.expand_refs(atts, f"{cls}/preset {clean}"): + aid = self.class_to_id.get(c.lower()) + if not aid: + self.errors.append(f"{cls}: preset '{clean}' uses undefined class '{c}'") + continue + if aid not in p["attachments"]: + self.errors.append(f"{cls}: preset '{clean}' uses '{c}' which is not in the weapon's attachments list") + continue + att_ids.append(aid) + presets["presets"].append({"presetName": clean, "presetId": preset_id, + "productId": pid, "attachments": att_ids}) + if is_default: + presets["defaultPresetId"] = preset_id + if not presets["defaultPresetId"] and presets["presets"]: + presets["defaultPresetId"] = presets["presets"][0]["presetId"] + self.presets[pid] = presets + for p in self.products.values(): + for k in ("_att", "_var", "_presets"): + p.pop(k, None) + + # -- pass 3: traders & licenses in GeneralSettings ----------------------- + def resolve_settings(self): + gs_path = os.path.join(self.cfg, "TraderXGeneralSettings.json") + if not os.path.exists(gs_path): + self.warnings.append("TraderXGeneralSettings.json not found - traders/licenses not updated") + return + gs = load_json(gs_path) + self.gs = gs + traders = gs.setdefault("traders", []) + licenses = gs.setdefault("licenses", []) + by_name = {str(t.get("givenName", "")).lower(): t for t in traders} + by_id = {str(t.get("npcId")): t for t in traders} + lic_by_name = {str(l.get("licenseName", "")).lower(): l for l in licenses} + managed = set() + for cid, refs in self.cat_traders.items(): + for ref in refs: + t = by_id.get(str(ref)) or by_name.get(str(ref).lower()) + if not t: + self.errors.append(f"category {cid}: trader '{ref}' not found in TraderXGeneralSettings.json") + continue + key = str(t.get("npcId")) + if key not in managed: + t["categoriesId"] = [] + managed.add(key) + if cid not in t["categoriesId"]: + t["categoriesId"].append(cid) + for cid, names in self.cat_licenses.items(): + ids = [] + for n in names: + l = lic_by_name.get(str(n).lower()) + if not l: + import uuid + l = {"licenseId": str(uuid.uuid4()), "licenseName": n, "description": ""} + licenses.append(l) + lic_by_name[n.lower()] = l + self.notes.append(f"license '{n}' created in TraderXGeneralSettings.json") + ids.append(l["licenseId"]) + self.categories[cid]["licensesRequired"] = ids + for t in traders: + if str(t.get("npcId")) not in managed: + self.warnings.append(f"trader {t.get('npcId')} ({t.get('givenName')}) is not referenced by any category - left untouched") + cn = str(t.get("className", "")) + m = re.match(r"Survivor([MF])_(\w+)$", cn) + if m: + sex, nm = m.groups() + other = "F" if sex == "M" else "M" + if nm in KNOWN_SURVIVORS[other] and nm not in KNOWN_SURVIVORS[sex]: + self.errors.append(f"trader {t.get('npcId')}: {cn} does not exist, use Survivor{other}_{nm}") + + # -- write -------------------------------------------------------------- + def write(self): + prod_dir = os.path.join(self.cfg, "Products") + cat_dir = os.path.join(self.cfg, "Categories") + pre_dir = os.path.join(self.root, "TraderXPresets") + changed = added = 0 + stale = [] + plan = [] + + def emit(path, data): + nonlocal changed, added + new = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + old = None + if os.path.exists(path): + with open(path, "r", encoding="utf-8-sig") as f: + old = f.read() + if old == new: + return + if old is None: + added += 1 + plan.append(f" + {os.path.relpath(path, self.root)}") + else: + changed += 1 + plan.append(f" ~ {os.path.relpath(path, self.root)}") + if not self.dry: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(new) + + for pid, p in self.products.items(): + emit(os.path.join(prod_dir, pid + ".json"), {k: p[k] for k in PRODUCT_KEYS}) + for cid, c in self.categories.items(): + emit(os.path.join(cat_dir, cid + ".json"), c) + for pid, pr in self.presets.items(): + emit(os.path.join(pre_dir, f"presets_{pid}.json"), pr) + if self.gs is not None: + emit(os.path.join(self.cfg, "TraderXGeneralSettings.json"), self.gs) + + for d, ids in ((prod_dir, self.products), (cat_dir, self.categories)): + if os.path.isdir(d): + for f in os.listdir(d): + if f.endswith(".json") and f[:-5] not in ids: + stale.append(os.path.join(d, f)) + for f in stale: + plan.append(f" {'- ' if self.clean else '? '}{os.path.relpath(f, self.root)}" + + ("" if self.clean else " (not in source; --clean removes)")) + if self.clean and not self.dry: + os.remove(f) + return added, changed, stale, plan + + def run(self): + try: + self.walk() + self.resolve() + self.resolve_settings() + except BuildError as e: + self.errors.append(str(e)) + return self + + +def cmd_build(args, check_only=False): + src = load_source(args.source) + root = find_traderx_root(args.out) if not check_only else "/nonexistent" + compat = load_compat(getattr(args, "compat", None)) + if compat: + print(f"compat data: {len(compat)} weapons") + b = Builder(src, root, dry_run=args.dry_run or check_only, clean=getattr(args, "clean", False), + strict=args.strict, compat=compat).run() + for n in b.notes: + print(f"note: {n}") + for w in b.warnings: + print(f"warning: {w}") + for e in b.errors: + print(f"ERROR: {e}") + print(f"\n{len(b.products)} products, {len(b.categories)} categories, {len(b.presets)} preset files") + if b.errors: + print(f"{len(b.errors)} error(s) - nothing written") + return 1 + if check_only: + print("OK") + return 0 + if not args.dry_run and not args.no_backup: + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + bdir = os.path.join(root, f"Backup_build_{stamp}") + for sub in ("TraderXConfig/Products", "TraderXConfig/Categories", "TraderXPresets"): + s = os.path.join(root, sub) + if os.path.isdir(s): + shutil.copytree(s, os.path.join(bdir, sub)) + gsp = os.path.join(b.cfg, "TraderXGeneralSettings.json") + if os.path.exists(gsp): + os.makedirs(os.path.join(bdir, "TraderXConfig"), exist_ok=True) + shutil.copy2(gsp, os.path.join(bdir, "TraderXConfig")) + print(f"backup: {bdir}") + added, changed, stale, plan = b.write() + if plan: + print("\n".join(plan)) + print(f"\n{'would write' if args.dry_run else 'wrote'}: {added} new, {changed} changed, " + f"{len(stale)} stale file(s){' removed' if args.clean and not args.dry_run else ''}") + comp = os.path.join(b.cfg, "Compiled") + if os.path.isdir(comp) and any(f.endswith(".json") for f in os.listdir(comp)): + print("WARNING: TraderXConfig/Compiled/ exists - the mod will load THAT instead of these files. Delete it.") + return 0 + + +# --------------------------------------------------------------------------- +# IMPORTERS (all produce the source schema) +# --------------------------------------------------------------------------- +def item_entry(cls, **opts): + opts = {k: v for k, v in opts.items() if v not in (None, [], {}, "")} + return cls if not opts else {cls: opts} + + +def merge_into(new_src, args): + """Write new_src to -o, or append its categories/items/groups into --into.""" + if args.into: + base = load_source(args.into) if os.path.exists(args.into) else {} + base.setdefault("defaults", DEFAULTS.copy()) + base.setdefault("groups", {}).update(new_src.get("groups", {})) + if new_src.get("items"): + base.setdefault("items", []).extend(new_src["items"]) + existing = {c["name"].lower() for c in base.get("categories", [])} + for c in new_src.get("categories", []): + if c["name"].lower() in existing: + c["name"] += " (imported)" + base.setdefault("categories", []).append(c) + dump_source(base, args.into) + print(f"appended to {args.into}") + else: + out = args.output or ("source.yaml" if yaml else "source.json") + new_src = dict({"defaults": DEFAULTS.copy()}, **new_src) if "defaults" not in new_src else new_src + dump_source(new_src, out) + print(f"written {out}") + + +def cat_block(name, args, items, **extra): + c = {"name": name} + if args.trader: + c["traders"] = [int(t) if t.isdigit() else t for t in args.trader] + c.update({k: v for k, v in extra.items() if v not in (None, "", [])}) + c["items"] = items + return c + + +def cmd_import_plain(args): + with open(args.file, encoding="utf-8-sig") as f: + lines = [l.strip() for l in f] + items = [] + for l in lines: + if not l or l.startswith("#"): + continue + parts = [p.strip() for p in re.split(r"[,\t;]", l)] + cls = parts[0] + if len(parts) > 1 and parts[1].lstrip("-").isdigit(): + items.append(item_entry(cls, price=int(parts[1]), sell=int(parts[2]) if len(parts) > 2 and parts[2].lstrip("-").isdigit() else None)) + else: + items.append(cls) + cat = cat_block(args.category or os.path.splitext(os.path.basename(args.file))[0], args, items, price=args.price) + merge_into({"categories": [cat]}, args) + + +def cmd_import_traderplus(args): + data = load_json(args.file) + cats_in = data.get("TraderCategories") or data.get("Categories") or ([data] if "Products" in data else data) + cats = [] + for c in cats_in: + items = [] + for line in c.get("Products", []): + parts = [p.strip() for p in line.split(",")] + if len(parts) < 6: + print(f"skip malformed line: {line}") + continue + cls, coef, maxstock, tq, buy, sell = parts[:6] + try: + coef, maxstock, tq, buy, sell = float(coef), int(maxstock), int(tq), int(buy), int(sell) + except ValueError: + print(f"skip malformed line: {line}") + continue + bmode, smode = unpack_trade_quantity(tq) + items.append(item_entry(cls, price=buy, sell=sell, + stock=None if maxstock == -1 else maxstock, + coefficient=None if coef == 1.0 else coef, + buy=None if bmode == DEFAULTS["buy"] else bmode, + sell_mode=None if smode == DEFAULTS["sell"] else smode)) + cats.append(cat_block(args.category or c.get("CategoryName", "Imported"), args, items)) + merge_into({"categories": cats}, args) + + +def cmd_import_expansion(args): + paths = [] + if os.path.isdir(args.file): + paths = [os.path.join(args.file, f) for f in sorted(os.listdir(args.file)) if f.lower().endswith(".json")] + else: + paths = [args.file] + cats = [] + for p in paths: + d = load_json(p) + items = [] + for it in d.get("Items", []): + cls = it.get("ClassName") + if not cls: + continue + buy = int(it.get("MaxPriceThreshold", -1)) + minp = int(it.get("MinPriceThreshold", buy)) + spp = float(it.get("SellPricePercent", -1)) + sell = int(round(buy * spp / 100)) if spp >= 0 and buy >= 0 else None + maxs, mins = int(it.get("MaxStockThreshold", 1)), int(it.get("MinStockThreshold", 1)) + opts = {"price": buy, "sell": sell} + if maxs > 1 and buy != minp: + # Expansion dynamic price between min..max over stock range -> approximate coefficient + opts["stock"] = maxs + if buy > 0 and minp > 0 and maxs > 1: + opts["coefficient"] = round((minp / buy) ** (1.0 / (maxs - 1)), 4) + qp = it.get("QuantityPercent", -1) + if isinstance(qp, (int, float)) and qp >= 0: + opts["buy"] = "empty" if qp == 0 else ("full" if qp >= 100 else f"coef {int(qp)}%") + opts["attachments"] = [a for a in it.get("SpawnAttachments", []) if a] + opts["variants"] = [v for v in it.get("Variants", []) if v] + items.append(item_entry(cls, **opts)) + cats.append(cat_block(args.category or d.get("DisplayName", os.path.splitext(os.path.basename(p))[0]).replace("_", " ").title(), + args, items)) + merge_into({"categories": cats}, args) + + +def cmd_import_traderx(args): + root = find_traderx_root(args.path) + cfg = os.path.join(root, "TraderXConfig") + prods, cats, presets = {}, {}, {} + pdir, cdir, prdir = os.path.join(cfg, "Products"), os.path.join(cfg, "Categories"), os.path.join(root, "TraderXPresets") + for f in sorted(os.listdir(pdir)) if os.path.isdir(pdir) else []: + if f.endswith(".json"): + try: + d = load_json(os.path.join(pdir, f)) + if isinstance(d, dict) and d.get("className"): + prods[f[:-5]] = d + except Exception as e: + print(f"skip {f}: {e}") + for f in sorted(os.listdir(cdir)) if os.path.isdir(cdir) else []: + if f.endswith(".json"): + try: + cats[f[:-5]] = load_json(os.path.join(cdir, f)) + except Exception as e: + print(f"skip {f}: {e}") + for f in sorted(os.listdir(prdir)) if os.path.isdir(prdir) else []: + if f.startswith("presets_") and f.endswith(".json"): + try: + d = load_json(os.path.join(prdir, f)) + presets[d.get("productId", f[8:-5])] = d + except Exception as e: + print(f"skip {f}: {e}") + gs = load_json(os.path.join(cfg, "TraderXGeneralSettings.json")) if os.path.exists(os.path.join(cfg, "TraderXGeneralSettings.json")) else {} + lic_names = {l.get("licenseId"): l.get("licenseName") for l in gs.get("licenses", [])} + cat_traders = {} + for t in gs.get("traders", []): + for cid in t.get("categoriesId", []): + cat_traders.setdefault(cid, []).append(t.get("givenName") or t.get("npcId")) + + cls_of = {pid: p["className"] for pid, p in prods.items()} + + def entry(pid): + p = prods[pid] + bmode, smode = unpack_trade_quantity(int(p.get("tradeQuantity", 18))) + restart, destock = unpack_stock_settings(int(p.get("stockSettings", 0))) + opts = {"id": pid, "price": p.get("buyPrice", -1), "sell": p.get("sellPrice", -1)} + if p.get("maxStock", -1) != -1: + opts["stock"] = p["maxStock"] + if float(p.get("coefficient", 1.0)) != 1.0: + opts["coefficient"] = p["coefficient"] + if bmode != DEFAULTS["buy"]: + opts["buy"] = bmode + if smode != DEFAULTS["sell"]: + opts["sell_mode"] = smode + if restart != "keep": + opts["restart"] = restart + if destock: + opts["destock"] = destock + opts["attachments"] = [cls_of[a] for a in p.get("attachments", []) if a in cls_of] + opts["variants"] = [cls_of[v] for v in p.get("variants", []) if v in cls_of] + pr = presets.get(pid) + if pr: + pm = {} + for ps in pr.get("presets", []): + nm = ps.get("presetName", "preset") + ("*" if ps.get("presetId") == pr.get("defaultPresetId") else "") + pm[nm] = [cls_of[a] for a in ps.get("attachments", []) if a in cls_of] + opts["presets"] = pm + return item_entry(p["className"], **opts) + + used = set() + out_cats = [] + for cid, c in cats.items(): + items = [] + for pid in c.get("productIds", []): + if pid in prods: + items.append(entry(pid)) + used.add(pid) + cat = {"name": c.get("categoryName", cid), "id": cid} + if cid in cat_traders: + cat["traders"] = cat_traders[cid] + lic = [lic_names.get(l, l) for l in c.get("licensesRequired", [])] + if lic: + cat["licenses"] = lic + if not c.get("isVisible", True): + cat["visible"] = False + if c.get("icon"): + cat["icon"] = c["icon"] + cat["items"] = items + out_cats.append(cat) + hidden = [entry(pid) for pid in prods if pid not in used] + src = {"defaults": DEFAULTS.copy(), "groups": {}, "items": hidden, "categories": out_cats} + out = args.output or ("source.yaml" if yaml else "source.json") + dump_source(src, out) + print(f"written {out}: {len(prods)} products ({len(hidden)} hidden), {len(cats)} categories, {len(presets)} preset files") + + +def cmd_compat(args): + compat = load_compat(args.files) + rec = compat.get(args.classname.lower()) + if not rec: + print(f"{args.classname} not found in compat data ({len(compat)} weapons known)") + return 1 + for k in ("attachments", "magazines", "bullets"): + print(f"{k} ({len(rec[k])}):") + for c in rec[k]: + print(f" {c}") + return 0 + + +# --------------------------------------------------------------------------- +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + def add_import_common(p): + p.add_argument("-o", "--output", help="write a new source file (default source.yaml/.json)") + p.add_argument("--into", help="append into an existing source file instead") + p.add_argument("--category", help="category name (default: from the export)") + p.add_argument("--trader", action="append", default=[], help="trader givenName or npcId (repeatable)") + + p = sub.add_parser("import-traderx", help="reverse-import an existing TraderX config") + p.add_argument("path") + p.add_argument("-o", "--output") + p.set_defaults(fn=cmd_import_traderx) + + p = sub.add_parser("import-traderplus", help="TraderPlus v1 PriceConfig export") + p.add_argument("file") + add_import_common(p) + p.set_defaults(fn=cmd_import_traderplus) + + p = sub.add_parser("import-expansion", help="Expansion market json (file or folder)") + p.add_argument("file") + add_import_common(p) + p.set_defaults(fn=cmd_import_expansion) + + p = sub.add_parser("import-plain", help="plain class list, optionally 'class,buy[,sell]'") + p.add_argument("file") + p.add_argument("--price", type=int, help="default buy price for the category") + add_import_common(p) + p.set_defaults(fn=cmd_import_plain) + + p = sub.add_parser("build", help="compile source -> TraderX files") + p.add_argument("source") + p.add_argument("--out", required=True, help="TraderX profile folder (parent of TraderXConfig)") + p.add_argument("--dry-run", action="store_true") + p.add_argument("--clean", action="store_true", help="delete product/category files not in the source") + p.add_argument("--strict", action="store_true", help="do not auto-create undefined attachments") + p.add_argument("--compat", action="append", default=[], metavar="FILE", + help="'All classes and their attachments' json (repeatable); enables @compat refs and compatibility warnings") + p.add_argument("--no-backup", action="store_true") + p.set_defaults(fn=lambda a: cmd_build(a)) + + p = sub.add_parser("check", help="validate the source only") + p.add_argument("source") + p.add_argument("--strict", action="store_true") + p.add_argument("--compat", action="append", default=[], metavar="FILE") + p.set_defaults(fn=lambda a: cmd_build(argparse.Namespace(**vars(a), out=".", dry_run=True, clean=False, no_backup=True), check_only=True)) + + p = sub.add_parser("compat", help="show what a weapon accepts according to a compat file") + p.add_argument("classname") + p.add_argument("files", nargs="+", help="one or more compat json files") + p.set_defaults(fn=cmd_compat) + + args = ap.parse_args() + return args.fn(args) or 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/traderx_check.py b/traderx_check.py new file mode 100644 index 0000000..8a1c824 --- /dev/null +++ b/traderx_check.py @@ -0,0 +1,625 @@ +#!/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())