Initial release
This commit is contained in:
commit
c6893763dd
19 changed files with 2948 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -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.*
|
||||
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal file
|
|
@ -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.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
133
README.md
Normal file
133
README.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# traderx-tools
|
||||
|
||||
Command-line tools for administering the **TraderX** DayZ trader mod
|
||||
(<https://github.com/TheDmitri/TraderX>) 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 <this repo> 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 <TraderX dir> [-o FILE]
|
||||
traderx_build.py import-traderplus <file.json> [--category NAME] [--trader T]... (-o FILE | --into FILE)
|
||||
traderx_build.py import-expansion <file|dir> [--category NAME] [--trader T]... (-o FILE | --into FILE)
|
||||
traderx_build.py import-plain <file.txt> --price N [--category NAME] [--trader T]... (-o FILE | --into FILE)
|
||||
traderx_build.py build <source> --out <TraderX dir> [--dry-run] [--clean] [--strict] [--compat FILE]... [--no-backup]
|
||||
traderx_build.py check <source> [--strict] [--compat FILE]...
|
||||
traderx_build.py compat <ClassName> <compat.json>...
|
||||
|
||||
traderx_check.py <TraderX dir> [--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).
|
||||
58
docs/checking.md
Normal file
58
docs/checking.md
Normal file
|
|
@ -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_<name>_NNN.json` / `cat_<name>_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_<timestamp>/`:
|
||||
|
||||
- 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/<id>.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`).
|
||||
67
docs/compat.md
Normal file
67
docs/compat.md
Normal file
|
|
@ -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
|
||||
{
|
||||
"<any key>": [
|
||||
{ "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.
|
||||
99
docs/importing.md
Normal file
99
docs/importing.md
Normal file
|
|
@ -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.
|
||||
211
docs/source-format.md
Normal file
211
docs/source-format.md
Normal file
|
|
@ -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_<productId>.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`.
|
||||
565
docs/traderx-configuration-guide.md
Normal file
565
docs/traderx-configuration-guide.md
Normal file
|
|
@ -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.
|
||||
|
||||
```
|
||||
<profiles>/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_<name>_NNN.json ← LEGACY format (one file per product)
|
||||
│ ├── Categories/ cat_<name>_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_<npcId>.json
|
||||
│ └── DebugTestCases/ *.json
|
||||
├── TraderXDatabase/ ← runtime state, do NOT hand-edit while server is up
|
||||
│ ├── Stock/ <productId>.json ← current stock per product
|
||||
│ └── PlayerLicenses/ playerLicense_<steam64>.json
|
||||
├── TraderXPresets/ presets_<id>.json ← player-saved attachment presets
|
||||
└── TraderXLogs/
|
||||
├── Config/LoggingSettings.json
|
||||
├── Config/ConfigReport.log ← CSV validation report
|
||||
└── Logs/TraderX_<date>.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/<productId>.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_<name>_NNN.json
|
||||
```
|
||||
|
||||
- must start with `prod_`
|
||||
- `<name>` = 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/<productId>.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/<productId>.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/<categoryId>.json`
|
||||
|
||||
File name rule is the same as products, but with the `cat_` prefix and **lowercase** letters only in the middle part:
|
||||
|
||||
```
|
||||
cat_<name>_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": "<auto-generated uuid>",
|
||||
"licenses": [
|
||||
{
|
||||
"licenseId": "<uuid>",
|
||||
"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_<steam64>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"steamId": "76561198000000000",
|
||||
"playerName": "Someone",
|
||||
"licenses": [
|
||||
{ "licenseId": "<same uuid as in GeneralSettings>", "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_<npcId>.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_<npcId>.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_<name>_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_<name>_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_<npcId>.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_<date>.log`. Startup logs the format used (`Configuration format selected: …`) and product/category counts (`LoadAllProducts - Completed. Total products loaded: N`).
|
||||
10
examples/compat.example.json
Normal file
10
examples/compat.example.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
32
examples/expansion.example.json
Normal file
32
examples/expansion.example.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
4
examples/plain.example.txt
Normal file
4
examples/plain.example.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# one class per line; optional ",buyPrice[,sellPrice]"
|
||||
FC_Conserv_Angel_Closed
|
||||
FC_Conserv_Angel_Opened
|
||||
BandageDressing,40,10
|
||||
67
examples/source.example.yaml
Normal file
67
examples/source.example.yaml
Normal file
|
|
@ -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
|
||||
15
examples/traderplus.example.json
Normal file
15
examples/traderplus.example.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
39
examples/weapons-with-presets.example.yaml
Normal file
39
examples/weapons-with-presets.example.yaml
Normal file
|
|
@ -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]
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
pyyaml>=6.0 # optional: enables .yaml sources; .json sources work without it
|
||||
15
scripts/prestart-check.sh
Normal file
15
scripts/prestart-check.sh
Normal file
|
|
@ -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
|
||||
967
traderx_build.py
Normal file
967
traderx_build.py
Normal file
|
|
@ -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 <TraderX dir> reverse-import an existing config
|
||||
import-traderplus <TraderPlusPriceConfig.json> TraderPlus v1 export
|
||||
import-expansion <expansion market .json or dir> Expansion market export
|
||||
import-plain <classnames.txt> --price N plain class list
|
||||
build <source.yaml|.json> --out <TraderX dir> [--dry-run] [--clean]
|
||||
check <source.yaml|.json> 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 | <int>
|
||||
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())
|
||||
625
traderx_check.py
Normal file
625
traderx_check.py
Normal file
|
|
@ -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())
|
||||
Loading…
Add table
Reference in a new issue