Initial release

This commit is contained in:
smthspec 2026-09-06 22:47:27 +03:00
commit c6893763dd
19 changed files with 2948 additions and 0 deletions

7
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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`.

View 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`).

View 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"]
}
]
}

View 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"]
}
]
}

View file

@ -0,0 +1,4 @@
# one class per line; optional ",buyPrice[,sellPrice]"
FC_Conserv_Angel_Closed
FC_Conserv_Angel_Opened
BandageDressing,40,10

View 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

View 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"
]
}
]
}

View 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
View file

@ -0,0 +1 @@
pyyaml>=6.0 # optional: enables .yaml sources; .json sources work without it

15
scripts/prestart-check.sh Normal file
View 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
View 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
View 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())