This commit is contained in:
2026-05-06 23:26:10 +04:00
parent 0a3d3b6afa
commit 5dcb8be3aa
16 changed files with 2622 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(command -v lua5.1)",
"Bash(command -v lua)",
"Read(//home/sindo/azeroth-server/**)",
"Read(//home/sindo/**)",
"Bash(docker image *)",
"Bash(docker restart *)",
"Bash(bash import-custom-sql.sh)",
"Bash(awk '/^ ac-worldserver:/,/^ [a-z]/' /home/sindo/moonwell-core/docker-compose.yml)",
"Bash(awk '/ac-worldserver:/{flag=1} flag{print; if\\(/depends_on:/\\){flag=0}}' /home/sindo/moonwell-core/docker-compose.yml)",
"Bash(grep -n \"LoadAdditionalFile\\\\|\\\\.conf.dist\\\\|conf$\" /home/sindo/moonwell-core/src/common/Configuration/Config.cpp)",
"Bash(grep -n \"^$\\\\|ENCOUNTER\" /home/sindo/moonwell-core/conf/dist/env.ac)",
"Bash(grep -v \"^information_schema\\\\|^mysql$\\\\|^performance_schema\\\\|^sys$\")"
]
}
}
+10
View File
@@ -19,3 +19,13 @@ AC_CCACHE=true
AC_RESTARTER_BINPATH=
AC_RESTARTER_BINFILE=
AC_RESTARTER_WITHGDB=
#
# mod-encounter-journal
# This deployment reads only .conf.dist files, so module conf overrides
# live here as env vars. ExportOnStart=1 writes JSON/Lua right after the
# custom_ej_* tables load. EXPORT_PATH must point at a directory that's
# mounted on the host so the file appears outside the container.
#
AC_ENCOUNTER_JOURNAL_EXPORT_ON_START=1
AC_ENCOUNTER_JOURNAL_EXPORT_PATH=/azerothcore/env/dist/logs
+46
View File
@@ -14,6 +14,7 @@ fi
IMPORT_MYTHICPLUS_SQL="${ACORE_IMPORT_MYTHICPLUS_SQL:-1}"
IMPORT_STORE_SQL="${ACORE_IMPORT_STORE_SQL:-1}"
IMPORT_ENCOUNTER_JOURNAL_SQL="${ACORE_IMPORT_ENCOUNTER_JOURNAL_SQL:-1}"
usage() {
cat <<EOF
@@ -234,6 +235,49 @@ import_aoe_loot_sql() {
import_sql_file "acore_world" "$aoe_loot_sql_file"
}
import_encounter_journal_sql() {
local schema_file="$ROOT_DIR/modules/mod-encounter-journal/data/sql/db-world/base/01_encounter_journal_schema.sql"
local sample_file="$ROOT_DIR/modules/mod-encounter-journal/data/sql/db-world/base/02_encounter_journal_sample.sql"
if [[ "$IMPORT_ENCOUNTER_JOURNAL_SQL" == "0" ]]; then
log "Encounter Journal SQL import disabled, skipped"
return 0
fi
if [[ ! -f "$schema_file" ]]; then
log "mod-encounter-journal schema SQL not found, skipped"
return 0
fi
# Schema file uses CREATE TABLE IF NOT EXISTS, but we still gate on the
# canonical tier table to avoid noisy re-imports on every bootstrap.
if ! table_exists "acore_world" "custom_ej_tier"; then
import_sql_file "acore_world" "$schema_file"
else
log "custom_ej_* schema already present, skipping"
fi
# Sample data only on a fresh install (no rows in any custom_ej_* table).
# Re-running it would clobber hand-edited rows in the 9000-9999 id range.
if [[ -f "$sample_file" ]]; then
local total
total="$(mysql_query "acore_world" "
SELECT (SELECT COUNT(*) FROM custom_ej_tier)
+ (SELECT COUNT(*) FROM custom_ej_instance)
+ (SELECT COUNT(*) FROM custom_ej_encounter)
+ (SELECT COUNT(*) FROM custom_ej_creature)
+ (SELECT COUNT(*) FROM custom_ej_section)
+ (SELECT COUNT(*) FROM custom_ej_loot);
")"
if [[ "$total" == "0" ]]; then
import_sql_file "acore_world" "$sample_file"
else
log "custom_ej_* tables already populated, skipping sample data"
fi
fi
}
if (($# > 0)); then
case "$1" in
-h|--help)
@@ -250,6 +294,7 @@ fi
require_integer_flag "ACORE_IMPORT_MYTHICPLUS_SQL" "$IMPORT_MYTHICPLUS_SQL"
require_integer_flag "ACORE_IMPORT_STORE_SQL" "$IMPORT_STORE_SQL"
require_integer_flag "ACORE_IMPORT_ENCOUNTER_JOURNAL_SQL" "$IMPORT_ENCOUNTER_JOURNAL_SQL"
cd "$ROOT_DIR"
@@ -260,6 +305,7 @@ fi
import_mythicplus_sql
import_aoe_loot_sql
import_encounter_journal_sql
import_store_sql
log "custom SQL bootstrap completed"
+5
View File
@@ -0,0 +1,5 @@
This module is part of the AzerothCore project and is distributed under the
terms of the GNU General Public License v2 (GPLv2). See the AzerothCore LICENSE
file for the full license text:
https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE
+191
View File
@@ -0,0 +1,191 @@
# mod-encounter-journal
Source-of-truth for the **MoonWellClient EncounterJournal** addon. Stores
all data (tiers, instances, encounters, sections, creatures, loot) in
`acore_world` under `custom_ej_*` tables and exports them as JSON / Lua
that the client can consume directly. No DBC dependency.
## Layout
```text
mod-encounter-journal/
├── conf/ module configuration
├── data/sql/db-world/base/
│ ├── 01_encounter_journal_schema.sql table definitions
│ └── 02_encounter_journal_sample.sql sample data (Deadmines, Naxxramas)
├── data/sql/db-world/updates/ future incremental migrations
├── docs/encounter_journal_format.md output structure reference
├── src/ C++ implementation (manager + commands)
└── tools/json_to_lua.py offline JSON → Lua fallback
```
## Tables
| Table | Purpose |
| --------------------------- | ---------------------------------------------------------- |
| `custom_ej_tier` | Top-level expansion / content bucket |
| `custom_ej_instance` | Dungeon / raid header |
| `custom_ej_tier_instance` | N:M tier ↔ instance |
| `custom_ej_encounter` | Boss / encounter under an instance |
| `custom_ej_creature` | Creature card (portrait, name, description) per encounter |
| `custom_ej_section` | Tree of Overview / Abilities sections |
| `custom_ej_loot` | Explicit loot table per encounter |
References to standard AzerothCore tables:
- `custom_ej_instance.map_id``instance_template.map`
- `custom_ej_creature.creature_id``creature_template.entry`
- `custom_ej_loot.item_id``item_template.entry`
- `custom_ej_creature.creature_display_id` is optional and only used by the
client's 3D portrait tab; pull it from
`creature_template.modelid1..modelid4` when populating manually.
## Bitmasks
### `instance.flags`
| Bit | Meaning |
| ------- | ----------------- |
| `0x000` | dungeon (default) |
| `0x010` | raid |
| `0x040` | hide difficulty |
| `0x800` | open world |
### `encounter.difficulty_mask`
For dungeons:
| Bit | Meaning |
| ----- | --------------- |
| `0x1` | normal |
| `0x2` | heroic |
| `0x4` | mythic (custom) |
For raids:
| Bit | Meaning |
| ----- | --------- |
| `0x1` | 10 normal |
| `0x2` | 25 normal |
| `0x4` | 10 heroic |
| `0x8` | 25 heroic |
### `section.flags`
| Bit | Meaning |
| ------ | ------------ |
| `0x1` | starts open |
| `0x2` | heroic only |
## Console / GM commands
All commands require **administrator** rights, except `info` which is
gamemaster-only. They run from the in-game chat **and** the worldserver
console.
| Command | What it does |
| ---------------- | ---------------------------------------------------------------------------- |
| `.ej info` | Print row counts of the in-memory cache. |
| `.ej reload` | Re-read all `custom_ej_*` tables. |
| `.ej validate` | Run integrity checks (see "Validation" below). |
| `.ej importloot` | Auto-populate `custom_ej_loot` from `creature_loot_template` (skips dupes). |
| `.ej export` | Write `EncounterJournalData.json` (and `.lua`) to the configured path. |
## Configuration
`conf/mod_encounter_journal.conf.dist` (rename to `.conf` to override):
| Option | Default | Description |
| ----------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------- |
| `EncounterJournal.Enable` | `1` | Master switch. |
| `EncounterJournal.Export.Path` | (current working dir) | Output directory; must exist & be writable. |
| `EncounterJournal.Export.JsonFile` | `EncounterJournalData.json` | JSON filename. |
| `EncounterJournal.Export.LuaFile` | `EncounterJournalData.lua` | Lua filename. Empty string skips Lua generation. |
| `EncounterJournal.Export.LuaGlobal` | `MoonWellEncounterJournalData` | Name of the Lua global the addon reads. |
| `EncounterJournal.ExportOnStart` | `0` | When `1`, exports JSON/Lua right after startup load (useful for headless docker without TTY). |
## Output format
The exporter writes the following top-level structure (full per-row layout
in [`docs/encounter_journal_format.md`](docs/encounter_journal_format.md)):
```jsonc
{
"tiers": [ /* [id, name, flags, sort_order] */ ],
"instances": { /* "instance_id": [...] */ },
"tierInstances": { /* "tier_id": [instance_id, ...] */ },
"encounters": { /* "instance_id": [[...], [...]] */ },
"creatures": { /* "encounter_id": [[...], [...]] */ },
"sections": { /* "section_id": [...] */ },
"items": { /* "encounter_id": [[...], [...]] */ }
}
```
Field order inside each row matches the layout the MoonWellClient
EncounterJournal addon expects, so the Lua file can be loaded directly:
```lua
-- in your addon Init() or once on load
local data = MoonWellEncounterJournalData
for _, tier in ipairs(data.tiers) do
-- tier[1]=id, tier[2]=name, tier[3]=flags, tier[4]=sort_order
end
```
## Workflow
1. **Edit data** — INSERT/UPDATE rows in `custom_ej_*` tables.
2. **Validate**`.ej validate` (no errors expected).
3. **Export**`.ej export` (writes JSON + Lua to `Export.Path`).
4. **Ship** — copy `EncounterJournalData.lua` into the
MoonWellClient EncounterJournal addon directory.
For loot, you can shortcut step 1:
1. Insert the encounter and a `custom_ej_creature` row with `creature_id`
set to the boss NPC id.
2. Run `.ej importloot` — entries from `creature_loot_template` (and one
level of `reference_loot_template`) are inserted into `custom_ej_loot`
with `difficulty_mask` copied from the creature card. Existing rows are
skipped via the unique key.
3. Manually clean up: drop trash items, set `faction_mask` /
`class_mask` / `sort_order` where needed.
4. Re-run `.ej validate` and `.ej export`.
## Validation
`.ej validate` verifies:
- every `tier_instance.tier_id` and `tier_instance.instance_id` exists;
- every `encounter.instance_id` exists;
- every `encounter.first_section_id` exists or equals `0`;
- `encounter.difficulty_mask` only uses bits valid for the parent
instance type (dungeon → `0x7`, raid → `0xF`);
- every `creature.encounter_id` exists;
- every `creature.creature_id` (if non-zero) exists in `creature_template`;
- every `section.encounter_id` exists;
- every `section.parent_section_id` / `next_section_id` /
`sub_section_id` (if non-zero) exists;
- every `loot.encounter_id` exists;
- every `loot.item_id` exists in `item_template`;
- no `loot` duplicates by
`(encounter_id, item_id, difficulty_mask, faction_mask, class_mask)`.
`sort_order` stability is enforced by the schema (column has no
`AUTO_INCREMENT`/random default, exporter sorts on it).
## Offline JSON → Lua
If you produce the JSON outside the worldserver (e.g., from a CI job that
talks to MySQL directly), use the bundled converter:
```sh
python3 modules/mod-encounter-journal/tools/json_to_lua.py \
--input EncounterJournalData.json \
--output EncounterJournalData.lua \
--global MoonWellEncounterJournalData
```
The output uses the same field order, key sort, and global name as
`.ej export`, so the addon does not care which path produced the file.
Whitespace / float formatting may differ slightly.
@@ -0,0 +1,83 @@
#
# This file is part of the AzerothCore project.
#
########################################
# mod-encounter-journal configuration
########################################
#
# EncounterJournal.Enable
# Description: Master switch for the module. When 0 the module
# does not register the .ej commands.
# Default: 1
#
EncounterJournal.Enable = 1
#
# EncounterJournal.Export.Path
# Description: Directory where exported files are written. The
# directory must already exist and be writable by the
# worldserver process. Trailing slash is optional.
# Empty string == current working directory.
# Tip (Docker): set to "/azerothcore/env/dist/logs"
# so files appear on the host under ./env/dist/logs.
# Default: ""
#
EncounterJournal.Export.Path = "/env/dist/logs"
#
# EncounterJournal.Export.JsonFile
# Description: Filename of the JSON export written under Export.Path.
# Default: "EncounterJournalData.json"
#
EncounterJournal.Export.JsonFile = "EncounterJournalData.json"
#
# EncounterJournal.Export.LuaFile
# Description: Filename of the Lua export written under Export.Path.
# Set to empty string to skip Lua generation.
# Default: "EncounterJournalData.lua"
#
EncounterJournal.Export.LuaFile = "EncounterJournalData.lua"
#
# EncounterJournal.Export.LuaGlobal
# Description: Name of the Lua global the .lua file assigns its
# payload to. The MoonWellClient EncounterJournal addon
# reads this global on load.
# Default: "MoonWellEncounterJournalData"
#
EncounterJournal.Export.LuaGlobal = "MoonWellEncounterJournalData"
#
# EncounterJournal.ExportOnStart
# Description: When 1, the worldserver writes the JSON/Lua files to
# EncounterJournal.Export.Path immediately after the
# custom_ej_* tables are loaded at startup. Useful when
# the in-game/CLI .ej commands are not available
# (CI builds, headless docker without an attached TTY).
# Default: 0
#
EncounterJournal.ExportOnStart = 1
#
# EncounterJournal.ImportOnStart
# Description: When 1, on startup the worldserver auto-populates
# custom_ej_* tables from instance_template,
# instance_encounters and creature_template, then runs
# the loot importer. Existing rows are kept (INSERT
# IGNORE on PK), so manual edits are preserved.
# Use this once after a fresh install or after wiping
# the auto-imported rows; descriptions stay empty and
# must be filled by hand.
# Default: 0
#
EncounterJournal.ImportOnStart = 0
@@ -0,0 +1,115 @@
--
-- Encounter Journal: schema for custom_ej_* tables.
-- Source of truth for client EncounterJournal data lives here, not in DBC.
--
-- Tables are CREATE TABLE IF NOT EXISTS so re-running the file is safe.
-- DBUpdater re-applies the file when its hash changes.
--
CREATE TABLE IF NOT EXISTS `custom_ej_tier` (
`id` INT UNSIGNED NOT NULL,
`name` VARCHAR(255) NOT NULL,
`flags` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: tier (expansion/content bucket).';
CREATE TABLE IF NOT EXISTS `custom_ej_instance` (
`id` INT UNSIGNED NOT NULL,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NOT NULL,
`button_icon` VARCHAR(255) NOT NULL DEFAULT '',
`small_button_icon` VARCHAR(255) NOT NULL DEFAULT '',
`background` VARCHAR(255) NOT NULL DEFAULT '',
`lore_background` VARCHAR(255) NOT NULL DEFAULT '',
`map_id` INT UNSIGNED NOT NULL DEFAULT 0,
`area_id` INT UNSIGNED NOT NULL DEFAULT 0,
`world_map_area_id` INT UNSIGNED NOT NULL DEFAULT 0,
`flags` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: dungeon/raid/scenario.';
CREATE TABLE IF NOT EXISTS `custom_ej_tier_instance` (
`tier_id` INT UNSIGNED NOT NULL,
`instance_id` INT UNSIGNED NOT NULL,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`tier_id`, `instance_id`),
KEY `idx_instance` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: tier <-> instance N:M.';
CREATE TABLE IF NOT EXISTS `custom_ej_encounter` (
`id` INT UNSIGNED NOT NULL,
`instance_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NOT NULL,
`map_position_x` FLOAT NOT NULL DEFAULT 0,
`map_position_y` FLOAT NOT NULL DEFAULT 0,
`floor_index` INT UNSIGNED NOT NULL DEFAULT 1,
`world_map_area_id` INT UNSIGNED NOT NULL DEFAULT 0,
`first_section_id` INT UNSIGNED NOT NULL DEFAULT 0,
`difficulty_mask` INT UNSIGNED NOT NULL DEFAULT 0,
`flags` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_instance` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: boss / encounter.';
CREATE TABLE IF NOT EXISTS `custom_ej_creature` (
`id` INT UNSIGNED NOT NULL,
`encounter_id` INT UNSIGNED NOT NULL,
`creature_id` INT UNSIGNED NOT NULL DEFAULT 0,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NOT NULL,
`creature_display_id` INT UNSIGNED NOT NULL DEFAULT 0,
`icon` VARCHAR(255) NOT NULL DEFAULT '',
`difficulty_mask` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_encounter` (`encounter_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: creature card under an encounter.';
CREATE TABLE IF NOT EXISTS `custom_ej_section` (
`id` INT UNSIGNED NOT NULL,
`encounter_id` INT UNSIGNED NOT NULL,
`parent_section_id` INT UNSIGNED NOT NULL DEFAULT 0,
`next_section_id` INT UNSIGNED NOT NULL DEFAULT 0,
`sub_section_id` INT UNSIGNED NOT NULL DEFAULT 0,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NOT NULL,
`creature_display_id` INT UNSIGNED NOT NULL DEFAULT 0,
`description_spell_id` INT UNSIGNED NOT NULL DEFAULT 0,
`icon_spell_id` INT UNSIGNED NOT NULL DEFAULT 0,
`icon` VARCHAR(255) NULL,
`flags` INT UNSIGNED NOT NULL DEFAULT 0,
`icon_flags` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
`type` INT UNSIGNED NOT NULL DEFAULT 0,
`difficulty_mask` INT UNSIGNED NOT NULL DEFAULT 0,
`creature_id` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_encounter` (`encounter_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: encounter section (Overview/Abilities tree).';
CREATE TABLE IF NOT EXISTS `custom_ej_loot` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`encounter_id` INT UNSIGNED NOT NULL,
`item_id` INT UNSIGNED NOT NULL,
`difficulty_mask` INT UNSIGNED NOT NULL DEFAULT 0,
`faction_mask` INT NOT NULL DEFAULT -1,
`class_mask` INT NOT NULL DEFAULT -1,
`flags` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_encounter` (`encounter_id`),
KEY `idx_item` (`item_id`),
UNIQUE KEY `uk_loot_dedup`
(`encounter_id`, `item_id`, `difficulty_mask`, `faction_mask`, `class_mask`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: explicit loot table per encounter.';
@@ -0,0 +1,179 @@
--
-- Encounter Journal: minimal sample data.
-- Showcases one dungeon (Deadmines, map 36) and one raid (Naxxramas, map 533),
-- with one encounter each, a creature card, a section tree (overview + 1 ability)
-- and a couple of loot rows. Loot item_ids reference real WotLK item_template entries.
--
-- IDs used by the sample stay above 9000 to avoid collisions with future imports.
--
START TRANSACTION;
DELETE FROM `custom_ej_loot` WHERE `encounter_id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_section` WHERE `encounter_id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_creature` WHERE `encounter_id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_encounter` WHERE `id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_tier_instance` WHERE `instance_id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_instance` WHERE `id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_tier` WHERE `id` BETWEEN 9000 AND 9999;
-- ---------------------------------------------------------------------------
-- Tiers
-- ---------------------------------------------------------------------------
INSERT INTO `custom_ej_tier` (`id`, `name`, `flags`, `sort_order`) VALUES
(9001, 'Classic', 0, 10),
(9002, 'Wrath of the Lich King', 0, 20);
-- ---------------------------------------------------------------------------
-- Dungeon: The Deadmines (map 36)
-- ---------------------------------------------------------------------------
-- instance.flags = 0 (dungeon)
INSERT INTO `custom_ej_instance`
(`id`, `name`, `description`, `button_icon`, `small_button_icon`,
`background`, `lore_background`, `map_id`, `area_id`, `world_map_area_id`,
`flags`, `sort_order`)
VALUES
(9001, 'The Deadmines',
'Beneath the quiet town of Moonbrook lies the Defias Brotherhood''s '
'shipyard, where Edwin VanCleef forges his revenge against the city of '
'Stormwind.',
'Interface\\EncounterJournal\\UI-EJ-DUNGEONBUTTON-DEADMINES',
'Interface\\EncounterJournal\\UI-EJ-SMALLBUTTON-DEADMINES',
'Interface\\EncounterJournal\\UI-EJ-DUNGEON-DEADMINES',
'',
36, 1581, 0,
0, 10);
INSERT INTO `custom_ej_tier_instance` (`tier_id`, `instance_id`, `sort_order`)
VALUES (9001, 9001, 10);
-- Encounter: Edwin VanCleef
INSERT INTO `custom_ej_encounter`
(`id`, `instance_id`, `name`, `description`,
`map_position_x`, `map_position_y`, `floor_index`, `world_map_area_id`,
`first_section_id`, `difficulty_mask`, `flags`, `sort_order`)
VALUES
(9001, 9001, 'Edwin VanCleef',
'Once a master stonemason, VanCleef built much of postwar Stormwind. '
'Cheated of his pay, he founded the Defias Brotherhood to take revenge.',
0.85, 0.65, 1, 0,
9001, 0x1, 0, 100);
-- Creature card
INSERT INTO `custom_ej_creature`
(`id`, `encounter_id`, `creature_id`, `name`, `description`,
`creature_display_id`, `icon`, `difficulty_mask`, `sort_order`)
VALUES
(9001, 9001, 639, 'Edwin VanCleef',
'Leader of the Defias Brotherhood.',
822, '', 0x1, 10);
-- Sections
-- Tree: 9001 (Overview) -> next 9002 (Abilities)
-- sub 9003 (Strike)
INSERT INTO `custom_ej_section`
(`id`, `encounter_id`, `parent_section_id`, `next_section_id`,
`sub_section_id`, `name`, `description`,
`creature_display_id`, `description_spell_id`, `icon_spell_id`,
`icon`, `flags`, `icon_flags`, `sort_order`, `type`,
`difficulty_mask`, `creature_id`)
VALUES
(9001, 9001, 0, 9002, 0,
'Overview',
'VanCleef calls in waves of his lieutenants before engaging the party '
'directly. Stay mobile and interrupt his casts.',
0, 0, 0, NULL, 0x1, 0, 10, 0, 0x1, 639),
(9002, 9001, 0, 0, 9003,
'Abilities',
'', 0, 0, 0, NULL, 0, 0, 20, 0, 0x1, 639),
(9003, 9001, 9002, 0, 0,
'Strike',
'A heavy melee strike on VanCleef''s current target.',
0, 6975, 6975, NULL, 0, 0, 10, 0, 0x1, 639);
-- Loot (real WotLK items): Cruel Barb (mh sword), Buccaneer''s Bracers
INSERT INTO `custom_ej_loot`
(`encounter_id`, `item_id`, `difficulty_mask`, `faction_mask`,
`class_mask`, `flags`, `sort_order`)
VALUES
(9001, 1937, 0x1, -1, -1, 0, 10), -- Cruel Barb
(9001, 2169, 0x1, -1, -1, 0, 20); -- Buccaneer''s Bracers
-- ---------------------------------------------------------------------------
-- Raid: Naxxramas (map 533)
-- ---------------------------------------------------------------------------
-- instance.flags = 0x10 (raid)
INSERT INTO `custom_ej_instance`
(`id`, `name`, `description`, `button_icon`, `small_button_icon`,
`background`, `lore_background`, `map_id`, `area_id`, `world_map_area_id`,
`flags`, `sort_order`)
VALUES
(9101, 'Naxxramas',
'Necropolis of the lich Kel''Thuzad, anchored above the Dragonblight. '
'Liberated and reactivated as a fortress of the Scourge.',
'Interface\\EncounterJournal\\UI-EJ-RAIDBUTTON-NAXXRAMAS',
'Interface\\EncounterJournal\\UI-EJ-SMALLBUTTON-NAXXRAMAS',
'Interface\\EncounterJournal\\UI-EJ-RAID-NAXXRAMAS',
'',
533, 3456, 0,
0x10, 10);
INSERT INTO `custom_ej_tier_instance` (`tier_id`, `instance_id`, `sort_order`)
VALUES (9002, 9101, 10);
-- Encounter: Patchwerk (10/25, normal/heroic combinations -> mask 0xF)
INSERT INTO `custom_ej_encounter`
(`id`, `instance_id`, `name`, `description`,
`map_position_x`, `map_position_y`, `floor_index`, `world_map_area_id`,
`first_section_id`, `difficulty_mask`, `flags`, `sort_order`)
VALUES
(9101, 9101, 'Patchwerk',
'Kel''Thuzad''s favored creation: an abomination stitched from the '
'corpses of more than a hundred victims.',
0.40, 0.50, 1, 0,
9101, 0xF, 0, 100);
INSERT INTO `custom_ej_creature`
(`id`, `encounter_id`, `creature_id`, `name`, `description`,
`creature_display_id`, `icon`, `difficulty_mask`, `sort_order`)
VALUES
(9101, 9101, 16028, 'Patchwerk',
'Kel''Thuzad''s flagship abomination.',
23323, '', 0xF, 10);
INSERT INTO `custom_ej_section`
(`id`, `encounter_id`, `parent_section_id`, `next_section_id`,
`sub_section_id`, `name`, `description`,
`creature_display_id`, `description_spell_id`, `icon_spell_id`,
`icon`, `flags`, `icon_flags`, `sort_order`, `type`,
`difficulty_mask`, `creature_id`)
VALUES
(9101, 9101, 0, 9102, 0,
'Overview',
'Tank-and-spank with a fixed enrage timer. Two well-geared tanks must '
'soak Hateful Strikes; healers run hot from the start.',
0, 0, 0, NULL, 0x1, 0, 10, 0, 0xF, 16028),
(9102, 9101, 0, 0, 9103,
'Abilities',
'', 0, 0, 0, NULL, 0, 0, 20, 0, 0xF, 16028),
(9103, 9101, 9102, 0, 0,
'Hateful Strike',
'Patchwerk strikes the highest-health target within melee range for '
'massive physical damage.',
0, 28308, 28308, NULL, 0, 0, 10, 0, 0xF, 16028);
-- Loot: a few real Patchwerk drops (10-normal mask 0x1, 25-normal mask 0x2)
INSERT INTO `custom_ej_loot`
(`encounter_id`, `item_id`, `difficulty_mask`, `faction_mask`,
`class_mask`, `flags`, `sort_order`)
VALUES
(9101, 39262, 0x1, -1, -1, 0, 10), -- Stained-Glass Shard of Light
(9101, 39263, 0x1, -1, -1, 0, 20), -- Wraith Spear
(9101, 40548, 0x2, -1, -1, 0, 30), -- Patchwork Bracers (25-man)
(9101, 40549, 0x2, -1, -1, 0, 40); -- Severed Visionary Tendrils (25-man)
COMMIT;
@@ -0,0 +1,125 @@
# EncounterJournal export format
This document fixes the field order inside each row of the JSON / Lua
export. Same order in JSON arrays and Lua tables; the only difference is
the surrounding container syntax.
## `tiers`
Array. Stable order: `sort_order ASC, id ASC`.
```text
[ id, name, flags, sort_order ]
```
## `instances` — keyed by `instance_id`
```text
[
name,
description,
button_icon,
small_button_icon,
background,
lore_background,
map_id,
area_id,
sort_order,
flags,
id,
world_map_area_id
]
```
## `tierInstances` — keyed by `tier_id`
```text
[ instance_id, instance_id, ... ]
```
Stable order: `tier_instance.sort_order ASC, instance_id ASC`.
## `encounters` — keyed by `instance_id`
Array of rows; stable order `sort_order ASC, id ASC`.
```text
[
id,
name,
description,
map_position_x,
map_position_y,
floor_index,
world_map_area_id,
first_section_id,
instance_id,
difficulty_mask,
flags,
sort_order
]
```
## `creatures` — keyed by `encounter_id`
Array of rows; stable order `sort_order ASC, id ASC`.
```text
[
name,
description,
creature_display_id,
icon,
encounter_id,
sort_order,
id,
creature_id,
difficulty_mask
]
```
## `sections` — keyed by `section_id`
Single row per key.
```text
[
id,
name,
description,
creature_display_id,
description_spell_id,
icon_spell_id,
encounter_id,
next_section_id,
sub_section_id,
parent_section_id,
flags,
icon_flags,
sort_order,
type,
difficulty_mask,
creature_id
]
```
## `items` — keyed by `encounter_id`
Array of rows; stable order `sort_order ASC, id ASC`.
```text
[
item_id,
encounter_id,
difficulty_mask,
faction_mask,
flags,
id,
class_mask
]
```
> Item name / icon / quality are intentionally **not** part of this
> payload. The MoonWellClient EncounterJournal addon resolves them via
> the regular `GetItemInfo` API; if you need an offline cache, generate
> a separate `ItemCache.lua` from `item_template`.
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
MOD_ENCOUNTER_JOURNAL_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/" && pwd )"
@@ -0,0 +1,182 @@
/*
* This file is part of the AzerothCore Project. See AUTHORS file for Copyright information.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License v2 as published by the
* Free Software Foundation.
*/
#include "EncounterJournalMgr.h"
#include "Chat.h"
#include "CommandScript.h"
#include <string>
#include <vector>
using namespace Acore::ChatCommands;
class encounter_journal_commandscript : public CommandScript
{
public:
encounter_journal_commandscript() : CommandScript("encounter_journal_commandscript") { }
ChatCommandTable GetCommands() const override
{
static ChatCommandTable ejCommandTable =
{
{ "reload", HandleReloadCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "export", HandleExportCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "validate", HandleValidateCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "importloot", HandleImportLootCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "importskeleton", HandleImportSkeletonCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "importall", HandleImportAllCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "info", HandleInfoCommand, SEC_GAMEMASTER, Console::Yes },
};
static ChatCommandTable commandTable =
{
{ "ej", ejCommandTable },
};
return commandTable;
}
static bool HandleReloadCommand(ChatHandler* handler)
{
sEncounterJournalMgr->Load();
handler->PSendSysMessage("Encounter Journal reloaded ({} instances, "
"{} encounters, {} sections, {} loot rows).",
sEncounterJournalMgr->InstanceCount(),
sEncounterJournalMgr->EncounterCount(),
sEncounterJournalMgr->SectionCount(),
sEncounterJournalMgr->LootCount());
return true;
}
static bool HandleExportCommand(ChatHandler* handler)
{
sEncounterJournalMgr->Load();
std::vector<std::string> paths;
std::string error;
if (!sEncounterJournalMgr->Export(paths, error))
{
handler->SendErrorMessage("Encounter Journal export failed: {}", error);
return false;
}
handler->PSendSysMessage("Encounter Journal exported {} file(s):", paths.size());
for (std::string const& p : paths)
handler->PSendSysMessage(" {}", p);
return true;
}
static bool HandleValidateCommand(ChatHandler* handler)
{
sEncounterJournalMgr->Load();
std::vector<std::string> errors;
std::size_t count = sEncounterJournalMgr->Validate(errors);
if (count == 0)
{
handler->PSendSysMessage("Encounter Journal: validation OK "
"({} instances, {} encounters, {} sections, {} loot rows).",
sEncounterJournalMgr->InstanceCount(),
sEncounterJournalMgr->EncounterCount(),
sEncounterJournalMgr->SectionCount(),
sEncounterJournalMgr->LootCount());
return true;
}
handler->PSendSysMessage("Encounter Journal: {} issue(s):", count);
// Cap output to avoid flooding chat in the in-game case.
std::size_t const limit = 50;
for (std::size_t i = 0; i < errors.size() && i < limit; ++i)
handler->PSendSysMessage(" {}", errors[i]);
if (errors.size() > limit)
handler->PSendSysMessage(" ... and {} more (run from console for full list)",
errors.size() - limit);
return true;
}
static bool HandleImportLootCommand(ChatHandler* handler)
{
sEncounterJournalMgr->Load();
std::vector<std::string> warnings;
std::size_t inserted = sEncounterJournalMgr->ImportLoot(warnings);
handler->PSendSysMessage(
"Encounter Journal loot import: inserted {} row(s).", inserted);
if (!warnings.empty())
{
handler->PSendSysMessage("{} warning(s):", warnings.size());
std::size_t const limit = 25;
for (std::size_t i = 0; i < warnings.size() && i < limit; ++i)
handler->PSendSysMessage(" {}", warnings[i]);
if (warnings.size() > limit)
handler->PSendSysMessage(" ... and {} more",
warnings.size() - limit);
}
if (inserted > 0)
handler->PSendSysMessage("Reload the manager (.ej reload) to "
"see the new rows in subsequent .ej export/validate.");
return true;
}
static bool HandleImportSkeletonCommand(ChatHandler* handler)
{
sEncounterJournalMgr->Load();
MoonWell::EncounterJournal::Mgr::SkeletonCounts c;
std::vector<std::string> warnings;
std::size_t total = sEncounterJournalMgr->ImportSkeleton(c, warnings);
handler->PSendSysMessage(
"Encounter Journal skeleton import: {} new rows.", total);
handler->PSendSysMessage(
" tiers={} instances={} tier_instances={}",
c.tiers, c.instances, c.tierInstances);
handler->PSendSysMessage(
" encounters={} creatures={} sections={}",
c.encounters, c.creatures, c.sections);
if (!warnings.empty())
{
handler->PSendSysMessage("{} warning(s):", warnings.size());
std::size_t const limit = 25;
for (std::size_t i = 0; i < warnings.size() && i < limit; ++i)
handler->PSendSysMessage(" {}", warnings[i]);
if (warnings.size() > limit)
handler->PSendSysMessage(" ... and {} more",
warnings.size() - limit);
}
if (total > 0)
handler->PSendSysMessage("Run .ej importloot next, then .ej export.");
return true;
}
static bool HandleImportAllCommand(ChatHandler* handler)
{
HandleImportSkeletonCommand(handler);
sEncounterJournalMgr->Load();
return HandleImportLootCommand(handler);
}
static bool HandleInfoCommand(ChatHandler* handler)
{
handler->PSendSysMessage("Encounter Journal cache:");
handler->PSendSysMessage(" tiers: {}", sEncounterJournalMgr->TierCount());
handler->PSendSysMessage(" instances: {}", sEncounterJournalMgr->InstanceCount());
handler->PSendSysMessage(" encounters: {}", sEncounterJournalMgr->EncounterCount());
handler->PSendSysMessage(" creatures: {}", sEncounterJournalMgr->CreatureCount());
handler->PSendSysMessage(" sections: {}", sEncounterJournalMgr->SectionCount());
handler->PSendSysMessage(" loot rows: {}", sEncounterJournalMgr->LootCount());
return true;
}
};
void AddSC_encounter_journal_commands()
{
new encounter_journal_commandscript();
}
@@ -0,0 +1,83 @@
/*
* This file is part of the AzerothCore Project. See AUTHORS file for Copyright information.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License v2 as published by the
* Free Software Foundation.
*/
#include "Config.h"
#include "EncounterJournalMgr.h"
#include "Log.h"
#include "ScriptMgr.h"
void AddSC_encounter_journal_commands();
class encounter_journal_world_script : public WorldScript
{
public:
encounter_journal_world_script()
: WorldScript("encounter_journal_world_script",
{ WORLDHOOK_ON_LOAD_CUSTOM_DATABASE_TABLE })
{ }
void OnLoadCustomDatabaseTable() override
{
if (!sConfigMgr->GetOption<bool>("EncounterJournal.Enable", true))
{
LOG_INFO("server.loading", ">> mod-encounter-journal disabled "
"via EncounterJournal.Enable=0.");
return;
}
sEncounterJournalMgr->Load();
if (sConfigMgr->GetOption<bool>("EncounterJournal.ImportOnStart", false))
{
MoonWell::EncounterJournal::Mgr::SkeletonCounts sc;
std::vector<std::string> warnings;
std::size_t inserted =
sEncounterJournalMgr->ImportSkeleton(sc, warnings);
LOG_INFO("server.loading",
">> mod-encounter-journal: ImportOnStart added {} skeleton rows "
"(tiers={}, instances={}, encounters={}, creatures={}, sections={}).",
inserted, sc.tiers, sc.instances, sc.encounters,
sc.creatures, sc.sections);
std::vector<std::string> lootWarn;
std::size_t lootRows = sEncounterJournalMgr->ImportLoot(lootWarn);
LOG_INFO("server.loading",
">> mod-encounter-journal: ImportOnStart added {} loot rows.",
lootRows);
sEncounterJournalMgr->Load();
}
if (!sConfigMgr->GetOption<bool>("EncounterJournal.ExportOnStart", false))
return;
std::vector<std::string> paths;
std::string error;
if (!sEncounterJournalMgr->Export(paths, error))
{
LOG_ERROR("server.loading",
">> mod-encounter-journal: ExportOnStart failed: {}", error);
return;
}
for (std::string const& p : paths)
LOG_INFO("server.loading",
">> mod-encounter-journal: wrote {}", p);
}
};
void Addmod_encounter_journalScripts()
{
if (!sConfigMgr->GetOption<bool>("EncounterJournal.Enable", true))
return;
new encounter_journal_world_script();
AddSC_encounter_journal_commands();
LOG_INFO("server.loading",
">> mod-encounter-journal scripts registered (.ej commands available).");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
/*
* This file is part of the AzerothCore Project. See AUTHORS file for Copyright information.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License v2 as published by the
* Free Software Foundation.
*/
#ifndef MOONWELL_ENCOUNTER_JOURNAL_MGR_H
#define MOONWELL_ENCOUNTER_JOURNAL_MGR_H
#include "Define.h"
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace MoonWell::EncounterJournal
{
struct Tier
{
uint32 id = 0;
std::string name;
uint32 flags = 0;
int32 sortOrder = 0;
};
struct Instance
{
uint32 id = 0;
std::string name;
std::string description;
std::string buttonIcon;
std::string smallButtonIcon;
std::string background;
std::string loreBackground;
uint32 mapId = 0;
uint32 areaId = 0;
uint32 worldMapAreaId = 0;
uint32 flags = 0;
int32 sortOrder = 0;
};
struct TierInstance
{
uint32 tierId = 0;
uint32 instanceId = 0;
int32 sortOrder = 0;
};
struct Encounter
{
uint32 id = 0;
uint32 instanceId = 0;
std::string name;
std::string description;
float mapPositionX = 0.f;
float mapPositionY = 0.f;
uint32 floorIndex = 1;
uint32 worldMapAreaId = 0;
uint32 firstSectionId = 0;
uint32 difficultyMask = 0;
uint32 flags = 0;
int32 sortOrder = 0;
};
struct Creature
{
uint32 id = 0;
uint32 encounterId = 0;
uint32 creatureId = 0;
std::string name;
std::string description;
uint32 creatureDisplayId = 0;
std::string icon;
uint32 difficultyMask = 0;
int32 sortOrder = 0;
};
struct Section
{
uint32 id = 0;
uint32 encounterId = 0;
uint32 parentSectionId = 0;
uint32 nextSectionId = 0;
uint32 subSectionId = 0;
std::string name;
std::string description;
uint32 creatureDisplayId = 0;
uint32 descriptionSpellId = 0;
uint32 iconSpellId = 0;
std::string icon; // empty when SQL value is NULL or ''
uint32 flags = 0;
uint32 iconFlags = 0;
int32 sortOrder = 0;
uint32 type = 0;
uint32 difficultyMask = 0;
uint32 creatureId = 0;
};
struct Loot
{
uint32 id = 0;
uint32 encounterId = 0;
uint32 itemId = 0;
uint32 difficultyMask = 0;
int32 factionMask = -1;
int32 classMask = -1;
uint32 flags = 0;
int32 sortOrder = 0;
};
class Mgr
{
public:
static Mgr* instance();
// Loads all custom_ej_* tables into in-memory caches.
// Safe to call repeatedly: previous data is dropped first.
// Returns true on success (does not fail when tables are empty).
bool Load();
// Drops cached data.
void Unload();
// Writes the configured JSON file (always) and Lua file
// (when Export.LuaFile is non-empty). On success @writtenPaths
// contains the absolute paths created/overwritten.
bool Export(std::vector<std::string>& writtenPaths, std::string& error);
// Validates relational integrity. Returns the number of errors
// found and pushes one human-readable line per error into @errors.
std::size_t Validate(std::vector<std::string>& errors);
// Imports loot rows from creature_loot_template via creature_template.lootid
// for each creature card whose creature_id != 0. Reference rows are
// followed one level. Existing rows in custom_ej_loot are skipped via
// INSERT IGNORE on the unique key. Returns the number of inserted rows.
std::size_t ImportLoot(std::vector<std::string>& warnings);
// Generates skeleton EJ rows from existing AzerothCore tables:
// - custom_ej_tier from MapEntry.expansionID buckets
// - custom_ej_instance from instance_template + sMapStore (name, type)
// - custom_ej_encounter from instance_encounters (creditType=0 only)
// - custom_ej_creature from creature_template referenced by encounters
// - custom_ej_section one Overview placeholder per encounter
// Existing rows are kept (INSERT IGNORE on PK). Returns total inserted
// rows; per-table counts are reported via @counts (tier/instance/
// tier_instance/encounter/creature/section).
struct SkeletonCounts
{
std::size_t tiers = 0;
std::size_t instances = 0;
std::size_t tierInstances = 0;
std::size_t encounters = 0;
std::size_t creatures = 0;
std::size_t sections = 0;
};
std::size_t ImportSkeleton(SkeletonCounts& counts,
std::vector<std::string>& warnings);
std::size_t TierCount() const { return _tiers.size(); }
std::size_t InstanceCount() const { return _instances.size(); }
std::size_t EncounterCount() const { return _encounters.size(); }
std::size_t CreatureCount() const { return _creatures.size(); }
std::size_t SectionCount() const { return _sections.size(); }
std::size_t LootCount() const { return _loot.size(); }
private:
Mgr() = default;
std::string ResolveExportPath(std::string const& filename) const;
std::string BuildJson() const;
std::string BuildLua(std::string const& globalName) const;
std::vector<Tier> _tiers;
std::vector<TierInstance> _tierInstances;
std::unordered_map<uint32, Instance> _instances;
std::unordered_map<uint32, Encounter> _encounters;
std::unordered_map<uint32, Creature> _creatures;
std::unordered_map<uint32, Section> _sections;
std::vector<Loot> _loot;
// Sorted access caches (by sort_order, id). Filled at end of Load().
std::vector<uint32> _instancesSorted;
std::unordered_map<uint32, std::vector<uint32>> _encountersByInstance;
std::unordered_map<uint32, std::vector<uint32>> _creaturesByEncounter;
std::unordered_map<uint32, std::vector<std::size_t>> _lootByEncounter;
std::unordered_map<uint32, std::vector<uint32>> _instancesByTier;
};
}
#define sEncounterJournalMgr ::MoonWell::EncounterJournal::Mgr::instance()
#endif // MOONWELL_ENCOUNTER_JOURNAL_MGR_H
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
Convert an EncounterJournal JSON dump into a Lua file the
MoonWellClient EncounterJournal addon can `loadstring`/`include`.
Field order, key sort and structure mirror what the C++ exporter
in mod-encounter-journal writes via .ej export. Use this when the
JSON is produced outside the worldserver (e.g., from a CI job that
queries acore_world directly).
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Iterable
def lua_str(value: Any) -> str:
s = str(value)
s = s.replace("\\", "\\\\")
s = s.replace("'", "\\'")
s = s.replace("\n", "\\n")
s = s.replace("\r", "\\r")
return f"'{s}'"
def lua_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
# Integers stay int; floats use repr for round-trip stability.
if isinstance(value, float) and value.is_integer():
return str(int(value))
return repr(value) if isinstance(value, float) else str(value)
if value is None:
return "nil"
return lua_str(value)
def lua_array(values: Iterable[Any]) -> str:
return "{ " + ", ".join(lua_value(v) for v in values) + " }"
def emit_keyed_rows(out: list[str], name: str, mapping: dict, indent: str) -> None:
out.append(f"{indent}{name} = {{\n")
for key in sorted(mapping.keys(), key=lambda k: int(k)):
rows = mapping[key]
if rows and isinstance(rows[0], list):
out.append(f"{indent} [{int(key)}] = {{\n")
for row in rows:
out.append(f"{indent} {lua_array(row)},\n")
out.append(f"{indent} }},\n")
else:
# Single row OR flat list of ids (tierInstances).
out.append(f"{indent} [{int(key)}] = {lua_array(rows)},\n")
out.append(f"{indent}}},\n")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", "-i", required=True,
help="Path to EncounterJournalData.json")
parser.add_argument("--output", "-o", required=True,
help="Path to write EncounterJournalData.lua")
parser.add_argument("--global", "-g", dest="global_name",
default="MoonWellEncounterJournalData",
help="Name of the Lua global to assign")
args = parser.parse_args()
src = Path(args.input)
if not src.exists():
print(f"input file not found: {src}", file=sys.stderr)
return 1
with src.open("r", encoding="utf-8") as f:
data = json.load(f)
out: list[str] = []
out.append("-- Generated by mod-encounter-journal/tools/json_to_lua.py.\n")
out.append("-- Source of truth: custom_ej_* tables in acore_world.\n\n")
out.append(f"{args.global_name} = {{\n")
# tiers (flat array)
out.append(" tiers = {\n")
for tier in data.get("tiers", []):
out.append(f" {lua_array(tier)},\n")
out.append(" },\n")
# instances : map id -> single row
instances = data.get("instances", {})
out.append(" instances = {\n")
for key in sorted(instances.keys(), key=lambda k: int(k)):
out.append(f" [{int(key)}] = {lua_array(instances[key])},\n")
out.append(" },\n")
# tierInstances : map tier_id -> [instance_id, ...]
emit_keyed_rows(out, "tierInstances", data.get("tierInstances", {}), " ")
# encounters : map instance_id -> [[row], ...]
emit_keyed_rows(out, "encounters", data.get("encounters", {}), " ")
# creatures : map encounter_id -> [[row], ...]
emit_keyed_rows(out, "creatures", data.get("creatures", {}), " ")
# sections : map section_id -> single row
sections = data.get("sections", {})
out.append(" sections = {\n")
for key in sorted(sections.keys(), key=lambda k: int(k)):
out.append(f" [{int(key)}] = {lua_array(sections[key])},\n")
out.append(" },\n")
# items : map encounter_id -> [[row], ...]
emit_keyed_rows(out, "items", data.get("items", {}), " ")
out.append("}\n")
Path(args.output).write_text("".join(out), encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())